히어로와 파이프라인이 같은 씬을 쓰던 것을 나눈다

게이트 씬을 파이프라인으로 옮기면서 히어로에도 남겨뒀더니 같은 그림이
페이지에 두 번 나왔다. 역할이 다르면 씬도 달라야 한다.

  히어로     shader-plane 흐름 필드   노이즈를 두 번 접어 빛의 결을 만든다
                                      공간을 만드는 재질. 아무것도 반응 안 한다
  파이프라인 gate-scene 유리판 여섯   통과하면 그 단계가 켜진다. 주인공
  재료       shader-plane 스테이지    필터 타일이 앉는 무대

히어로에는 무거운 씬을 임포트하지 않는다 — three.md §0 대로 셰이더 플레인 하나다.
lib/hero-scene.ts -> lib/gate-scene.ts 로 리네임했다. 이름이 용도와 다르면
다음 사람이 히어로 것인 줄 알고 고친다.

대비 실측: 캔버스 배경 최악 8.46:1(가장 밝은 중앙 여백), h1 자리 14.83:1.
readPixels 로 재려다 전부 0 이 나왔다 — preserveDrawingBuffer: false 면
버퍼가 비어 있다. 스크린샷 픽셀로 쟀고, 글자 없는 영역만 골랐다.

스킬 three.md
  씬을 두 자리에 재사용하지 마라. 역할(주인공/재질/무대)로 나눠라
  파일 이름을 용도와 맞춰라
  WebGL 위 대비는 readPixels 가 아니라 스크린샷 픽셀로 잰다.
  0 을 검정으로 착각하면 대비가 통과한 것처럼 보인다
This commit is contained in:
Yun Chan 2026-08-20 16:11:53 +09:00
parent af3a08397e
commit 91bb4ae9dc
5 changed files with 124 additions and 29 deletions

View file

@ -1,33 +1,20 @@
/**
* 히어로 3D 씬 — 파이프라인 게이트.
* 게이트 씬 — 유리판 여섯 장이 깊이 축에 서고, 스크롤하면 카메라가 통과한다.
*
* 유리 판 여섯 장이 깊이 축에 차례로 서 있다. 이 스킬의 여섯 단계다.
* 스크롤하면 카메라가 판을 하나씩 통과하고, 지나온 판은 뒤에 남아 겹친다.
* 파이프라인 섹션 전용이다. 한때 히어로 배경으로도 썼는데, 그러면 같은 그림이
* 페이지에 두 번 나온다. 그리고 히어로에는 옆에서 반응할 콘텐츠가 없어서
* 의미를 판독기가 자막으로 설명해야 했다 — 그림이 스스로 말하지 못한다는 뜻이다.
* 히어로 배경은 `shader-plane.ts` 의 흐름 필드가 맡는다.
*
* 형태에 이유가 있다. 이 제품이 파는 것은 순서이고("순서를 바꾸지 마라"가
* 절대 규칙이다), 순서를 깊이로 옮기는 것은 은유가 아니라 직역이다.
* 판이 여섯인 이유도 여기서 나온다. 아래 Pipeline 섹션의 여섯 단계와 같은 수다.
*
* 조사 결론(research/three/08-proposal.md): 우리가 파는 것은 무형이라
* 구체 오브젝트가 성립하지 않는다. GLB 는 쓰지 않는다. 지오메트리는 코드다.
*
* 구현 방식과 그 이유:
* 배경 그라디언트를 렌더 타겟에 한 번 그리고, 유리 판이 그 텍스처를
* UV 를 밀어서 샘플링한다. MeshPhysicalMaterial 의 transmission 은
* 매 프레임 씬을 다시 렌더하므로 판 한 장만 있어도 비싸다.
* references/three.md 가 그 비용을 경고한다. 렌더 타겟 한 장이면
* 같은 인상을 훨씬 싸게 얻는다.
*
* 예산 규칙은 그대로 지킨다:
* 성능 게이트는 references/three.md 그대로다.
* - three 는 초기 번들에 없다. 화면에 가까워질 때만 받는다
* - DPR 클램프 1.5
* - DPR 클램프 1.5. 저사양이면 판을 여섯에서 셋으로 줄인다
* - 화면 밖이거나 탭이 숨으면 그리지 않는다
* - 저사양이면 판 수를 줄인다
* - WebGL 이 없거나 모션을 줄이는 설정이면 아예 시작하지 않는다.
* 그때 보이는 CSS 그라디언트가 폴백이 아니라 결과물이다
*/
export interface HeroSceneOptions {
export interface GateSceneOptions {
canvas: HTMLCanvasElement;
/** CSS 커스텀 프로퍼티 이름 */
accentVar: string;
@ -61,7 +48,7 @@ const NOISE = `
}
`;
export function mountHeroScene(opts: HeroSceneOptions): void {
export function mountGateScene(opts: GateSceneOptions): void {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const { canvas } = opts;
@ -76,7 +63,7 @@ export function mountHeroScene(opts: HeroSceneOptions): void {
io.observe(canvas);
}
async function boot({ canvas, accentVar, glowVar, onGate, progressFrom }: HeroSceneOptions): Promise<void> {
async function boot({ canvas, accentVar, glowVar, onGate, progressFrom }: GateSceneOptions): Promise<void> {
let THREE: typeof import("three");
try {
THREE = await import("three");