디자인 SSOT 를 세우고 3D 를 배경 재질로 되돌린다

지적받은 것: 모바일 마진·패딩, 폰트 웨이트, 버튼 스타일 불일치, SSOT,
스크롤바, 모바일 메뉴, 그리고 3D 를 왜 상자에 넣었는지.

3D 의 역할을 잘못 잡았다
  lusion.co 의 "3D 는 액자 안"은 3D 자체가 상품일 때의 규칙이다.
  우리는 절차를 파는데 액자에 넣을 상품이 없어 "저 상자는 뭐냐"가 됐고,
  의미를 게이트 판독기가 자막으로 설명하고 있었다.
  이 씬의 역할은 재질이다. 배경으로 되돌리고 값을 전부 낮췄다 —
  테두리 광 0.85->0.42, 알파 0.72->0.34, UV 경계 0.05->0.17, 이동 60%
  ResizeObserver 로 캔버스를 따라가게 했다. window.resize 만 듣는 동안
  히어로 높이 변화를 놓쳐 배경이 화면의 3분의 2만 덮었다

SSOT
  웨이트 6종(400/500/560/600/620/660) -> 3종. 셋은 토큰에 없던 값이었다
  .btn + btn-solid/btn-quiet. 두 버튼이 높이 36 vs 44, 패딩 16 vs 8,
    테두리 1px vs 0, 웨이트 400 vs 500 이었다
  --header-h. 간격을 clamp 로 바꾸자 관계가 끊겨 h1 이 헤더 뒤로 들어갔다
  --space-5~8 과 --gutter 를 clamp 로. 고정이면 모바일 여백이 화면의 26%
  scrollbar-color 지정

모바일
  좌우 여백 24 -> 32px, 섹션 상하 112 -> 56px
  헤더에 섹션 앵커 4개. 900px 미만은 details 기반 메뉴(JS 0바이트)

패럴랙스는 섹션마다 다른 말을 한다
  히어로   글자 상승 / 배경 하강 — 반대 방향이라야 시차가 보인다
  파이프라인 단계명 고정, 산출물만 지연 — 인과
  재료     타일이 서로 다른 깊이 — 표면은 한 겹이 아니다
  프리플라이트 뒤 항목일수록 늦게 — 로그는 흐른다
  수치     없음. 의도적 정지 — 계기판의 바늘은 떨지 않는다

스킬
  three.md  §0-A 를 역할별로 갈랐다. "액자 안"만 적어둔 것이 잘못된
            일반화였고 내가 거기 그대로 걸렸다. 재질일 때의 값 교정을 실측으로 남김
  tokens.md 웨이트·컨트롤·헤더 높이·반응형 간격 — 토큰이 아예 없던 네 축
  motion.md 섹션별 패럴랙스 배정표. 움직이지 않는 것도 결정이다

390px 실렌더: 가로 스크롤 0, 대비 실패 0, 웨이트 3종, radius 2종. 테스트 22개 통과
This commit is contained in:
Yun Chan 2026-08-20 14:15:30 +09:00
parent 4b853355e3
commit 9c9ab2de7e
18 changed files with 606 additions and 231 deletions

View file

@ -139,15 +139,17 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
float f1 = fbm(p * 1.8 + vec2(t, -t * 0.6));
float f2 = fbm(p * 2.3 - vec2(t * 0.8, t * 1.1) + f1 * 0.55);
// 통과할수록 빛이 뒤로 물러난다
vec2 c1 = mix(vec2(0.30 * uAspect, 0.44), m, 0.20) + vec2(0.0, uProgress * 0.18);
vec2 c2 = vec2(0.82 * uAspect, 0.28) - vec2(uProgress * 0.12, 0.0);
// 통과할수록 빛이 뒤로 물러난다.
// 중심을 화면 세로 중앙에 두고 반경을 넓게 잡는다 — 좁으면
// 히어로 위아래가 검게 남아 배경이 '떠 있는 덩어리'로 보인다.
vec2 c1 = mix(vec2(0.34 * uAspect, 0.52), m, 0.20) + vec2(0.0, uProgress * 0.14);
vec2 c2 = vec2(0.78 * uAspect, 0.44) - vec2(uProgress * 0.10, 0.0);
float a = smoothstep(0.72, 0.08, distance(p, c1) + f1 * 0.36);
float b = smoothstep(0.66, 0.06, distance(p, c2) + f2 * 0.32);
float a = smoothstep(1.05, 0.05, distance(p, c1) + f1 * 0.34);
float b = smoothstep(0.95, 0.04, distance(p, c2) + f2 * 0.30);
vec3 col = uA * a + uB * b * 0.95;
float alpha = clamp(a * 1.2 + b, 0.0, 1.0) * 0.9;
vec3 col = uA * a + uB * b * 0.9;
float alpha = clamp(a * 1.15 + b, 0.0, 1.0) * 0.78;
gl_FragColor = vec4(col, alpha);
}
`,
@ -204,8 +206,11 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
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));
float edge = 1.0 - smoothstep(0.0, 0.17, min(e.x, e.y));
edge *= edge;
// 화면 좌표를 굴절시켜 뒤 텍스처를 샘플링한다
vec2 screen = gl_FragCoord.xy / uResolution;
@ -215,8 +220,10 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
) - 0.5;
vec3 back = texture2D(uScene, screen + flow * 0.06 + n.xy * 0.05).rgb;
vec3 col = back * 1.04 + uEdge * (fres * 0.32 + edge * 0.85);
float alpha = 0.14 + fres * 0.26 + edge * 0.72;
// 배경이다. 테두리 광을 절반으로 낮춘다 — 액자 안에서 주인공일 때의
// 값을 그대로 두면 글자 뒤에서 형광 막대처럼 튄다.
vec3 col = back * 1.03 + uEdge * (fres * 0.20 + edge * 0.30);
float alpha = 0.12 + fres * 0.18 + edge * 0.32;
gl_FragColor = vec4(col, alpha);
}
`,
@ -230,7 +237,7 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
// 지나가는 것으로 보인다 — 실제로 그렇게 보였다.
// 판을 액자 비율에 맞춰 첫 판이 화면을 덮게 하면, 가까워질수록 좌우가
// 화면 밖으로 빠지고 뒤 판들이 원근으로 남아 터널이 된다.
const GATE_GAP = 6.4;
const GATE_GAP = 4.6;
const panes: import("three").Mesh[] = [];
const geo = new THREE.PlaneGeometry(15, 6, 1, 1);
for (let i = 0; i < PANE_COUNT; i++) {
@ -240,7 +247,7 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
// '쌓아둔 사각형'이 된다. 중심축이 살아야 원근이 터널로 읽힌다.
const sway = (i % 2 === 0 ? 1 : -1) * (0.16 + i * 0.05);
m.position.set(sway, Math.sin(i * 1.3) * 0.32, -i * GATE_GAP);
m.rotation.set(0, sway * 0.05, sway * 0.018);
m.rotation.set(sway * 0.03, sway * 0.11, sway * 0.05);
m.userData["index"] = i;
m.userData["sway"] = sway;
scene.add(m);
@ -264,7 +271,11 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
bgUniforms.uAspect.value = r.width / r.height;
};
fit();
addEventListener("resize", fit, { passive: true });
// window resize 만 듣던 것을 ResizeObserver 로 바꾼다.
// 히어로 높이는 폰트가 늦게 오거나 콘텐츠가 접힐 때도 바뀌는데
// 그건 resize 이벤트가 아니다 — 그때 캔버스가 옛 크기로 남아
// 배경이 화면의 3분의 2만 덮고 아래가 검게 비었다.
new ResizeObserver(fit).observe(canvas);
addEventListener(
"pointermove",
@ -309,7 +320,8 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
// 카메라가 여섯 개의 문을 차례로 통과한다
const depth = (PANE_COUNT - 1) * GATE_GAP;
camera.position.z = 7.2 - progress * (depth + 5);
// 배경은 천천히 흐른다. 전 구간을 다 통과시키면 시선을 끌어 글자와 싸운다.
camera.position.z = 7.2 - progress * depth * 0.6;
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);