# three — 입체와 공간

4-3에서 읽는다. **레이아웃과 재질이 끝난 뒤에 온다.** three는 세 번째 도구지 첫 번째 아이디어가 아니다.

## 이 문서를 읽는 법

전부 읽지 마라. 필요한 절만 열어라.

| 상황 | 읽을 곳 |
|---|---|
| **three를 쓸지 아직 안 정했다** | **§0만.** 대부분 여기서 "안 쓴다"로 끝나고, **그게 가장 흔한 정답이다** |
| 쓰기로 했다 | §1 버전 핀 + §2 함정 + **§3 코드 A(지연 로드)** — 이 셋이 최소 필수 |
| 배경 이펙트가 필요하다 | + §4 코드 B (메시 그라디언트) |
| 모바일·저사양을 만난다 | + §5 코드 C (품질 티어) + §8 성능 |
| 이미지 그리드를 강화한다 | + §6 코드 D |
| 다른 패턴을 찾는다 | §7 표에서 고르고 `research/three/02-patterns.md`를 열어라 |
| 감사 직전 | §9 Lighthouse + §10 폴백 + §11 체크리스트 |

§0을 통과하지 못하면 나머지는 읽을 필요가 없다. **"안 쓴다"는 실패가 아니라 이 스킬의 규칙이다.**

---

## 0-A. 배치가 먼저다 — 이 씬은 주인공인가 재질인가

채택을 결정했다면 **어디에 놓을지부터 정해라.** 이 결정이 씬의 품질보다 결과를 크게 바꾼다.
그리고 배치는 하나가 아니다. **먼저 이 씬의 역할을 정해야 한다.**

| 역할 | 배치 | 언제 |
|---|---|---|
| **주인공** | 액자 안. 텍스트는 액자 밖 | 3D 자체가 상품일 때 — 포트폴리오, 프로덕트 뷰어, 3D 스튜디오 |
| **재질** | 배경 전면. 텍스트가 그 위에 앉는다 | 공간감이 목적일 때 — 랜딩, 브랜드 사이트 |

**둘을 섞지 마라.** 재질로 쓸 씬을 액자에 가두면 "저 상자는 뭐냐"는 질문을 받는다.
액자는 안에 든 것이 볼 가치가 있다고 선언하는 장치라서, 그 안에 배경 무늬가 들어 있으면
선언과 내용이 어긋난다. 실측에서 그렇게 만들었다가 되돌렸다.

### 주인공일 때 — 액자 안

> **텍스트를 3D 위에 얹지 마라.**

겹치면 두 가지가 동시에 일어난다. 3D 는 텍스트 가독성을 위해 흐리고 어둡게 눌려 얼룩이 되고,
텍스트는 대비를 확보하려고 글로우·스크림·그림자를 달게 된다. **둘 다 소리치다 둘 다 죽는다.**

실측 근거 — `lusion.co`(3D 프로덕션 스튜디오, 2026-08 측정):

| 관측 | 값 |
|---|---|
| 캔버스 배치 | `border-radius: 15px` 액자 **안**. 페이지 배경과 명확히 분리 |
| 페이지 배경 | **흰색**. 텍스트 색 **2단계**(검정 135회 / 흰 16회) |
| 폰트 패밀리 | **1개** |
| 강조색 | 캔버스 **안**에서만. DOM 에는 순청 4회뿐 |
| h1 크기 | **36px** |

3D 를 가장 잘 쓰는 곳이 타이포를 가장 조용하게 쓴다. **힘이 글자가 아니라 물체에서 나오기 때문에**
h1 이 36px 이어도 히어로가 압도적이다.

겹쳐야만 하는 경우(HAOQI.DESIGN 패턴)는 **DOM 이 구조를 소유하고 WebGL 은 셰이더 uniform 만 만지게** 한다.
기본 상태는 DOM 이 그리고, 상호작용 상태에서만 WebGL 이 나타난다. §6 이 그 구현이다.

캔버스 위에 글자를 올려야 한다면 **하나만** 올려라. 그리고 거기엔 바탕을 깔아라.

### 씬을 두 자리에 재사용하지 마라

의미를 가진 씬을 만들었다면 그 자리는 하나다. 배경이 허전하다고 같은 씬을 다른 섹션에
또 깔면 **같은 그림이 두 번** 나오고, 첫 번째 자리의 의미까지 희석된다.

한 페이지에 씬이 여럿이면 **역할을 나눠라.**

| 역할 | 구현 | 판단 기준 |
|---|---|---|
| 주인공 | 씬(메시·카메라·이동) | 옆의 콘텐츠가 반응한다 |
| 재질 | 셰이더 플레인 하나 | 공간만 만든다. 아무것도 반응하지 않는다 |
| 무대 | 셰이더 플레인 하나 | 다른 요소가 그 위에 앉는다 |

**파일 이름을 용도와 맞춰라.** `hero-scene.ts` 를 파이프라인에서 쓰기 시작한 순간
다음 사람은 그것을 히어로 것인 줄 알고 고친다. `gate-scene.ts` 로 바꿔라.

### 지오메트리는 화면 비율에 맞춰 다시 잡아라

데스크톱에서 정한 크기를 세로 화면에 그대로 쓰면 씬이 통째로 안 보인다.

수직 시야는 화면 비율과 무관하지만 **수평 시야는 aspect 에 비례한다.**

```
수직 시야 = 2 · d · tan(fov/2)
수평 시야 = 수직 시야 × aspect
```

실측 사례: 판 폭 15 를 데스크톱(2.5:1)에서 정했다. 세로 화면(0.45:1)에서 수평 시야는
**2.5** 로 줄어드는데 판은 그대로 15 라 **여섯 배**가 됐다. 첫 판이 화면을 완전히 덮어
게이트가 보이지 않고 그냥 밝은 화면이 됐다.

```js
const fitPanes = (aspect) => {
  const halfH = Math.tan((camera.fov * Math.PI) / 360);
  const viewW = 2 * camDist * halfH * aspect;
  const want = Math.max(MIN_W, viewW * 1.12);   // 살짝만 덮는다
  for (const m of panes) m.scale.x = Math.min(1, want / GEO_W);
};
```

`fit()` 안에서 `camera.aspect` 를 갱신할 때 **같이 부른다.**

### 저사양 판정에 화면 폭을 넣어라

```js
const narrow = innerWidth < 760;
const lean = narrow || (navigator.hardwareConcurrency ?? 4) <= 4;
const dpr = Math.min(devicePixelRatio, narrow ? 1.25 : 1.5);
```

**`hardwareConcurrency` 만 보면 요즘 폰이 전부 고사양으로 잡힌다** — 8코어가 흔하다.
좁은 화면은 대개 배터리로 돌아가고, 같은 씬이라도 열이 더 난다.
폰의 DPR 은 3 까지 가므로 좁은 화면에서는 한 단계 더 내린다.

**세로 화면에서 캔버스가 화면 전체를 덮는다면 불투명도도 낮춰라.**
가로에서 적당하던 밝기가 세로에서는 본문을 묻는다. 실측 교정: `0.7 → 0.45`.

### WebGL 위의 대비는 `readPixels` 로 못 잰다

`preserveDrawingBuffer: false`(기본값)면 프레임을 그린 뒤 버퍼가 비워진다.
`gl.readPixels` 는 **전부 0 을 돌려주고, 그 0 을 검정으로 착각하면 대비가 통과한 것처럼 보인다.**

**스크린샷을 찍어 그 픽셀을 재라.** 그리고 글자가 없는 영역만 골라라 —
글자를 포함해 재면 최고 밝기가 글자 자신이라 대비 1.00:1 이 나온다.

```js
// 캔버스 배경만 있는 영역에서 가장 밝은 픽셀 = 최악의 경우
sharp(shot).extract({ left, top, width, height }).raw()
```

### 씬의 의미는 옆의 콘텐츠가 반응할 때 성립한다

3D 에 은유를 담았는데 **그 의미를 자막으로 설명하고 있다면, 그림이 스스로 말하지 못한다는 뜻**이다.
자막을 다듬을 게 아니라 배치를 의심해라.

실측 사례 — 여섯 단계 파이프라인을 유리판 여섯 장으로 표현한 씬:

| | 히어로에 뒀을 때 | 파이프라인 섹션으로 옮긴 뒤 |
|---|---|---|
| 옆에 있는 것 | 헤드라인과 CTA | **여섯 단계의 실제 이름과 설명** |
| 의미 전달 | `게이트 01 / 06` 판독기 — **자막** | 판을 통과하면 그 단계가 켜진다 |
| 사용자 반응 | "왜 저 상자가 있는지 모르겠다" | — |

**같은 씬인데 자리를 옮기니 자막이 필요 없어졌다.** 콘텐츠가 반응하기 때문이다.

```js
mountScene({
  canvas,
  progressFrom: section,          // sticky 캔버스는 자기 rect 로 진행도를 못 잰다
  onGate(index) {                 // 값이 바뀔 때만 DOM 을 쓴다
    stops.forEach((el, i) => el.classList.toggle("is-active", i === index));
  },
});
```

**씬을 넣을 자리를 고를 때 "여기에 두면 무엇이 반응하는가"를 먼저 물어라.**
아무것도 반응하지 않으면 그것은 재질이지 주인공이 아니다.

### 재질일 때 — 배경 전면

주인공이 아니라 공간을 만드는 표면이다. 규칙이 반대가 된다.

- **대비를 낮춘다.** 주인공일 때의 값을 그대로 쓰면 글자 뒤에서 형광 막대처럼 튄다.
  실측 교정: 테두리 광 계수 `0.85 → 0.42`, 알파 `0.72 → 0.34`
- **경계를 푼다.** 평면의 UV 테두리를 좁게 잡으면 사각형 프레임이 또렷하게 서서
  추상 패턴이 아니라 '상자'로 읽힌다. `smoothstep(0.0, 0.05, ...)` → `0.17` 로 넓히고 제곱해 번지게 한다
- **느리게 움직인다.** 스크롤 전 구간을 다 통과시키면 시선을 끌어 글자와 싸운다. 이동량을 60% 로 줄였다
- **글자 자리에 스크림을 깐다.** 배경이 균일하게 밝으면 본문 대비가 무너진다.
  전면을 덮지 말고 **글자가 앉는 쪽만** 눌러라 — 반대쪽을 열어둬야 배경이 살아 있다
- **아래를 마스크로 지운다.** 섹션 경계에서 배경이 가로선으로 끊기면 두 장의 종이처럼 보인다.
  `mask-image: linear-gradient(to bottom, black 62%, transparent 96%)`
- **캔버스 크기는 `ResizeObserver` 로 따라간다.** `window.resize` 만 들으면 폰트가 늦게 오거나
  콘텐츠가 접힐 때 캔버스가 옛 크기로 남아 배경이 화면 일부만 덮는다. 그건 resize 이벤트가 아니다

---

## 0. 채택 게이트 — "쓰지 않는다"를 먼저 통과시켜라

| 원하는 것 | 먼저 시도 | three가 필요한 순간 |
|---|---|---|
| 유동적 그라디언트 배경 | `radial-gradient` 겹치기 + `filter: blur()` | 마우스/스크롤에 유기적으로 반응해야 |
| 유리·굴절 | `backdrop-filter` + SVG `feDisplacementMap` | 뒤 오브젝트가 3D로 왜곡돼야 |
| 그레인·노이즈 | SVG `feTurbulence` | 노이즈가 시간에 따라 흘러야 |
| 이미지 왜곡 | `clip-path` + `transform` | 픽셀 단위 변형·색수차가 필요 |
| 떠다니는 입자 | CSS 애니메이션 20개 | 1,000개 이상이거나 서로 반응해야 |
| 제품 회전 | 스프라이트 시퀀스(36장 WebP) | 사용자가 자유롭게 돌려야 |

**왼쪽 칸으로 되면 왼쪽이 정답이다.** 4-2에서 SVG 필터를 이미 썼다면 대부분 여기서 끝난다.

### 무거운 씬 임포트보다 셰이더 플레인 하나

| 방식 | 비용(gzip) | 언제 |
|---|---|---|
| **셰이더 플레인 1장** (§4) | **~95KB** | 기본값. 배경·재질·분위기 |
| GLTF 모델 + 환경광 | ~180KB + 모델 | 제품이 주인공일 때만 |
| Spline / 씬 임포트 | **800KB ~ 2MB** | 예산을 즉시 깬다. 사실상 금지 |

**"3D를 넣자"가 아니라 "이 인상에 필요한 최소 GPU 작업은 무엇인가"를 물어라.**

### 채택 조건 — 넷 다 만족해야 한다

1. CSS/SVG로 같은 인상이 안 나온다
2. 추가분이 **+200KB(gzip) 이내**다 (데모·포트폴리오 브리프면 +600KB, 단 올렸다고 명시)
3. **폴백을 같이 만든다.** 폴백 없이 채택하지 않는다
4. 2단계 한 문장 컨셉을 **강화**한다. "있으면 멋있어서"는 이유가 아니다

---

## 1. 버전 핀 — 지금 안전한 조합

```json
{ "three": "0.185.1", "@react-three/fiber": "^9.7.0", "@react-three/drei": "^10.7.8",
  "@react-three/postprocessing": "^3.0.5", "postprocessing": "^6.39.4",
  "overrides": { "three": "0.185.1" } }
```

| 경고 | 내용 |
|---|---|
| **three 0.186 금지** | `postprocessing`의 peer가 `three >=0.168.0 <0.186.0`. 올리면 포스트프로세싱이 깨진다 → **0.185.1에 핀** |
| **R3F v9 = React 19 전용** | peer `react >=19 <19.3`. 19.3+는 아직 지원 없음(v10 alpha) |
| **three 중복 설치 금지** | 두 벌이면 `instanceof` 검사가 전부 깨진다. `overrides`로 단일화 |
| **바닐라 우선** | 페이지가 React가 아니면 R3F를 쓰지 마라. React+R3F만 +75KB다 |
| **drei는 named import** | 배럴 임포트는 +100KB다 |

---

## 2. 오늘 사람 잡는 함정

| 함정 | 증상 | 고치는 법 |
|---|---|---|
| **sRGB 자동 지정 제거** [R3F v9] | 이미지가 어둡고 채도가 죽는다 | 컬러 텍스처에 `tex.colorSpace = THREE.SRGBColorSpace`. 노멀/러프니스맵엔 **지정 금지** |
| **`Clock` deprecated** [r183+] | 곧 제거됨 | `renderer.setAnimationLoop((t) => …)`의 `t`(ms)를 쓴다 |
| **`RGBELoader` 개명** [r180+] | import 실패 | `import { HDRLoader } from 'three/addons/loaders/HDRLoader.js'` |
| **ACES 톤매핑 기본값** | 플랫 그라디언트가 뿌옇게 죽는다 | 2D 셰이더면 `toneMapping = THREE.NoToneMapping` (R3F는 `<Canvas flat>`) |
| **셰이더 색이 틀림** | hex를 `vec3`에 하드코딩 | 셰이더 안 `vec3`는 **linear 값**이다. 색은 JS에서 `THREE.Color`로 넘겨라 |
| **WebGL1 미지원** [r163+] | 구형 기기 백지 | `webgl2` 컨텍스트로 게이팅 (§3) |
| **drei `Environment preset`** | 외부 CDN에서 HDRI를 받는다 | `files="/hdri/studio_1k.hdr"` 자체 호스팅 |
| **정면으로 선 평면의 프레넬** | 유리판이 "흐린 사각형"으로만 보이고 재질이 안 읽힌다 | 평면이 카메라를 정면으로 보면 `dot(normal, view) ≈ 1` 이라 **프레넬이 구조적으로 0**이다. 가장자리에서도 법선이 같아 변하지 않는다. 법선을 UV 로 미세하게 굽히고(`normalize(n + vec3((vUv-0.5)*0.9, 0.0))`), 두께는 **UV 경계**에서 따로 만들어라 |
| **`gl_FragCoord` 를 렌더 타겟 크기로 나눔** | 굴절 텍스처가 잘리거나 늘어난다 | `gl_FragCoord` 는 **드로잉버퍼** 좌표다. 렌더 타겟을 0.6배로 줄여 썼다면 그 크기로 나누면 UV 가 1 을 넘는다. `renderer.getDrawingBufferSize()` 를 uniform 으로 넘겨라 |

**실측 사례**: 위 두 함정을 designpaca 소개 페이지가 동시에 밟았다. 판 여섯 장을 세워놓고
"그냥 흐릿한 사각형"이라는 평가를 받았는데, 원인은 배치도 색도 아니고 **유리로 읽히게 하는 신호가
계산되지 않고 있던 것**이었다. 셰이더는 에러를 내지 않는다 — 조용히 아무것도 안 한다.

---

## 3. 코드 A — 지연 로드 부트스트랩 (**이 문서에서 가장 중요**)

**언제**: three를 쓰기로 한 모든 경우. 예외 없다. three를 초기 번들에서 빼는 유일한 방법이다.

```js
// src/gl/boot.js — 계약: 모든 씬은 default export 로 mount(canvas, opts) => disposeFn 을 노출한다
export async function boot(canvas, loader) {
  if (!canvas || !gate()) { canvas?.remove(); return null }        // 폴백 배경이 그대로 남는다
  await inViewport(canvas)                                          // 진입 전엔 다운로드도 안 한다
  const { default: mount } = await loader()
  const css = getComputedStyle(document.documentElement)
  const dispose = mount(canvas, { css })
  const dur = css.getPropertyValue('--dur-normal').trim() || '350ms'
  const ease = css.getPropertyValue('--ease-out').trim() || 'ease'
  canvas.style.transition = `opacity ${dur} ${ease}`
  requestAnimationFrame(() => { canvas.style.opacity = '1' })
  addEventListener('pagehide', dispose, { once: true })
  return dispose
}

function gate() {                            // WebGL2 + 사용자 선호 + 하드웨어를 한 번에 판정
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) return false
  if (navigator.connection?.saveData) return false
  if ((navigator.deviceMemory ?? 8) < 4 || (navigator.hardwareConcurrency ?? 8) < 4) return false
  try {
    const gl = document.createElement('canvas').getContext('webgl2')
    if (!gl) return false
    gl.getExtension('WEBGL_lose_context')?.loseContext()            // 프로브 컨텍스트 즉시 반납
    return true
  } catch { return false }
}

const inViewport = (el) => new Promise((res) => {
  const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); res() } },
    { rootMargin: '200px' })
  io.observe(el)
})
```

```js
// src/main.js — three 는 여기 어디에도 import 되지 않는다
import { boot } from './gl/boot.js'
boot(document.getElementById('gl'), () => import('./gl/scenes/hero.js'))
```

```html
<div class="hero__bg" aria-hidden="true"><canvas id="gl" style="opacity:0"></canvas></div>
```

```css
.hero__bg {
  position: absolute; inset: 0; z-index: -1;
  aspect-ratio: 16 / 9;                       /* CLS 차단 */
  background:                                 /* WebGL 실패 시 이것이 최종 결과물이다 */
    radial-gradient(90% 70% at 20% 0%, var(--accent) 0%, transparent 60%),
    linear-gradient(180deg, var(--surface-raised) 0%, var(--surface) 100%);
}
.hero__bg canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
@media (prefers-reduced-motion: reduce) { .hero__bg canvas { display: none; } }
```

**폴백 배경은 셰이더의 정지 프레임과 같은 인상이어야 한다.** 완성 후 `renderer.domElement.toDataURL('image/webp', .85)`로 한 프레임을 캡처해 대조해라.

### 공용 Stage — 모든 씬이 얹히는 최소 컨테이너

```js
// src/gl/stage.js
import * as THREE from 'three'

export function createStage(canvas, { dprMax = 1.5, alpha = false } = {}) {
  const renderer = new THREE.WebGLRenderer({ canvas, alpha, antialias: false, stencil: false,
    powerPreference: 'high-performance' })
  renderer.outputColorSpace = THREE.SRGBColorSpace
  renderer.toneMapping = THREE.NoToneMapping                       // 플랫 2D 셰이더 기준
  const scene = new THREE.Scene()
  const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); camera.position.z = 5
  const size = { w: 0, h: 0, dpr: 1 }, updaters = new Set()
  let running = false, last = 0, t0 = performance.now()

  const draw = (t, dt) => { for (const f of updaters) f(t, dt); renderer.render(scene, stage.camera) }
  const tick = (now) => { const dt = Math.min((now - last) / 1000, 1 / 20); last = now; draw((now - t0) / 1000, dt) }
  const start = () => { if (!running) { running = true; last = performance.now(); renderer.setAnimationLoop(tick) } }
  const stop = () => { running = false; renderer.setAnimationLoop(null) }

  function resize() {
    const r = canvas.getBoundingClientRect()
    const w = Math.max(1, Math.round(r.width)), h = Math.max(1, Math.round(r.height))
    const dpr = Math.min(devicePixelRatio || 1, dprMax)
    if (w === size.w && h === size.h && dpr === size.dpr) return
    Object.assign(size, { w, h, dpr })
    renderer.setPixelRatio(dpr); renderer.setSize(w, h, false)
    stage.camera.aspect = w / h; stage.camera.updateProjectionMatrix()
    stage.onResize?.(w, h, dpr)
    if (!running) draw(0, 0)
  }
  const ro = new ResizeObserver(resize); ro.observe(canvas)
  const io = new IntersectionObserver(([e]) => e.isIntersecting ? start() : stop(), { rootMargin: '10%' })
  io.observe(canvas)
  const onVis = () => document.hidden ? stop() : start()
  document.addEventListener('visibilitychange', onVis)
  const onLost = (e) => { e.preventDefault(); stop() }              // 없으면 복구 이벤트가 안 온다
  canvas.addEventListener('webglcontextlost', onLost)

  const stage = { renderer, scene, camera, size, start, stop, renderOnce: () => draw(0, 0),
    add(f) { updaters.add(f); return () => updaters.delete(f) },
    setDprMax(v) { dprMax = v; size.dpr = -1; resize() },
    dispose() {
      stop(); ro.disconnect(); io.disconnect()
      document.removeEventListener('visibilitychange', onVis)
      canvas.removeEventListener('webglcontextlost', onLost)
      scene.traverse((o) => {
        o.geometry?.dispose()
        for (const m of [o.material].flat().filter(Boolean)) {
          for (const u of Object.values(m.uniforms ?? {})) if (u.value?.isTexture) u.value.dispose()
          m.dispose()
        }
      })
      scene.clear(); renderer.dispose(); renderer.forceContextLoss(); updaters.clear()
    } }
  resize()
  return stage
}
```

---

## 4. 코드 B — 메시 그라디언트 셰이더 플레인

**언제**: 랜딩 히어로 배경의 기본 무기. three를 쓰기로 했다면 90%는 이것으로 끝난다. 드로우콜 1개, 텍스처 0장.
**색과 속도는 3단계 토큰에서 읽는다. 셰이더에 hex를 쓰지 마라** (하드 게이트 #1).

```js
// src/gl/scenes/hero.js
import * as THREE from 'three'
import { createStage } from '../stage.js'
import { resolveQuality, watchdog } from '../quality.js'
import frag from '../shaders/hero.frag'

const VERT = /* glsl */`void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }`  // 이미 클립 공간

export default function mount(canvas, { css }) {
  const q = resolveQuality()
  const stage = createStage(canvas, { dprMax: q.dpr })

  const color = (n) => {                                   // 하드코딩 폴백을 두지 마라
    const v = css.getPropertyValue(n).trim()
    if (!v) throw new Error(`designpaca: 토큰 ${n} 이 없다. 3단계로 돌아가라`)
    return new THREE.Color(v)                              // THREE.Color 는 linear 로 저장된다
  }
  const durSlow = parseFloat(css.getPropertyValue('--dur-slow')) || 600   // ms
  const speed = 1000 / (durSlow * 20)          // 셰이더도 같은 시간 문법: 1주기 = --dur-slow × 20

  // 풀스크린 트라이앵글. 플레인(2 tri)보다 싸고 대각선 이음매가 없다
  const geometry = new THREE.BufferGeometry()
  geometry.setAttribute('position',
    new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3))

  const u = {
    uTime: { value: 0 }, uSpeed: { value: speed },
    uResolution: { value: new THREE.Vector2(1, 1) },
    uPointer: { value: new THREE.Vector2(0.5, 0.5) },
    uSurface: { value: color('--surface') },
    uRaised: { value: color('--surface-raised') },
    uAccent: { value: color('--accent') },
  }
  const mesh = new THREE.Mesh(geometry, new THREE.ShaderMaterial({
    vertexShader: VERT, fragmentShader: frag, uniforms: u,
    defines: { OCTAVES: q.octaves }, depthTest: false, depthWrite: false,
  }))
  mesh.frustumCulled = false
  stage.scene.add(mesh)

  stage.onResize = (w, h, dpr) => u.uResolution.value.set(w * dpr, h * dpr)
  stage.onResize(stage.size.w, stage.size.h, stage.size.dpr)

  const target = new THREE.Vector2(0.5, 0.5)
  const onMove = (e) => target.set(e.clientX / innerWidth, 1 - e.clientY / innerHeight)
  addEventListener('pointermove', onMove, { passive: true })

  const guard = watchdog(q, stage)
  stage.add((t, dt) => {
    u.uTime.value = t
    u.uPointer.value.lerp(target, 1 - Math.pow(0.002, dt))          // 프레임레이트 독립 보간
    guard(dt)
  })
  stage.start()
  return () => { removeEventListener('pointermove', onMove); stage.dispose() }
}
```

```glsl
/* src/gl/shaders/hero.frag */
precision mediump float;
uniform float uTime, uSpeed;
uniform vec2  uResolution, uPointer;
uniform vec3  uSurface, uRaised, uAccent;
#ifndef OCTAVES
  #define OCTAVES 3
#endif

float hash(vec2 p) { p = fract(p * vec2(233.34, 851.73)); p += dot(p, p + 23.45); return fract(p.x * p.y); }
float vnoise(vec2 p) {
  vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x),
             mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) {
  float s = 0.0, a = 0.5;
  for (int i = 0; i < OCTAVES; i++) { s += vnoise(p) * a; p *= 2.03; a *= 0.5; }
  return s;
}

void main() {
  /* 짧은 축 기준 정규화 — 안 하면 노이즈가 가로로 늘어진다 */
  vec2 p = (gl_FragCoord.xy * 2.0 - uResolution) / min(uResolution.x, uResolution.y);
  float t = uTime * uSpeed;

  /* 도메인 워프: 노이즈로 좌표를 흔든 뒤 다시 노이즈 = "유기적"의 정체 */
  float w = fbm(p * 1.1 + t);
  float field = fbm(p * 1.7 + w * 0.9 + vec2(0.0, t * 1.3));

  vec2 pc = (uPointer * 2.0 - 1.0) * vec2(uResolution.x / uResolution.y, 1.0);
  field += exp(-dot(p - pc, p - pc) * 1.6) * 0.28;                 /* 포인터 근처를 부풀린다 */

  vec3 col = mix(uSurface, uRaised, smoothstep(0.18, 0.62, field));
  col = mix(col, uAccent, smoothstep(0.55, 0.95, field));
  col *= 1.0 - dot(p, p) * 0.16;                                   /* 비네트 */
  col += (hash(gl_FragCoord.xy) - 0.5) / 255.0;                    /* 밴딩 제거. 사실상 필수 */

  gl_FragColor = vec4(col, 1.0);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}
```

**조절**: `uSpeed`(0.03~0.15, 낮을수록 고급) · `OCTAVES`(2~4) · `p * 1.1`의 배율(작을수록 큰 덩어리) · 비네트 0.16.
`#include <…>`는 `ShaderMaterial`에서만 동작한다. `RawShaderMaterial`은 컴파일 에러다.

---

## 5. 코드 C — 품질 티어 + 런타임 강등

**언제**: three를 쓰는 모든 씬. 한 번 판정해 씬 전체 설정을 결정한다. 컴포넌트마다 각자 판단하면 조합이 폭발한다.

```js
// src/gl/quality.js
const TIERS = {
  low:  { tier: 'low',  dpr: 1,   octaves: 2, particles: 4000,  post: false,     targetFps: 30 },
  mid:  { tier: 'mid',  dpr: 1.5, octaves: 3, particles: 15000, post: 'minimal', targetFps: 60 },
  high: { tier: 'high', dpr: 2,   octaves: 4, particles: 40000, post: 'full',    targetFps: 60 },
}
const ORDER = ['high', 'mid', 'low']

/** 동기 판정. detect-gpu(+12KB)는 GLTF 모델 씬에서만 추가로 쓴다 */
export function resolveQuality() {
  const cached = sessionStorage.getItem('gl-tier')
  if (cached && TIERS[cached]) return TIERS[cached]
  const mobile = matchMedia('(max-width: 768px)').matches
  const mem = navigator.deviceMemory ?? 8, cores = navigator.hardwareConcurrency ?? 8
  const t = (mobile || mem < 6 || cores < 6) ? 'low'
          : (mem >= 8 && cores >= 8) ? 'high' : 'mid'
  sessionStorage.setItem('gl-tier', t)
  return TIERS[t]
}

/** 4초 창에서 목표 fps의 75%를 30% 넘게 놓치면 한 단계 강등. low 에서도 실패하면 캔버스를 버린다 */
export function watchdog(quality, stage) {
  let cur = quality, elapsed = 0, bad = 0, total = 0
  return function update(dt) {
    elapsed += dt; total++
    if (1 / dt < cur.targetFps * 0.75) bad++
    if (elapsed < 4) return
    const failing = bad / total > 0.3
    elapsed = 0; bad = 0; total = 0
    if (!failing) return
    const next = ORDER[ORDER.indexOf(cur.tier) + 1]
    if (!next) {                                   // 더 내릴 곳이 없다 → CSS 폴백만 남긴다
      sessionStorage.setItem('gl-tier', 'low')
      stage.renderer.domElement.remove(); stage.dispose(); return
    }
    cur = TIERS[next]
    sessionStorage.setItem('gl-tier', next)
    stage.setDprMax(cur.dpr)                       // 가장 효과가 큰 레버를 먼저 당긴다
  }
}
```

---

## 6. 코드 D — DOM 동기화 이미지 hover 왜곡

**언제**: 이미지 그리드·포트폴리오. **폴백이 공짜인 유일한 패턴**이다 — 실패하면 원래 `<img>`가 그대로 남는다.
장식을 얹는 게 아니라 이미 있는 콘텐츠를 강화하므로 "레이아웃 → 재질 → 입체" 순서와도 맞는다.
캔버스는 `position: fixed; inset: 0; pointer-events: none;` 이어야 한다.

```js
// src/gl/scenes/gallery.js
import * as THREE from 'three'
import { createStage } from '../stage.js'
import { resolveQuality } from '../quality.js'
import frag from '../shaders/image.frag'

const VERT = /* glsl */`
  uniform float uHover, uVelocity;
  varying vec2 vUv;
  void main() {
    vUv = uv;
    vec3 p = position;
    p.y += sin(uv.x * 3.14159265) * uVelocity * 0.14;     /* 스크롤 속도로 활처럼 휜다 */
    p.z += sin(uv.y * 3.14159265) * uHover * 0.08;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
  }`

export default function mount(canvas, { selector = 'img[data-gl]' } = {}) {
  const q = resolveQuality()
  const stage = createStage(canvas, { dprMax: q.dpr, alpha: true })
  stage.camera = new THREE.PerspectiveCamera(45, 1, 100, 3000)   // CSS 픽셀에 맞춘다
  stage.camera.position.z = 800                                  // → 1 world unit = 1 px

  const geometry = new THREE.PlaneGeometry(1, 1, 20, 20)
  const loader = new THREE.TextureLoader()
  const items = [], scroll = { cur: scrollY, vel: 0 }

  for (const el of document.querySelectorAll(selector)) {
    const u = { uTexture: { value: null }, uCover: { value: new THREE.Vector2(1, 1) },
      uMouse: { value: new THREE.Vector2(.5, .5) },
      uHover: { value: 0 }, uVelocity: { value: 0 }, uShift: { value: .006 } }
    const mesh = new THREE.Mesh(geometry, new THREE.ShaderMaterial({
      vertexShader: VERT, fragmentShader: frag, uniforms: u, transparent: true }))
    mesh.visible = false
    stage.scene.add(mesh)
    const it = { el, mesh, u, hoverTarget: 0, box: null, ratio: 1 }
    items.push(it)

    loader.load(el.currentSrc || el.src, (tex) => {
      tex.colorSpace = THREE.SRGBColorSpace              // ← 빠뜨리면 이미지가 어두워진다
      tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter
      u.uTexture.value = tex
      it.ratio = tex.image.width / tex.image.height
      cover(it); mesh.visible = true
      el.style.opacity = '0'                             // 텍스처 도착 후에만 DOM 이미지를 숨긴다
    })
    // 캔버스가 pointer-events:none 이므로 이벤트는 DOM 요소에서 받는다
    el.addEventListener('pointerenter', () => { it.hoverTarget = 1 })
    el.addEventListener('pointerleave', () => { it.hoverTarget = 0 })
    el.addEventListener('pointermove', (e) => {
      const r = el.getBoundingClientRect()
      u.uMouse.value.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height)
    })
  }

  const cover = (it) => {                    // object-fit: cover 를 JS 에서 계산 → 셰이더가 짧아진다
    const pr = it.box ? it.box.w / it.box.h : 1
    it.u.uCover.value.set(Math.min(pr / it.ratio, 1), Math.min(it.ratio / pr, 1))
  }
  const measure = () => {                    // 리플로우를 유발한다. 리사이즈 때만 부른다
    for (const it of items) {
      const r = it.el.getBoundingClientRect()
      it.box = { top: r.top + scrollY, left: r.left + scrollX, w: r.width, h: r.height }
      it.mesh.scale.set(r.width, r.height, 1); cover(it)
    }
  }
  stage.onResize = (w, h) => {
    stage.camera.fov = 2 * Math.atan(h / 2 / stage.camera.position.z) * (180 / Math.PI)
    stage.camera.aspect = w / h; stage.camera.updateProjectionMatrix(); measure()
  }
  stage.onResize(stage.size.w, stage.size.h)

  stage.add((t, dt) => {
    // 스크롤 값은 rAF 에서 직접 읽는다. `scroll` 리스너는 하드 게이트 #10 위반이고,
    // 어차피 매 프레임 필요한 값이다.
    const k = 1 - Math.pow(.001, dt), hk = 1 - Math.pow(.0005, dt), prev = scroll.cur
    scroll.cur += (scrollY - scroll.cur) * k
    scroll.vel = THREE.MathUtils.clamp((scroll.cur - prev) / Math.max(dt, 1e-4) / 2500, -1, 1)
    for (const it of items) {
      if (!it.box) continue
      it.mesh.position.x = it.box.left - stage.size.w / 2 + it.box.w / 2
      it.mesh.position.y = -(it.box.top - scroll.cur) + stage.size.h / 2 - it.box.h / 2
      it.u.uVelocity.value += (scroll.vel - it.u.uVelocity.value) * k
      it.u.uHover.value += (it.hoverTarget - it.u.uHover.value) * hk
      it.mesh.visible = !!it.u.uTexture.value &&
        Math.abs(it.mesh.position.y) < stage.size.h / 2 + it.box.h
    }
  })
  stage.start()
  return () => {
    items.forEach((it) => { it.el.style.opacity = '' })
    geometry.dispose(); stage.dispose()
  }
}
```

```glsl
/* src/gl/shaders/image.frag */
precision mediump float;
uniform sampler2D uTexture;
uniform vec2  uCover, uMouse;
uniform float uHover, uVelocity, uShift;
varying vec2 vUv;

void main() {
  vec2 uv = vUv * uCover + (1.0 - uCover) * 0.5;                   /* cover */
  uv = (uv - 0.5) * mix(1.0, 0.94, uHover) + 0.5;                  /* hover 줌 */
  vec2 dir = normalize(vUv - uMouse + 1e-5);
  uv += dir * exp(-distance(vUv, uMouse) * 4.0) * uHover * 0.016;  /* 마우스 방향 밀림 */

  float s = uShift * (uHover * 0.6 + abs(uVelocity) * 1.4);        /* 색수차 */
  vec3 col = vec3(texture2D(uTexture, uv + vec2(s, s * 0.35)).r,
                  texture2D(uTexture, uv).g,
                  texture2D(uTexture, uv - vec2(s, s * 0.35)).b);

  gl_FragColor = vec4(col, 1.0);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}
```

**원본 이미지를 그대로 쓰면 VRAM이 터진다.** 1920×1080 RGBA = 8.3MB/장. 표시 크기 × 2까지만 서버에서 리사이즈해라.

---

## 7. 패턴 카탈로그 — 코드는 `research/three/02-patterns.md`에

| 효과 | 언제 | 비용 | 문서 |
|---|---|---|---|
| SDF 도트 그리드 + 마우스 트레일 | 텍스트가 위에 올라가는 배경. 가독성을 안 해친다 | +0KB · 낮음 | P2 |
| 파티클 필드 (Points) | 먼지·별 레이어. 5만 개 이하 | +0KB · 중간 | P3 |
| GPGPU 플로우필드 파티클 | 10만 개 이상, 유체 같은 흐름 | +0KB · 높음 | P4 |
| InstancedMesh 그리드 웨이브 | 입체 격자 인터랙션. 드로우콜 1 | +0KB · 중간 | P5 |
| 스크롤 카메라 리그 (Lenis+ScrollTrigger) | 스크롤텔링. WebGL이 페이지를 지배할 때 | **+45KB** · 낮음 | P6 |
| drei ScrollControls | R3F에서 페이지 전체가 캔버스일 때 | +0KB · 낮음 | P7 |
| 이미지 전환 (displacement) | 슬라이더·캐러셀 | +0KB · 낮음 | P9 |
| 유리·굴절 (MeshTransmissionMaterial) | 중심 오브제. **가장 비싸다** | +0KB · **매우높음** | P10 |
| 3D 텍스트 (troika SDF) | WebGL 안의 대형 타이포 | **+55KB** · 낮음 | P11 |
| 포스트프로세싱 (bloom/CA/grain) | 발광·필름 룩 | **+32KB** · 중간~높음 | P12 |
| GLTF 제품 씬 (DRACO/KTX2) | 제품이 주인공 | **+15KB** + 모델 · 중간 | P13 |
| 노이즈 디졸브 리빌 | 등장·전환 | +0KB · 낮음 | P15 |

**+45KB 이상 항목은 예산 계산에 반드시 넣어라.** 스크롤 리그(GSAP+Lenis)는 **한 줄도 안 썼는데 +200KB 예산의 22%를 먹고 시작한다.** 여기에 troika(+55KB)와 포스트프로세싱(+32KB)을 얹으면 +132KB — 예산의 66%가 라이브러리로만 사라지고 실제 씬에 쓸 몫은 68KB뿐이다.

---

## 8. 성능 — 효과 순서대로

1. **DPR 클램프가 1위다.** `min(devicePixelRatio, 2)` — 3→2면 픽셀 **55% 감소**, 육안 차이 없음. 배경 셰이더는 1.25~1.5까지 내려도 된다
2. **안 보일 때 안 그린다.** `visibilitychange` + `IntersectionObserver`. 정적 씬은 온디맨드(R3F `frameloop="demand"`). 배터리 80% 절약
3. **VRAM이 모바일의 진짜 한계다.** 4K 텍스처 = 67MB/장. **WebP는 다운로드만 줄이고 VRAM은 그대로**다. 줄이는 유일한 수단은 KTX2(ETC1S 1/8). 컨텍스트 로스의 최대 원인도 VRAM 고갈
4. **드로우콜 예산**: 데스크톱 150 / 모바일 60. `renderer.info.render.calls`로 측정. 넘으면 `InstancedMesh`
5. **`MeshTransmissionMaterial`은 매 프레임 씬을 재렌더한다.** `resolution`을 기본(풀스크린)으로 두지 마라 — 512 이하, 모바일은 `samples={2} resolution={256}`. 2개 이상이면 `transmissionSampler` 공유
6. **투명 파티클은 오버드로우가 병목**이다. 개수보다 `gl_PointSize`를 먼저 줄여라
7. **셰이더는 `mediump` 기본.** 모바일에서 ~2배 빠르다. 좌표·GPGPU만 `highp`
8. **실시간 그림자는 끈다.** `ContactShadows frames={1}` 또는 baked AO로 대체

**병목 격리**: 캔버스를 200×200으로 줄여 회복되면 프래그먼트 병목, 그대로면 드로우콜·CPU 병목이다.

---

## 9. Lighthouse — 캔버스를 LCP 요소로 만들지 마라

WebGL 랜딩페이지가 42점에서 98점으로 간 실측 사례의 핵심은 이 한 줄이다.

| 규칙 | 방법 |
|---|---|
| **LCP는 DOM 텍스트가 잡는다** | 히어로 `<h1>`이 LCP가 되게 하고 캔버스는 `z-index:-1` 배경 레이어로 |
| **CLS 차단** | 캔버스 컨테이너에 `aspect-ratio` 또는 `min-height`를 미리 준다 |
| **three는 LCP 이후에 요청된다** | 코드 A의 동적 import + IntersectionObserver. Network 탭에서 순서를 확인해라 |
| **셰이더 컴파일 분산** | 머티리얼 10개를 한 프레임에 처음 그리면 100ms 롱태스크다. `renderer.compileAsync(scene, camera)` [r170+] |
| **디코더 preload 금지** | DRACO·basis WASM(각 200~250KB)은 실제로 필요할 때만 받게 한다 |

---

## 10. 폴백과 접근성

| 상황 | 대체 |
|---|---|
| WebGL2 미지원 · 컨텍스트 생성 실패 | 캔버스 제거 → CSS 그라디언트 폴백 (코드 A) |
| 저사양(메모리<4GB, 코어<4) · `saveData` | 아예 로드하지 않는다 |
| **`prefers-reduced-motion: reduce`** | 캔버스 `display:none` + CSS 폴백. 굳이 띄워야 하면 `renderOnce()` 한 장만 |
| 런타임 프레임 저하 | 티어 강등 → 최종적으로 캔버스 제거 (코드 C) |
| 배터리 부족 · 3분 이상 실행 | DPR을 1로 내린다 (써멀 스로틀링 방지) |
| 컨텍스트 로스 | `e.preventDefault()`로 복구 신호 후 정지. **three는 리소스를 자동 재생성하지 않는다** — 씬을 통째로 다시 만들어라 |

- 장식 캔버스는 `aria-hidden="true"` + `pointer-events: none`
- **WebGL 안의 텍스트는 검색·선택·스크린리더가 안 된다.** 히어로 문구는 반드시 DOM에 둔다
- 의미 있는 3D 뷰는 `role="img"` + `aria-label` + 정적 이미지 대안
- 캔버스 위 텍스트는 뒤에 반투명 레이어를 깔아 대비 4.5:1을 보장한다
- 캔버스를 여러 개 만들지 마라. 컨텍스트 상한은 8~16개다 (drei `<View>` 또는 씬 전환)

---

## 11. 채택 체크리스트

5단계 프리플라이트에서 그대로 돌린다. **하나라도 실패하면 채택 취소이거나 4-3 복귀다.**

- [ ] §0 채택 조건 4개를 통과했다 (CSS/SVG로 안 되고, 예산 내이고, 폴백이 있고, 컨셉을 강화한다)
- [ ] WebGL 추가분을 **실측**했다(build 후 gzip). 예산 내이거나, 올린 이유를 명시했다
- [ ] `three`가 **초기 번들에 없다.** Network 탭에서 LCP 이후 요청됨을 확인했다
- [ ] 셰이더의 색·시간이 3단계 토큰(`--accent`, `--dur-*`)에서 파생됐다. hex 하드코딩이 없다
- [ ] 컬러 텍스처에 `colorSpace = SRGBColorSpace`를 지정했다
- [ ] DPR을 클램프했다. `IntersectionObserver` + `visibilitychange`로 렌더를 멈춘다
- [ ] `renderer.info.render.calls` ≤ 150(데스크톱) / 60(모바일)
- [ ] 캔버스를 `display:none`으로 껐을 때 페이지가 완성돼 있다 (프리플라이트 0-A와 동일)
- [ ] `prefers-reduced-motion`에서 애니메이션이 멈추고 폴백이 보인다
- [ ] 캔버스에 `aria-hidden="true"`, 히어로 텍스트는 DOM에 있다
- [ ] 컨텍스트 로스를 강제(`WEBGL_lose_context.loseContext()`)했을 때 크래시 없이 폴백이 뜬다
- [ ] 라우트 이탈 시 `dispose()`가 호출된다. 3분 실행 후 `renderer.info.memory`가 증가하지 않는다
- [ ] 실기기(중저가 안드로이드)에서 30fps 이상이다
- [ ] 6단계 `design.md`에 **채택한 이펙트 · 실측 추가분(KB) · 그 폴백**을 적었다

하드 게이트 중 셋이 여기서 자주 걸린다: **#1**(셰이더 hex 하드코딩) · **#8**(`transform`/`opacity` 외 애니메이션 — 캔버스 페이드는 `opacity`로) · **#10**(`scroll` 리스너 — rAF에서 `scrollY`를 읽어라).

> 근거: research/three/01~04 (조사일 2026-08-20)
