# 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' // 내부에 ``` ### 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
``` ```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는 `` + ``가 같은 일을 한다. ```tsx const [dpr, setDpr] = useState(1.5) setDpr(Math.min(2, window.devicePixelRatio))} onDecline={() => setDpr(1)} flipflops={3} // 3번 진동하면 포기하고 고정 onFallback={() => setDpr(1)} /> ``` ### 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 {/* 기본 always */} // 씬 안에서 const invalidate = useThree(s => s.invalidate) // 값이 바뀔 때 invalidate() 호출 ``` **효과**: 정적 제품 뷰어에서 배터리 소모가 **80% 이상** 감소한다. ### 3.3 드로우콜 줄이기 | 방법 | 적용 조건 | 효과 | |---|---|---| | `InstancedMesh` | 동일 지오메트리 + 동일 머티리얼 | N개 → 1콜 | | `BatchedMesh` [r159+] | 서로 다른 지오메트리 + 동일 머티리얼 | N개 → 1콜 | | `BufferGeometryUtils.mergeGeometries()` | 정적 오브젝트 | N개 → 1콜 (개별 제어 불가) | | drei `` | 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} 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; } } ``` **정적 이미지 폴백**이 더 정확한 재현이 필요하면 ``로: ```html ``` > 팁: 폴백 이미지는 **셰이더를 한 프레임 렌더해서 캡처**해 만들면 완벽히 일치한다. > `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 ``나 씬 전환으로 **컨텍스트 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 ``` ```html

이 3D 뷰의 정적 이미지: 제품 사진 갤러리

``` ### 7.3 콘텐츠는 항상 DOM에 **WebGL 안의 텍스트는 검색되지 않고, 선택되지 않고, 스크린리더가 읽지 못한다.** ``` ❌ 히어로 제목을 troika-three-text 로만 렌더 ✅ DOM 에

을 두고, 캔버스는 배경 장식 ✅ 또는 DOM

을 시각적으로 숨기고(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 링크를 겹쳐 둔다 // Project 1 ``` ### 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 `` 또는 씬 전환 | | `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 폴백을 기본값으로 |