히어로 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:
parent
ed1f335a89
commit
17d04ee2bb
4 changed files with 572 additions and 18 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue