히어로 3D 를 파이프라인 게이트로 — 형태에 이유를 준다

'3D 라고 그냥 큐브만 돌면 안 된다'는 지적에서 시작해 조사부터 했다.
research/three/06~08 에 사례 32건, GLB 소스 17곳, 방향 제안 3개가 있다.

조사 결론: 추상이다. GLB 는 쓰지 않는다.
- 구체 오브젝트의 성립 조건 3개(물리적으로 존재 / 각도가 정보 /
  오브젝트가 하나로 정해짐)를 우리 브리프는 하나도 충족하지 않는다.
  규칙 묶음은 돌려봐도 새 정보가 없다
- 실측: DamagedHelmet.glb 3,685KB 중 텍스처가 87%라 Draco 를 걸어도 -13%.
  게다가 디코더만 200KB 로 예산의 67%를 먹는다
- Stripe Globe 는 모델 파일이 0바이트다. 6만 점을 코드로 생성한다.
  지오메트리가 파일이 아니라 코드일 때 추상이 이긴다
- 2026 Awwwards Developer Award 수상작 중에는 3D 를 아예 안 쓴 것도 있다

채택안: 파이프라인 게이트
- 유리 판 여섯 장이 깊이 축에 정면으로 선다. 이 스킬의 여섯 단계다
- 스크롤하면 카메라가 문을 하나씩 통과하고 지나온 판은 뒤에 겹친다
- 판이 여섯인 이유가 생겼다. 아래 Pipeline 섹션과 같은 수다
- 순서는 이 제품의 본체다('순서를 바꾸지 마라'가 절대 규칙). 순서를
  깊이로 옮기는 것은 은유가 아니라 직역이다

스크롤 연동은 이미 도는 렌더 루프에서 위치를 한 번 읽는다.
addEventListener('scroll') 을 쓰지 않는다(하드 게이트 10). 핀도 없다.
This commit is contained in:
Yun Chan 2026-08-20 12:32:09 +09:00
parent ed1f335a89
commit 17d04ee2bb
4 changed files with 572 additions and 18 deletions

View file

@ -1,7 +1,15 @@
/**
* 3D .
* 3D .
*
* , .
* . .
* , .
*
* . ("순서를 바꾸지 마라"
* ), .
* . Pipeline .
*
* (research/three/08-proposal.md):
* . GLB . .
*
* :
* ,
@ -96,6 +104,7 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
uTime: { value: 0 },
uAspect: { value: 1 },
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
uProgress: { value: 0 },
uA: { value: accent },
uB: { value: glow },
};
@ -113,7 +122,8 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
precision mediump float;
varying vec2 vUv;
uniform float uTime; uniform float uAspect;
uniform vec2 uPointer; uniform vec3 uA; uniform vec3 uB;
uniform vec2 uPointer; uniform float uProgress;
uniform vec3 uA; uniform vec3 uB;
${NOISE}
void main() {
vec2 p = vUv; p.x *= uAspect;
@ -123,8 +133,9 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
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 c2 = vec2(0.82 * uAspect, 0.28);
// 통과할수록 빛이 뒤로 물러난다
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);
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);
@ -193,14 +204,18 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
`,
});
// 판은 깊이 축에 정면으로 선다. 떠다니는 사각형이 아니라 통과하는 문이다.
const GATE_GAP = 5.2;
const panes: import("three").Mesh[] = [];
const geo = new THREE.PlaneGeometry(3.1, 4.3, 1, 1);
const geo = new THREE.PlaneGeometry(7.4, 5.2, 1, 1);
for (let i = 0; i < PANE_COUNT; i++) {
const m = new THREE.Mesh(geo, paneMat);
const a = (i / PANE_COUNT) * Math.PI * 2;
m.position.set(Math.cos(a) * 3.6, Math.sin(a * 1.7) * 1.5, -i * 0.9);
m.rotation.set(0.1 + i * 0.05, a * 0.5, a * 0.22);
m.userData["seed"] = a;
// 좌우로 조금씩 어긋나게 세워 통과할 때 옆면이 스친다
const sway = (i % 2 === 0 ? 1 : -1) * (0.5 + i * 0.12);
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.userData["index"] = i;
m.userData["sway"] = sway;
scene.add(m);
panes.push(m);
}
@ -246,20 +261,36 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
const t = ms * 0.001;
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;
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);
// 카메라가 여섯 개의 문을 차례로 통과한다
const depth = (PANE_COUNT - 1) * GATE_GAP;
camera.position.z = 8.5 - progress * (depth + 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);
for (const m of panes) {
const seed = m.userData["seed"] as number;
m.rotation.y += 0.0016;
m.rotation.z = Math.sin(t * 0.18 + seed) * 0.24;
m.position.y = Math.sin(t * 0.22 + seed * 1.7) * 1.6;
// 포인터가 판을 아주 조금 기울인다
m.rotation.x = 0.1 + (pointer.y - 0.5) * 0.24;
const i = m.userData["index"] as number;
const sway = m.userData["sway"] as number;
// 통과 직전의 판만 미세하게 숨쉰다. 전부 흔들면 다시 부유물이 된다
const dist = Math.abs(m.position.z - camera.position.z);
const near = Math.max(0, 1 - dist / (GATE_GAP * 1.6));
m.rotation.z = sway * 0.018 + Math.sin(t * 0.3 + i) * 0.012 * near;
m.position.y = Math.sin(i * 1.3) * 0.32 + Math.sin(t * 0.24 + i) * 0.06;
}
camera.position.x = (pointer.x - 0.5) * 1.5;
camera.lookAt(0, 0, 0);
// 배경을 먼저 타겟에 굽고, 그 텍스처를 유리가 읽는다
renderer.setRenderTarget(rt);