# 04 — 성능 · 폴백 · 접근성

> 이 문서의 수치는 **실측 기준선**이다. "느리다/빠르다" 대신 숫자로 판단한다.
> 기준 기기: 저사양 = iPhone SE 3세대 / Galaxy A54 (Adreno 613급), 중간 = iPhone 13 / Pixel 7, 고사양 = M2 MacBook / RTX 3060

---

## 1. 예산 (Budget) — 먼저 정하고 시작한다

### 1.1 프레임 예산

| 목표 fps | 프레임 예산 | 그 중 GPU가 쓸 수 있는 시간 |
|---|---|---|
| 60fps | **16.6ms** | ~12ms (나머지는 브라우저 합성/JS) |
| 30fps (모바일 타협) | 33.3ms | ~26ms |
| 120fps (ProMotion) | 8.3ms | ~6ms |

**판정 기준**
- 데스크톱: **60fps 유지**, 프레임 드롭 < 1%
- 모바일: **최소 30fps**, 스크롤 중 45fps 이상
- `requestAnimationFrame` 간격의 p95가 20ms를 넘으면 실패

### 1.2 렌더 예산

| 항목 | 데스크톱 상한 | 모바일 상한 | 측정 |
|---|---|---|---|
| 드로우콜 | **150** | **60** | `renderer.info.render.calls` |
| 삼각형 | 500,000 | 120,000 | `renderer.info.render.triangles` |
| 프로그램(셰이더) 수 | 25 | 15 | `renderer.info.programs.length` |
| 텍스처 수 | 40 | 20 | `renderer.info.memory.textures` |
| 지오메트리 수 | 60 | 30 | `renderer.info.memory.geometries` |
| **VRAM 총량** | 400MB | **120MB** | 아래 §1.3 계산 |
| 활성 라이트 | 4 | 2 | 그림자 있으면 각각 1로 카운트 |
| 그림자 캐스터 | 2 | **0** | PointLight 그림자는 6배 |

### 1.3 VRAM 계산법

```
텍스처 VRAM = width × height × 4 bytes × (밉맵 있으면 × 1.33)
```

| 텍스처 | 밉맵 없음 | 밉맵 있음 |
|---|---|---|
| 512×512 | 1.0 MB | 1.4 MB |
| 1024×1024 | 4.2 MB | 5.6 MB |
| 2048×2048 | 16.8 MB | 22.3 MB |
| 4096×4096 | **67.1 MB** | **89.3 MB** |
| HDRI 2048×1024 (HalfFloat) | 16.8 MB | — |

**KTX2/ETC1S로 압축하면 위 값의 약 1/8**, UASTC는 약 1/4가 된다. WebP/AVIF는 다운로드만 줄이고 **VRAM은 동일**하다.

```js
// 런타임 VRAM 추정 (근사)
function estimateVram(renderer) {
  let bytes = 0
  renderer.info.memory.textures // 개수만 알려줌 → 직접 추적해야 정확
  // 실무: 로드한 텍스처를 배열에 모아두고 계산
  return bytes
}
```

### 1.4 번들 예산

| 구성 | gzip | brotli | 판정 |
|---|---|---|---|
| three (트리셰이킹된 최소 씬) | ~90 KB | ~75 KB | 바닥값 |
| three + R3F + React | ~165 KB | ~140 KB | |
| + drei (named import 3~5개) | +15~30 KB | | |
| + drei (배럴 전체 import) | **+100 KB 이상** | | ❌ 금지 |
| + postprocessing (Bloom+CA+Noise) | +32 KB | | |
| + gsap core + ScrollTrigger | +40 KB | | |
| + lenis | +5 KB | | |
| + troika-three-text | +55 KB | | |
| **초기 경로 JS 총합 목표** | **< 200 KB** | | three는 지연 로드로 제외 |

**규칙**: three와 씬 코드는 **초기 번들에 절대 넣지 않는다.** `import()`로 분리하고 IntersectionObserver로 트리거한다.

---

## 2. 측정

### 2.1 개발 중 상시 표시

```js
// stats-gl: CPU + GPU 시간 둘 다 (three 내장 Stats 는 CPU만)
// npm i stats-gl
import Stats from 'stats-gl'

const stats = new Stats({ trackGPU: true, trackHz: true, trackCPT: false })
await stats.init(renderer)
document.body.appendChild(stats.dom)

renderer.setAnimationLoop((t) => {
  stats.begin()
  uniforms.uTime.value = t / 1000
  renderer.render(scene, camera)
  stats.end()
  stats.update()   // begin/end 사이 구간을 CPU·GPU 각각으로 집계한다
})
```

R3F에서는:

```tsx
// npm i -D r3f-perf
import { Perf } from 'r3f-perf'
// <Canvas> 내부에 <Perf position="top-left" />
```

### 2.2 렌더 통계 덤프

```js
// src/gl/debug.js
export function logRenderInfo(renderer, label = '') {
  const i = renderer.info
  console.table({
    [label || 'render']: {
      calls: i.render.calls,
      triangles: i.render.triangles,
      points: i.render.points,
      lines: i.render.lines,
      textures: i.memory.textures,
      geometries: i.memory.geometries,
      programs: i.programs?.length ?? 0,
    },
  })
}

/** 3초마다 자동 로깅 (개발 전용) */
export function watchRenderInfo(renderer, interval = 3000) {
  if (import.meta.env.PROD) return () => {}
  const id = setInterval(() => logRenderInfo(renderer), interval)
  return () => clearInterval(id)
}
```

### 2.3 병목 격리 실험

| 실험 | 회복되면 |
|---|---|
| 캔버스를 200×200으로 축소 | **프래그먼트/필레이트 병목** → 셰이더 단순화, DPR 하향, 오버드로우 축소 |
| `renderer.setPixelRatio(0.5)` | 동일 (필레이트) |
| 오브젝트를 절반으로 줄임 | **드로우콜 병목** → 인스턴싱, 지오메트리 병합 |
| 셰이더를 `gl_FragColor = vec4(1.0)` 로 대체 | 프래그먼트 연산 병목 |
| 텍스처 해상도를 1/4로 | **텍스처 대역폭/VRAM 병목** |
| 애니메이션 루프에서 씬 업데이트만 제거 | **CPU(JS) 병목** → 객체 생성, 레이캐스팅 확인 |

### 2.4 Lighthouse / Core Web Vitals

| 지표 | 기준 (Good) | WebGL 사이트에서 위험한 이유 |
|---|---|---|
| **LCP** | ≤ 2.5s | 캔버스가 LCP 요소가 되면 셰이더 컴파일까지 기다린다 |
| **INP** | ≤ 200ms | 셰이더 컴파일/텍스처 업로드가 메인 스레드를 막는다 |
| **CLS** | ≤ 0.1 | 캔버스 크기가 나중에 정해지면 레이아웃이 밀린다 |
| **TBT** | ≤ 200ms | three 파싱 + 컴파일이 롱태스크가 된다 |

**WebGL 사이트에서 Lighthouse를 지키는 5가지**

1. **LCP 요소를 캔버스가 아닌 DOM 텍스트/이미지로 만든다.** 히어로 제목이 LCP가 되게 하고, 캔버스는 그 뒤 배경.
2. **캔버스 컨테이너에 고정 크기(`aspect-ratio` 또는 `min-height`)를 준다.** CLS 방지.
3. **three를 `import()`로 분리**하고 LCP 이후에 로드한다.
4. **셰이더 컴파일을 분산한다.** 한 프레임에 머티리얼 10개를 처음 렌더하면 100ms 이상 멈춘다 → `renderer.compileAsync()`(r170+) 또는 순차 마운트.
5. **디코더 WASM(DRACO/basis)을 preload하지 마라.** 실제 필요할 때만 받게 한다.

```html
<!-- CLS 방지: 캔버스 자리를 미리 확보 -->
<div class="hero__canvas-wrap" style="aspect-ratio: 16/9; min-height: 60svh;">
  <canvas id="gl"></canvas>
</div>
```

```js
// 셰이더 컴파일 비동기화 [r170+]
await renderer.compileAsync(scene, camera)   // 첫 렌더 전 미리 컴파일
```

---

## 3. 런타임 최적화 — 효과 순서대로

### 3.1 DPR 클램프 (가장 효과가 크다)

DPR 3 → 2로 낮추면 픽셀 수가 **55% 감소**한다. 육안 차이는 거의 없다.

```js
const dpr = Math.min(window.devicePixelRatio || 1, 2)
renderer.setPixelRatio(dpr)
```

| 콘텐츠 | 권장 DPR 상한 |
|---|---|
| 풀스크린 배경 셰이더 | **1.25 ~ 1.5** |
| 파티클 | 1.5 |
| 3D 모델 (제품) | 2 |
| 텍스트가 WebGL 안에 있음 | 2 (SDF라도 선명도 필요) |
| 포스트프로세싱 사용 | 1.5 (이펙트마다 풀스크린 패스) |

**동적 DPR** (프레임레이트에 따라):

```js
// src/gl/adaptiveDpr.js
export function createAdaptiveDpr(renderer, { min = 0.75, max = 2, target = 55 } = {}) {
  let current = Math.min(window.devicePixelRatio || 1, max)
  let samples = []
  let cooldown = 0

  renderer.setPixelRatio(current)

  return function update(delta) {
    if (cooldown > 0) { cooldown -= delta; return current }

    samples.push(1 / Math.max(delta, 1e-4))
    if (samples.length < 60) return current

    samples.sort((a, b) => a - b)
    const median = samples[30]
    samples = []

    let next = current
    if (median < target - 8) next = Math.max(min, current - 0.25)
    else if (median > target + 8) next = Math.min(max, Math.min(window.devicePixelRatio, current + 0.25))

    if (next !== current) {
      current = next
      renderer.setPixelRatio(current)
      cooldown = 1.5   // 진동 방지
    }
    return current
  }
}
```

R3F는 `<PerformanceMonitor>` + `<AdaptiveDpr>`가 같은 일을 한다.

```tsx
const [dpr, setDpr] = useState(1.5)
<Canvas dpr={dpr}>
  <PerformanceMonitor
    onIncline={() => setDpr(Math.min(2, window.devicePixelRatio))}
    onDecline={() => setDpr(1)}
    flipflops={3}                      // 3번 진동하면 포기하고 고정
    onFallback={() => setDpr(1)}
  />
  <AdaptiveDpr pixelated />
</Canvas>
```

### 3.2 안 그릴 때 안 그리기

```js
// 1) 탭이 백그라운드일 때
document.addEventListener('visibilitychange', () => {
  document.hidden ? renderer.setAnimationLoop(null) : renderer.setAnimationLoop(tick)
})

// 2) 캔버스가 뷰포트 밖일 때
new IntersectionObserver(([e]) => {
  e.isIntersecting ? start() : stop()
}, { rootMargin: '10%' }).observe(canvas)

// 3) 씬이 정적일 때 — 온디맨드 렌더
let needsRender = true
function invalidate() { needsRender = true }
renderer.setAnimationLoop(() => {
  if (!needsRender) return
  needsRender = false
  renderer.render(scene, camera)
})
controls.addEventListener('change', invalidate)
window.addEventListener('resize', invalidate)
```

R3F:

```tsx
<Canvas frameloop="demand">   {/* 기본 always */}
// 씬 안에서
const invalidate = useThree(s => s.invalidate)
// 값이 바뀔 때 invalidate() 호출
```

**효과**: 정적 제품 뷰어에서 배터리 소모가 **80% 이상** 감소한다.

### 3.3 드로우콜 줄이기

| 방법 | 적용 조건 | 효과 |
|---|---|---|
| `InstancedMesh` | 동일 지오메트리 + 동일 머티리얼 | N개 → 1콜 |
| `BatchedMesh` [r159+] | 서로 다른 지오메트리 + 동일 머티리얼 | N개 → 1콜 |
| `BufferGeometryUtils.mergeGeometries()` | 정적 오브젝트 | N개 → 1콜 (개별 제어 불가) |
| drei `<Merged>` | R3F에서 인스턴싱 자동화 | 동일 |
| 텍스처 아틀라스 | 머티리얼이 여러 개인 이유가 텍스처뿐일 때 | 머티리얼 통합 |

```js
// 정적 지오메트리 병합
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js'

const geometries = meshes.map(m => {
  const g = m.geometry.clone()
  g.applyMatrix4(m.matrixWorld)
  return g
})
const merged = BufferGeometryUtils.mergeGeometries(geometries)
const one = new THREE.Mesh(merged, sharedMaterial)
```

### 3.4 지오메트리·머티리얼 공유

```js
// ❌ 100개의 지오메트리 + 100개의 셰이더 프로그램
items.forEach(() => new THREE.Mesh(new THREE.PlaneGeometry(1,1), new THREE.MeshBasicMaterial()))

// ✅ 1개 지오메트리 + 1개 머티리얼
const geometry = new THREE.PlaneGeometry(1, 1)
const material = new THREE.MeshBasicMaterial()
items.forEach(() => new THREE.Mesh(geometry, material))
```

uniform이 인스턴스마다 달라야 하면 `InstancedBufferAttribute`나 `onBeforeRender`를 쓴다.

### 3.5 텍스처 최적화

```js
// 표시 크기에 맞춰 리사이즈 (서버/빌드 타임에)
// 카드가 400px 폭이면 800px 텍스처면 충분하다 (DPR 2)

texture.colorSpace = THREE.SRGBColorSpace   // 컬러 텍스처만
texture.generateMipmaps = false             // 축소 표시 안 하면 메모리 25% 절약
texture.minFilter = THREE.LinearFilter      // 밉맵 없으면 필수
texture.anisotropy = 1                      // 4 이상은 대부분 낭비. 바닥 텍스처만 8
texture.wrapS = texture.wrapT = THREE.ClampToEdgeWrapping

// 대량 텍스처 로딩은 ImageBitmap 로더가 메인스레드를 덜 막는다
const loader = new THREE.ImageBitmapLoader()
loader.setOptions({ imageOrientation: 'flipY' })
```

**포맷 결정 트리**

```
텍스처가 3D 모델에 붙는가?
├─ 예 → VRAM이 문제인가?
│        ├─ 예 → KTX2 (알베도: ETC1S, 노멀: UASTC)
│        └─ 아니오 → WebP (다운로드만 절약)
└─ 아니오(플레인/배경) → WebP 또는 AVIF, 밉맵 끄기
```

### 3.6 셰이더 비용 줄이기

| 항목 | 나쁨 | 좋음 |
|---|---|---|
| precision | 전부 `highp` | 색/일반 계산은 `mediump` (모바일 ~2배) |
| varying 개수 | 8개 | **3개 이하** (모바일 GPU 레지스터 압박) |
| 텍스처 페치 | 프래그먼트당 8회 | 4회 이하. 4채널에 데이터를 패킹 |
| 노이즈 | fragment에서 FBM 5옥타브 | vertex로 이동하거나 옥타브 3 이하 |
| 분기 | `if (uniform > 0.5)` | `mix()` + `step()` 또는 `#define` |
| `pow(x, 2.0)` | | `x * x` |
| `length(v)` 비교 | `length(a-b) < r` | `dot(d,d) < r*r` (sqrt 제거) |
| `normalize()` | 매번 | 필요할 때만. vertex에서 정규화해 varying으로 |

### 3.7 오버드로우 (투명 파티클의 진짜 적)

투명 픽셀이 겹칠수록 프래그먼트 셰이더가 반복 실행된다. 파티클 3만 개가 화면을 5겹 덮으면 실질 필레이트는 5배다.

```js
material.depthWrite = false        // 투명 오브젝트는 필수
material.depthTest = true
material.blending = THREE.AdditiveBlending
```

```glsl
// 얼리 아웃으로 블렌딩 비용 절감
if (alpha < 0.01) discard;
```

**가장 효과적인 대책은 파티클 개수가 아니라 `gl_PointSize`를 줄이는 것이다.**

### 3.8 그림자

랜딩페이지에서 실시간 그림자는 거의 항상 손해다.

| 대안 | 비용 | 품질 |
|---|---|---|
| `ContactShadows` (drei, `frames={1}`) | 1회 렌더 후 정지 | 좋음 |
| `AccumulativeShadows` (drei) | 초기 N프레임만 | 매우 좋음 |
| Baked AO 텍스처 | 0 | 최고 (정적 씬) |
| 반투명 원형 플레인 | 0 | 보통 |
| `PCFShadowMap` [r182+] | 라이트당 1 렌더 패스 | 보통 |

---

## 4. 로딩 전략

### 4.1 3단계 로딩

```
[0ms]     DOM + CSS 폴백 배경 (그라디언트/정적 이미지)  ← LCP는 여기서 확정
[LCP 후]  three + 씬 코드 dynamic import (~90KB)
[씬 준비]  캔버스 fade-in (300ms), 폴백 배경은 뒤에 그대로 유지
[유휴]    무거운 에셋(GLTF, HDRI)을 requestIdleCallback 으로 로드
```

```js
// src/gl/bootstrap.js
export async function bootstrapScene(canvas, { onReady } = {}) {
  // 1) 게이팅
  if (!canGL()) return null

  // 2) LCP 이후까지 기다린다
  await afterLCP()

  // 3) 뷰포트 진입 대기
  await inViewport(canvas)

  // 4) 코드 로드
  const { default: mount } = await import('../scenes/hero.js')

  // 5) 마운트 + 페이드인
  const dispose = mount(canvas, {})
  canvas.style.transition = 'opacity 400ms ease'
  canvas.style.opacity = '1'
  onReady?.()
  return dispose
}

function canGL() {
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
  if (reduced) return false
  if (navigator.connection?.saveData) return false
  if ((navigator.deviceMemory ?? 8) < 4) return false
  if ((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 }
}

function afterLCP() {
  return new Promise((resolve) => {
    if (!('PerformanceObserver' in window)) return setTimeout(resolve, 800)
    let done = false
    const finish = () => { if (!done) { done = true; resolve() } }
    try {
      const po = new PerformanceObserver(() => { po.disconnect(); finish() })
      po.observe({ type: 'largest-contentful-paint', buffered: true })
    } catch { /* noop */ }
    // 안전망
    setTimeout(finish, 2000)
  })
}

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

### 4.2 진행률 표시 (three `LoadingManager`)

```js
const manager = new THREE.LoadingManager()
manager.onProgress = (url, loaded, total) => {
  const pct = Math.round((loaded / total) * 100)
  document.querySelector('.loader__bar').style.transform = `scaleX(${pct / 100})`
}
manager.onLoad = () => {
  document.querySelector('.loader').classList.add('is-done')
}
manager.onError = (url) => {
  console.error('load failed:', url)
  document.querySelector('.loader').classList.add('is-done')  // 실패해도 로더는 치운다
}

const textureLoader = new THREE.TextureLoader(manager)
const gltfLoader = new GLTFLoader(manager)
```

**로더의 원칙**: 로딩 화면은 **1.5초 이상 걸릴 때만** 보여라. 그보다 짧으면 깜빡임이 더 나쁘다.

```js
let loaderShown = false
const showTimer = setTimeout(() => { loaderShown = true; showLoader() }, 400)
manager.onLoad = () => {
  clearTimeout(showTimer)
  if (loaderShown) hideLoaderWithDelay(300)   // 최소 표시 시간 보장
  else hideLoader()
}
```

### 4.3 프로그레시브 에셋

```js
// 저해상도 → 고해상도 교체
const lowRes = await loader.loadAsync('/img/hero-32.jpg')      // 2KB, 즉시 표시
material.uniforms.uTexture.value = lowRes

requestIdleCallback(async () => {
  const highRes = await loader.loadAsync('/img/hero-1600.webp')
  highRes.colorSpace = THREE.SRGBColorSpace
  material.uniforms.uTexture.value = highRes
  lowRes.dispose()
})
```

---

## 5. 품질 티어 시스템 (모바일/저사양 분기)

**한 번 판정하고 전체 씬 설정을 결정한다.** 개별 컴포넌트가 각자 판단하면 조합 폭발이 일어난다.

```js
// src/gl/quality.js
import { getGPUTier } from 'detect-gpu'

/**
 * @typedef {'off'|'low'|'mid'|'high'} Tier
 * @typedef {object} QualityConfig
 */

const CONFIGS = {
  off: null,
  low: {
    tier: 'low',
    dpr: 1,
    antialias: false,
    shadows: false,
    postprocessing: false,
    particleCount: 4000,
    gpgpuSize: 64,
    planeSegments: 48,
    fbmOctaves: 2,
    textureMaxSize: 1024,
    transmissionSamples: 2,
    transmissionResolution: 256,
    environment: false,
    targetFps: 30,
  },
  mid: {
    tier: 'mid',
    dpr: 1.5,
    antialias: false,
    shadows: false,
    postprocessing: 'minimal',   // vignette + tonemapping 만
    particleCount: 15000,
    gpgpuSize: 128,
    planeSegments: 96,
    fbmOctaves: 3,
    textureMaxSize: 1536,
    transmissionSamples: 4,
    transmissionResolution: 512,
    environment: true,
    targetFps: 60,
  },
  high: {
    tier: 'high',
    dpr: 2,
    antialias: true,
    shadows: true,
    postprocessing: 'full',
    particleCount: 40000,
    gpgpuSize: 256,
    planeSegments: 160,
    fbmOctaves: 4,
    textureMaxSize: 2048,
    transmissionSamples: 8,
    transmissionResolution: 1024,
    environment: true,
    targetFps: 60,
  },
}

let cached = null

/**
 * 품질 티어를 판정한다. 결과는 캐시되고 sessionStorage 에 저장된다.
 * @returns {Promise<QualityConfig|null>} null 이면 WebGL 을 쓰지 않는다
 */
export async function resolveQuality({ force } = {}) {
  if (cached !== undefined && cached !== null) return cached
  if (force) return CONFIGS[force]

  // 0) 하드 차단 조건
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
  if (reduced) return (cached = null)
  if (navigator.connection?.saveData) return (cached = null)

  // 1) 캐시된 판정 재사용 (detect-gpu 벤치마크는 ~50ms 걸린다)
  const stored = sessionStorage.getItem('gl-tier')
  if (stored && CONFIGS[stored] !== undefined) {
    return (cached = CONFIGS[stored])
  }

  // 2) 저사양 하드웨어 신호
  const mem = navigator.deviceMemory ?? 8
  const cores = navigator.hardwareConcurrency ?? 8
  if (mem < 4 || cores < 4) {
    sessionStorage.setItem('gl-tier', 'off')
    return (cached = null)
  }

  // 3) GPU 벤치마크
  let tier = 'mid'
  try {
    const gpu = await getGPUTier({ benchmarksURL: '/benchmarks' })  // 자체 호스팅 권장
    // gpu.tier: 0(미지원/블록리스트) 1(<30fps) 2(<60fps) 3(60fps+)
    if (gpu.tier === 0 || gpu.type === 'BLOCKLISTED') tier = 'off'
    else if (gpu.tier === 1) tier = 'low'
    else if (gpu.tier === 2) tier = gpu.isMobile ? 'low' : 'mid'
    else tier = gpu.isMobile ? 'mid' : 'high'
  } catch {
    // detect-gpu 실패 시 보수적으로
    tier = matchMedia('(max-width: 768px)').matches ? 'low' : 'mid'
  }

  sessionStorage.setItem('gl-tier', tier)
  cached = CONFIGS[tier]
  return cached
}

/** 런타임에 티어를 낮춘다 (프레임 저하 감지 시) */
export function degrade(current) {
  const order = ['high', 'mid', 'low', 'off']
  const i = order.indexOf(current.tier)
  const next = order[Math.min(i + 1, order.length - 1)]
  sessionStorage.setItem('gl-tier', next)
  return CONFIGS[next]
}
```

### 런타임 자동 강등

```js
// src/gl/watchdog.js
/**
 * N초 동안 목표 fps에 못 미치면 콜백을 부른다.
 * @param {number} targetFps
 * @param {() => void} onFail
 */
export function createWatchdog(targetFps, onFail, { window: winSec = 4, ratio = 0.7 } = {}) {
  let elapsed = 0
  let badFrames = 0
  let totalFrames = 0
  let fired = false

  return function update(delta) {
    if (fired) return
    elapsed += delta
    totalFrames++
    if (1 / delta < targetFps * 0.75) badFrames++

    if (elapsed >= winSec) {
      if (badFrames / totalFrames > 1 - ratio) {
        fired = true
        onFail()
      }
      elapsed = 0; badFrames = 0; totalFrames = 0
    }
  }
}
```

사용:

```js
const quality = await resolveQuality()
if (!quality) { /* 폴백 DOM 만 남긴다 */ }
else {
  const dispose = mountScene(canvas, quality)
  const watchdog = createWatchdog(quality.targetFps, () => {
    dispose()
    const lower = degrade(quality)
    if (lower) mountScene(canvas, lower)
    else canvas.remove()          // 완전히 포기
  })
  stage.add((t, dt) => watchdog(dt))
}
```

### R3F 버전

```tsx
import { useDetectGPU } from '@react-three/drei'

function Scene() {
  const gpu = useDetectGPU()
  const quality = gpu.tier === 0 ? 'off'
    : gpu.tier === 1 ? 'low'
    : gpu.tier === 2 ? (gpu.isMobile ? 'low' : 'mid')
    : (gpu.isMobile ? 'mid' : 'high')

  if (quality === 'off') return null
  return <>{/* quality 에 따라 컴포넌트 분기 */}</>
}
```

---

## 6. 폴백

### 6.1 폴백 계층

```
1. WebGL2 + 고사양     → 전체 효과
2. WebGL2 + 저사양     → 축소 효과 (파티클 1/4, 포스트프로세싱 없음)
3. WebGL 미지원        → CSS 그라디언트 / 정적 이미지
4. prefers-reduced-motion → 정지 프레임 1장 또는 CSS만
5. saveData / 저메모리  → CSS만
6. JS 비활성           → CSS만 (캔버스는 애초에 비어 있음)
```

### 6.2 CSS 폴백을 "기본값"으로 설계한다

캔버스를 **덮어씌우는** 방식이 아니라, 캔버스 **뒤에 항상 존재하게** 한다.

```css
.hero__canvas-wrap {
  position: absolute;
  inset: 0;
  z-index: -1;
  /* 폴백: 항상 여기 있다. WebGL 이 성공하면 캔버스가 위를 덮는다 */
  background:
    radial-gradient(90% 70% at 20% 0%, #3b0764 0%, transparent 60%),
    radial-gradient(80% 60% at 85% 30%, #1d4ed8 0%, transparent 55%),
    linear-gradient(180deg, #0b0b16 0%, #07070a 100%);
}

.hero__canvas-wrap canvas {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  display: block;
  opacity: 0;
  transition: opacity 400ms ease;
}
.hero__canvas-wrap canvas.is-ready { opacity: 1; }

/* reduced-motion: 캔버스를 아예 숨기고 CSS 만 남긴다 */
@media (prefers-reduced-motion: reduce) {
  .hero__canvas-wrap canvas { display: none; }
}
```

**정적 이미지 폴백**이 더 정확한 재현이 필요하면 `<picture>`로:

```html
<picture>
  <source media="(prefers-reduced-motion: reduce)" srcset="/img/hero-static.avif" />
  <img src="/img/hero-static.avif" alt="" aria-hidden="true" class="hero__fallback" />
</picture>
```

> 팁: 폴백 이미지는 **셰이더를 한 프레임 렌더해서 캡처**해 만들면 완벽히 일치한다.
> `renderer.domElement.toDataURL('image/webp', 0.85)`

### 6.3 WebGL 컨텍스트 로스

```js
canvas.addEventListener('webglcontextlost', (e) => {
  e.preventDefault()              // 이게 없으면 restored 이벤트가 오지 않는다
  cancelRenderLoop()
  showFallback()                  // 폴백 배경 노출
}, false)

canvas.addEventListener('webglcontextrestored', () => {
  // three 는 컨텍스트 복구 시 리소스를 자동 재생성하지 않는다.
  // 가장 안전한 방법: 씬을 통째로 다시 만든다
  disposeScene()
  rebuildScene()
  startRenderLoop()
  hideFallback()
}, false)
```

**컨텍스트 로스가 나는 이유**: VRAM 고갈(가장 흔함), 드라이버 크래시, 탭 장시간 백그라운드, 페이지에 WebGL 컨텍스트가 너무 많음(브라우저 상한 보통 8~16개).

**예방**: 캔버스를 여러 개 만들지 마라. drei `<View>`나 씬 전환으로 **컨텍스트 1개**를 유지한다.

### 6.4 메모리 누수 방지

```js
// SPA 라우팅에서 반드시 호출
function disposeScene(scene, renderer) {
  scene.traverse((obj) => {
    obj.geometry?.dispose()
    const mats = Array.isArray(obj.material) ? obj.material : obj.material ? [obj.material] : []
    mats.forEach((m) => {
      Object.values(m).forEach((v) => {
        if (v?.isTexture) { v.dispose(); v.image?.close?.() }
      })
      if (m.uniforms) {
        Object.values(m.uniforms).forEach((u) => {
          if (u.value?.isTexture) u.value.dispose()
          if (u.value?.isWebGLRenderTarget) u.value.dispose()
        })
      }
      m.dispose()
    })
  })
  scene.clear()
  renderTargets.forEach(rt => rt.dispose())
  renderer.dispose()
  renderer.forceContextLoss()      // 컨텍스트 슬롯 즉시 반환
}
```

**검증**: `renderer.info.memory`가 씬 파괴 후 `{ geometries: 0, textures: 0 }`에 가까워지는지 확인.
배경/환경맵 등 일부 내부 텍스처는 남을 수 있다(정상).

---

## 7. 접근성

### 7.1 `prefers-reduced-motion`

이건 **선택이 아니라 요구사항**이다(WCAG 2.2 C39, 성공기준 2.3.3).

```js
const mq = matchMedia('(prefers-reduced-motion: reduce)')

function applyMotionPreference(reduced) {
  if (reduced) {
    stage.stop()
    stage.renderOnce()          // 정지 프레임 1장
    lenis?.stop()
    gsap.globalTimeline.timeScale(0)
  } else {
    stage.start()
    lenis?.start()
    gsap.globalTimeline.timeScale(1)
  }
}

applyMotionPreference(mq.matches)
mq.addEventListener('change', (e) => applyMotionPreference(e.matches))  // 실시간 반응
```

**단계별 대응** (전부 끄는 게 항상 정답은 아니다)

| 효과 | reduced-motion 대응 |
|---|---|
| 자동 재생 루프 애니메이션 | **정지** (1프레임 렌더) |
| 패럴랙스 / 스크롤 연동 카메라 | **비활성** (고정 시점) |
| 스무스 스크롤 (Lenis) | 비활성 (`respectReducedMotion: true` 기본) |
| 마우스 반응 | **유지 가능** (사용자가 유발한 것이므로) |
| 페이드 인/아웃 | 유지 가능 (움직임이 아니므로) |
| 플래시/스트로브 | **반드시 제거** (광과민성 발작 위험) |
| 파티클 대량 이동 | 정지 또는 진폭 25%로 축소 |

### 7.2 캔버스와 접근성 트리

```html
<!-- 장식용 캔버스: 접근성 트리에서 제외 -->
<canvas aria-hidden="true" role="presentation"></canvas>
```

```html
<!-- 의미 있는 캔버스(제품 3D 뷰): 대체 텍스트 제공 -->
<canvas
  role="img"
  aria-label="제품 3D 미리보기. 회전하는 무선 이어폰."
  tabindex="0"
></canvas>
<p class="visually-hidden">
  이 3D 뷰의 정적 이미지: <a href="/product-photos">제품 사진 갤러리</a>
</p>
```

### 7.3 콘텐츠는 항상 DOM에

**WebGL 안의 텍스트는 검색되지 않고, 선택되지 않고, 스크린리더가 읽지 못한다.**

```
❌ 히어로 제목을 troika-three-text 로만 렌더
✅ DOM 에 <h1>을 두고, 캔버스는 배경 장식
✅ 또는 DOM <h1>을 시각적으로 숨기고(visually-hidden) WebGL 텍스트를 병행
```

```css
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
```

### 7.4 키보드와 포커스

- 캔버스가 인터랙티브하면 `tabindex="0"` + 키보드 대체 조작을 제공한다.
- 캔버스가 장식이면 `pointer-events: none`으로 포인터를 통과시켜 아래 DOM이 클릭되게 한다.
- 포커스가 캔버스 뒤 요소로 갔을 때 시각적 피드백이 가려지지 않는지 확인한다.

```js
// 3D 오브젝트 = 링크인 경우, DOM 링크를 겹쳐 둔다
// <a href="/project-1" class="hotspot" style="top:...;left:...">Project 1</a>
```

### 7.5 색 대비

WebGL로 그린 배경 위의 DOM 텍스트는 대비를 보장하기 어렵다.

```css
/* 텍스트 뒤에 반투명 레이어를 깔아 최소 대비를 보장 */
.hero__inner {
  position: relative;
}
.hero__inner::before {
  content: '';
  position: absolute; inset: -2rem;
  background: radial-gradient(closest-side, rgba(0,0,0,0.55), transparent);
  z-index: -1;
}
```

또는 셰이더에서 텍스트 영역의 밝기를 강제로 낮춘다(마스크 uniform 전달).

---

## 8. 배터리 · 발열

WebGL은 GPU를 100% 점유할 수 있다. 모바일에서 3분 이상 돌면 **써멀 스로틀링**이 걸려 fps가 절반이 된다.

| 대책 | 구현 |
|---|---|
| 뷰포트 밖 정지 | IntersectionObserver (§3.2) |
| 탭 백그라운드 정지 | `visibilitychange` (§3.2) |
| 정적 씬 온디맨드 | `frameloop="demand"` |
| 프레임 상한 | 60fps 고정 (120Hz 기기에서 절반 절약) |
| 저전력 모드 감지 | `navigator.getBattery()` (지원 제한적) |
| 장시간 자동 강등 | 3분 후 DPR 하향 |

```js
// 60fps 상한 (ProMotion 120Hz 기기 대응)
let lastFrame = 0
const MIN_INTERVAL = 1000 / 60 - 1
renderer.setAnimationLoop((now) => {
  if (now - lastFrame < MIN_INTERVAL) return
  lastFrame = now
  render()
})
```

```js
// 배터리 상태 (Chrome/Edge 일부)
navigator.getBattery?.().then((battery) => {
  const check = () => {
    if (battery.level < 0.2 && !battery.charging) {
      quality = degrade(quality)   // 저전력 시 강등
    }
  }
  check()
  battery.addEventListener('levelchange', check)
  battery.addEventListener('chargingchange', check)
})
```

```js
// 장시간 실행 시 자동 강등
let runningTime = 0
stage.add((t, dt) => {
  runningTime += dt
  if (runningTime > 180 && !degraded) {   // 3분
    degraded = true
    renderer.setPixelRatio(Math.min(renderer.getPixelRatio(), 1))
  }
})
```

---

## 9. OffscreenCanvas (선택적 고급 최적화)

렌더링을 워커로 옮겨 메인 스레드의 **INP/TBT를 보호**한다. 실측 사례에서 Lighthouse 95 → 100.

**적용 조건**
- 씬이 무겁고 메인 스레드 인터랙션(폼, 스크롤)이 중요할 때
- 씬이 DOM과 강하게 결합되지 않을 때 (DOM 동기화 갤러리에는 부적합)

```js
// main.js
const canvas = document.getElementById('gl')

if ('transferControlToOffscreen' in canvas) {
  const offscreen = canvas.transferControlToOffscreen()
  const worker = new Worker(new URL('./gl.worker.js', import.meta.url), { type: 'module' })

  worker.postMessage({
    type: 'init',
    canvas: offscreen,
    width: canvas.clientWidth,
    height: canvas.clientHeight,
    dpr: Math.min(devicePixelRatio, 2),
  }, [offscreen])

  const ro = new ResizeObserver(() => {
    worker.postMessage({
      type: 'resize',
      width: canvas.clientWidth,
      height: canvas.clientHeight,
      dpr: Math.min(devicePixelRatio, 2),
    })
  })
  ro.observe(canvas)

  // 워커에는 DOM 이벤트가 없다. 메인에서 좌표만 전달
  window.addEventListener('pointermove', (e) => {
    worker.postMessage({
      type: 'pointer',
      x: e.clientX / innerWidth,
      y: 1 - e.clientY / innerHeight,
    })
  }, { passive: true })

  document.addEventListener('visibilitychange', () => {
    worker.postMessage({ type: document.hidden ? 'pause' : 'resume' })
  })
} else {
  // 폴백: 메인 스레드 렌더
  import('./scenes/hero.js').then(({ default: mount }) => mount(canvas, {}))
}
```

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

let renderer, scene, camera, uniforms, running = false

self.onmessage = ({ data }) => {
  switch (data.type) {
    case 'init': init(data); break
    case 'resize': resize(data); break
    case 'pointer': uniforms.uPointer.value.set(data.x, data.y); break
    case 'pause': stop(); break
    case 'resume': start(); break
    case 'dispose': dispose(); break
  }
}

function init({ canvas, width, height, dpr }) {
  // 워커의 OffscreenCanvas 에는 style 이 없다. three 가 참조하므로 스텁을 넣는다
  canvas.style = { width: `${width}px`, height: `${height}px` }

  renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false })
  renderer.outputColorSpace = THREE.SRGBColorSpace
  renderer.setPixelRatio(dpr)
  renderer.setSize(width, height, false)

  scene = new THREE.Scene()
  camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100)
  camera.position.z = 5

  uniforms = {
    uTime: { value: 0 },
    uResolution: { value: new THREE.Vector2(width * dpr, height * dpr) },
    uPointer: { value: new THREE.Vector2(0.5, 0.5) },
  }

  const geo = new THREE.BufferGeometry()
  geo.setAttribute('position', new THREE.BufferAttribute(
    new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3))
  geo.setAttribute('uv', new THREE.BufferAttribute(
    new Float32Array([0, 0, 2, 0, 0, 2]), 2))

  const mat = new THREE.ShaderMaterial({
    uniforms,
    vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
    fragmentShader: `
      precision mediump float;
      uniform float uTime; uniform vec2 uResolution; uniform vec2 uPointer;
      varying vec2 vUv;
      void main() {
        vec2 uv = gl_FragCoord.xy / uResolution;
        float d = distance(uv, uPointer);
        vec3 col = mix(vec3(0.05,0.03,0.12), vec3(0.42,0.25,0.85),
                       smoothstep(0.6, 0.0, d) + sin(uTime + uv.y * 6.0) * 0.08);
        gl_FragColor = vec4(col, 1.0);
      }`,
    depthTest: false, depthWrite: false,
  })
  const mesh = new THREE.Mesh(geo, mat)
  mesh.frustumCulled = false
  scene.add(mesh)

  start()
}

function resize({ width, height, dpr }) {
  renderer.setPixelRatio(dpr)
  renderer.setSize(width, height, false)
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  uniforms.uResolution.value.set(width * dpr, height * dpr)
}

function start() {
  if (running) return
  running = true
  renderer.setAnimationLoop((t) => {
    uniforms.uTime.value = t / 1000
    renderer.render(scene, camera)
  })
}

function stop() {
  running = false
  renderer.setAnimationLoop(null)
}

function dispose() {
  stop()
  scene.traverse(o => { o.geometry?.dispose(); o.material?.dispose() })
  renderer.dispose()
}
```

**제약**
- 워커 안에서는 `document`, `window`, DOM 이벤트를 쓸 수 없다. **모든 입력을 postMessage로 전달**해야 한다.
- `TextureLoader`는 `ImageBitmapLoader`로 대체해야 한다 (`Image`가 없음).
- 디버깅이 훨씬 어렵다. **먼저 메인 스레드로 완성하고, 마지막에 옮겨라.**
- three가 `canvas.style.width/height`를 참조하므로 스텁을 넣어야 한다.

---

## 10. 실측 체크리스트

### 배포 전 필수 검증

**성능**
- [ ] Chrome DevTools Performance 녹화: 4x CPU 스로틀에서 롱태스크(> 50ms) 3개 이하
- [ ] `renderer.info.render.calls` < 150(데스크톱) / < 60(모바일)
- [ ] 실기기(중저가 안드로이드)에서 30fps 이상 유지
- [ ] 3분 연속 실행 후에도 fps가 초기 대비 70% 이상
- [ ] `renderer.info.memory`가 3분간 증가하지 않음 (누수 없음)
- [ ] Lighthouse 모바일 Performance ≥ 85, LCP ≤ 2.5s, CLS ≤ 0.1, TBT ≤ 300ms
- [ ] 초기 JS(three 제외) < 200KB gzip
- [ ] Network 탭에서 three 청크가 **LCP 이후**에 요청됨

**폴백**
- [ ] `chrome://flags` 또는 확장으로 WebGL 비활성 → 페이지가 정상적으로 보임
- [ ] OS 설정에서 "동작 줄이기" 켬 → 애니메이션 정지, 콘텐츠 정상
- [ ] DevTools에서 `Save-Data: on` 에뮬레이션 → WebGL 미실행
- [ ] `WEBGL_lose_context.loseContext()` 강제 호출 → 폴백 노출 + 크래시 없음
- [ ] 저사양 티어 강제(`?quality=low`) → 씬이 정상 동작
- [ ] JS 비활성 → 페이지 콘텐츠가 모두 읽힘

**접근성**
- [ ] 장식 캔버스에 `aria-hidden="true"`
- [ ] 히어로 텍스트가 DOM에 존재하고 선택 가능
- [ ] 키보드만으로 모든 CTA 접근 가능
- [ ] 캔버스 위 텍스트의 대비비 ≥ 4.5:1 (큰 텍스트 3:1)
- [ ] 스크린리더(NVDA/VoiceOver)로 페이지 전체가 읽힘
- [ ] 초당 3회 이상 깜빡이는 요소 없음 (광과민성)

**리소스**
- [ ] 모든 컬러 텍스처에 `colorSpace = SRGBColorSpace`
- [ ] 텍스처 최대 변 ≤ 2048 (모바일 1024)
- [ ] GLTF에 DRACO 또는 meshopt 압축 적용
- [ ] HDRI ≤ 1K, 자체 호스팅
- [ ] DRACO/basis 디코더 자체 호스팅 + 로더 싱글턴
- [ ] 라우트 이탈 시 `dispose()` 호출 확인

### 자동화 스니펫

```js
// src/gl/audit.js — 개발 중 콘솔에서 window.__glAudit() 실행
export function installAudit(renderer, budgets = {}) {
  if (import.meta.env.PROD) return

  const B = {
    calls: 150, triangles: 500000, textures: 40, geometries: 60, programs: 25,
    ...budgets,
  }

  window.__glAudit = () => {
    const i = renderer.info
    const rows = [
      ['draw calls', i.render.calls, B.calls],
      ['triangles', i.render.triangles, B.triangles],
      ['textures', i.memory.textures, B.textures],
      ['geometries', i.memory.geometries, B.geometries],
      ['programs', i.programs?.length ?? 0, B.programs],
    ]
    console.table(rows.map(([name, value, budget]) => ({
      metric: name,
      value,
      budget,
      status: value <= budget ? '✅' : '❌ OVER',
      ratio: `${((value / budget) * 100).toFixed(0)}%`,
    })))
  }

  // fps 히스토그램
  let frames = []
  let last = performance.now()
  const loop = () => {
    const now = performance.now()
    frames.push(1000 / (now - last))
    last = now
    if (frames.length > 600) frames.shift()
    requestAnimationFrame(loop)
  }
  requestAnimationFrame(loop)

  window.__glFps = () => {
    const s = [...frames].sort((a, b) => a - b)
    console.table({
      min: s[0]?.toFixed(1),
      p5: s[Math.floor(s.length * 0.05)]?.toFixed(1),
      p50: s[Math.floor(s.length * 0.5)]?.toFixed(1),
      p95: s[Math.floor(s.length * 0.95)]?.toFixed(1),
      max: s[s.length - 1]?.toFixed(1),
      samples: s.length,
    })
  }
}
```

---

## 11. 안티패턴 목록

| 안티패턴 | 왜 나쁜가 | 대신 |
|---|---|---|
| 페이지에 캔버스 여러 개 | 컨텍스트 상한(8~16), 각각 VRAM 소모 | drei `<View>` 또는 씬 전환 |
| `useFrame` 안에서 `setState` | 초당 60회 리렌더 | ref 직접 변경 |
| 렌더 루프에서 `new THREE.Vector3()` | GC 압력 → 프레임 스파이크 | 모듈 스코프에 재사용 객체 |
| 렌더 루프에서 `getBoundingClientRect()` | 강제 리플로우 | 리사이즈 때만 측정 후 캐시 |
| 렌더 루프에서 레이캐스트 대상 전체 순회 | O(n) × 60fps | `intersectObjects(objs, false)` + BVH |
| 머티리얼을 컴포넌트마다 새로 생성 | 셰이더 재컴파일(수십 ms) | `useMemo` 또는 모듈 스코프 공유 |
| 런타임에 오브젝트 마운트/언마운트 | 셰이더 재컴파일 | `visible` 토글 |
| 4K 텍스처 | 67MB VRAM/장 | 1K~2K + KTX2 |
| `preset="city"` 등 drei Environment 프리셋 | 외부 CDN 의존 + 캐시 불가 | `files="/hdri/*.hdr"` 자체 호스팅 |
| `import * as drei from '@react-three/drei'` | 100KB+ 번들 | named import |
| `lerp(a, b, 0.1)` (dt 무시) | 120Hz에서 2배 빠름 | `1 - Math.pow(f, dt)` |
| `THREE.Clock` [r183+ deprecated] | 곧 제거됨 | `setAnimationLoop(t)` 인자 또는 `THREE.Timer` |
| RAF 루프 2개 이상 (Lenis + three) | 프레임 밀림, 지터 | `gsap.ticker` 하나로 통일 |
| `antialias: true` + DPR 2 | MSAA 비용 2배 | DPR 2면 AA 끄거나 SMAA |
| `postprocessing` 이펙트를 패스마다 분리 | 풀스크린 렌더 N회 | 하나의 `EffectPass`에 병합 |
| 폴백 없이 배포 | 방문자 일부에게 빈 화면 | CSS 폴백을 기본값으로 |
