소개 페이지를 스킬 전체 경로로 다시 만들고, 배운 것을 스킬에 반영

방향을 글래스로 바꿀 때 2단계 재결정을 하고도 전체 경로로 올리지 않았다.
레퍼런스 없이 재질만 갈아끼운 결과가 스위스 미니멀 뼈대에 유리를 바른
페이지였고, design.md 는 초판 결정을 그대로 담고 있었다. 0단계부터 다시 돌았다.

1단계 — 레퍼런스 3개를 computed style 까지 실측
  R1 zed.dev      컨테이너 1120px 과 전체폭이 교대 · radius 2px 621회
  R2 lusion.co    3D 는 액자 안, 텍스트는 액자 밖 · 배경 흰색 · h1 36px
  R3 Codrops      DOM 이 구조를 소유하고 WebGL 은 uniform 만 만진다

2단계 — 프리셋 swiss-minimal → dark-instrument, 컨셉 "검사대 위의 물체"
        유리는 액자에만 남긴다(헤더 · 검사대 · 명령창)

4단계 — 히어로 재구성. 캔버스를 배경에서 빼 액자 안으로, 텍스트는 밖으로
        판독기가 통과한 게이트를 표시한다(값이 바뀔 때만 DOM 을 쓴다)
        파이프라인을 2열로 열어 625px → 1113px

5단계에서 실제로 걸린 것
  게이트 3  radius 4종 → 2종 (3px / 14px, pill 제거)
  게이트 1  base.css 토큰 밖 rgb() 7곳 → 토큰
  게이트 7  헤더 English 2.59:1 → ink 로 상향
  카운트    히어로 h1 4줄 → 2줄
  셰이더    판이 정면이라 프레넬이 구조적으로 0. 법선을 굽히고 UV 로 테두리
  셰이더    gl_FragCoord 를 렌더 타겟 크기로 나눠 UV 가 1 을 넘고 있었다
  수치      페이지가 실은 "타입 단계 5단계"가 실측 9종이었다. lang 속성이
            스케일을 재정의해 링크 하나가 16px 로 새고 있었다
  측정      대비 스크립트가 반투명 배경을 불투명으로 계산했다. 알파 합성으로 교정

스킬 반영
  three.md    §0-A 배치 원리(3D 는 액자 안) · 프레넬 0 함정 · gl_FragCoord 함정
  preflight.md 대비 측정 시 알파 합성. 검사 도구가 틀리면 통과도 의미가 없다

최종: 하드 게이트 12/12, 대비 실패 0, 320px 오버플로 0, 테스트 22개 통과
This commit is contained in:
Yun Chan 2026-08-20 13:33:28 +09:00
parent eb3ca69b5d
commit 9648d1d82a
15 changed files with 576 additions and 306 deletions

View file

@ -32,6 +32,12 @@ export interface HeroSceneOptions {
/** CSS 커스텀 프로퍼티 이름 */
accentVar: string;
glowVar: string;
/**
* 통과한 게이트가 바뀔 때만 부른다. 프레임마다 부르지 않는다 —
* R3(Codrops HAOQI) 에서 가져온 규칙이다. DOM 은 구조를 소유하고
* WebGL 은 셰이더만 만진다. 매 프레임 DOM 을 쓰면 그 경계가 무너진다.
*/
onGate?: (index: number, total: number) => void;
}
const NOISE = `
@ -64,7 +70,7 @@ export function mountHeroScene(opts: HeroSceneOptions): void {
io.observe(canvas);
}
async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<void> {
async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): Promise<void> {
let THREE: typeof import("three");
try {
THREE = await import("three");
@ -161,7 +167,10 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
uniforms: {
uScene: { value: rt.texture },
uTime: { value: 0 },
uEdge: { value: new THREE.Color("#ffffff") },
// 테두리 광. 흰색을 박지 않고 강조색을 밝게 올려 쓴다 —
// 토큰을 바꾸면 유리 테두리도 같이 따라와야 한다.
uEdge: { value: accent.clone().lerp(new THREE.Color(1, 1, 1), 0.55) },
uResolution: { value: new THREE.Vector2(1, 1) },
},
vertexShader: `
varying vec2 vUv;
@ -183,22 +192,31 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
uniform sampler2D uScene;
uniform float uTime;
uniform vec3 uEdge;
uniform vec2 uResolution;
${NOISE}
void main() {
// 판을 미세하게 굽힌다.
// 완전히 평평한 판이 카메라를 정면으로 보면 dot(n, view) 가 1 이라
// 프레넬이 구조적으로 0 이 된다. 그러면 유리로 읽히게 하는 신호가
// 하나도 남지 않는다 — 이전 판이 흐린 얼룩으로 보였던 이유다.
vec3 n = normalize(vNormalW + vec3((vUv - 0.5) * 0.9, 0.0));
float fres = pow(1.0 - abs(dot(n, normalize(vViewDir))), 2.2);
// 판의 테두리. 평면에서 두께가 보이는 곳은 법선이 아니라 UV 경계다.
vec2 e = min(vUv, 1.0 - vUv);
float edge = 1.0 - smoothstep(0.0, 0.05, min(e.x, e.y));
// 화면 좌표를 굴절시켜 뒤 텍스처를 샘플링한다
vec2 screen = gl_FragCoord.xy / vec2(textureSize(uScene, 0));
vec2 screen = gl_FragCoord.xy / uResolution;
vec2 flow = vec2(
fbm(vUv * 2.4 + uTime * 0.05),
fbm(vUv * 2.4 - uTime * 0.04 + 3.1)
) - 0.5;
vec3 back = texture2D(uScene, screen + flow * 0.07 + vNormalW.xy * 0.035).rgb;
vec3 back = texture2D(uScene, screen + flow * 0.06 + n.xy * 0.05).rgb;
// 가장자리에서 빛이 서는 프레넬
float fres = pow(1.0 - abs(dot(normalize(vNormalW), normalize(vViewDir))), 2.6);
vec3 col = back * 1.06 + uEdge * fres * 0.5;
float alpha = 0.20 + fres * 0.55;
vec3 col = back * 1.04 + uEdge * (fres * 0.32 + edge * 0.85);
float alpha = 0.14 + fres * 0.26 + edge * 0.72;
gl_FragColor = vec4(col, alpha);
}
`,
@ -229,6 +247,9 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
if (!r.width || !r.height) return;
renderer.setSize(r.width, r.height, false);
rt.setSize(Math.round(r.width * dpr * 0.6), Math.round(r.height * dpr * 0.6));
// gl_FragCoord 는 캔버스의 드로잉버퍼 좌표다. 렌더 타겟은 그 0.6배라
// 타겟 크기로 나누면 UV 가 1 을 넘어 텍스처가 잘린다.
renderer.getDrawingBufferSize(paneMat.uniforms["uResolution"]!.value);
camera.aspect = r.width / r.height;
camera.updateProjectionMatrix();
bgUniforms.uAspect.value = r.width / r.height;
@ -262,22 +283,24 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
pointer.lerp(pointerTarget, 0.04);
const rect0 = canvas.getBoundingClientRect();
const progress0 = Math.min(Math.max(-rect0.top / (rect0.height * 1.6), 0), 1);
bgUniforms.uProgress.value = progress0;
// 스크롤 진행도. addEventListener("scroll") 을 쓰지 않는다(하드 게이트 10).
// rect 는 프레임당 한 번만 읽는다. 두 번 읽으면 레이아웃을 두 번 강제한다.
const rect = canvas.getBoundingClientRect();
// 액자가 화면 위로 92% 지나갔을 때 마지막 게이트를 통과하게 잡았다.
// 그 뒤로는 액자가 보이지 않으므로 계속 진행시킬 이유가 없다.
const travel = rect.height * 0.92;
const progress = Math.min(Math.max(-rect.top / travel, 0), 1);
bgUniforms.uProgress.value = progress;
bgUniforms.uTime.value = t;
bgUniforms.uPointer.value.copy(pointer);
paneMat.uniforms["uTime"]!.value = t;
// 스크롤 진행도. addEventListener("scroll") 을 쓰지 않는다(하드 게이트 10).
// 이미 도는 렌더 루프 안에서 위치를 한 번 읽을 뿐이라 추가 비용이 없다.
const rect = canvas.getBoundingClientRect();
const travel = rect.height * 1.6;
const progress = Math.min(Math.max(-rect.top / travel, 0), 1);
onGate?.(Math.min(PANE_COUNT - 1, Math.floor(progress * PANE_COUNT)), PANE_COUNT);
// 카메라가 여섯 개의 문을 차례로 통과한다
const depth = (PANE_COUNT - 1) * GATE_GAP;
camera.position.z = 8.5 - progress * (depth + 6);
camera.position.z = 7.2 - progress * (depth + 5);
camera.position.x = (pointer.x - 0.5) * 1.1;
camera.position.y = (pointer.y - 0.5) * -0.5;
camera.lookAt(camera.position.x * 0.3, 0, camera.position.z - 10);