designpaca/research/three/04-performance.md
Yun Chan 8808c672dc designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포.

스킬 (packages/skill)
- SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로
  본문은 절차와 인덱스만, 지식은 references/ 로 분리
- 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기
- 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외
- 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙,
  SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침

설치 CLI (packages/cli, packages/core)
- npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md
- 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다
- 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성
- AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입
- Windsurf 는 12,000자 상한 초과 시 설치를 차단

배포 (build/ci, .forgejo/workflows)
- 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러
  → draft 릴리스 → Cloudflare Pages. 재실행 멱등

근거 (research/)
- 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다

테스트 22개 통과 (core 16 · cli 6)
2026-08-20 10:48:00 +09:00

1248 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 폴백을 기본값으로 |