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

39 KiB
Raw Blame History

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은 동일하다.

// 런타임 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 개발 중 상시 표시

// 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에서는:

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

2.2 렌더 통계 덤프

// 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하지 마라. 실제 필요할 때만 받게 한다.
<!-- CLS 방지: 캔버스 자리를 미리 확보 -->
<div class="hero__canvas-wrap" style="aspect-ratio: 16/9; min-height: 60svh;">
  <canvas id="gl"></canvas>
</div>
// 셰이더 컴파일 비동기화 [r170+]
await renderer.compileAsync(scene, camera)   // 첫 렌더 전 미리 컴파일

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

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

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

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 (프레임레이트에 따라):

// 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>가 같은 일을 한다.

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 안 그릴 때 안 그리기

// 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:

<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에서 인스턴싱 자동화 동일
텍스처 아틀라스 머티리얼이 여러 개인 이유가 텍스처뿐일 때 머티리얼 통합
// 정적 지오메트리 병합
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 지오메트리·머티리얼 공유

// ❌ 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이 인스턴스마다 달라야 하면 InstancedBufferAttributeonBeforeRender를 쓴다.

3.5 텍스처 최적화

// 표시 크기에 맞춰 리사이즈 (서버/빌드 타임에)
// 카드가 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배다.

material.depthWrite = false        // 투명 오브젝트는 필수
material.depthTest = true
material.blending = THREE.AdditiveBlending
// 얼리 아웃으로 블렌딩 비용 절감
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 으로 로드
// 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)

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초 이상 걸릴 때만 보여라. 그보다 짧으면 깜빡임이 더 나쁘다.

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

4.3 프로그레시브 에셋

// 저해상도 → 고해상도 교체
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. 품질 티어 시스템 (모바일/저사양 분기)

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

// 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]
}

런타임 자동 강등

// 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
    }
  }
}

사용:

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 버전

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 폴백을 "기본값"으로 설계한다

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

.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>로:

<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 컨텍스트 로스

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 메모리 누수 방지

// 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).

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 캔버스와 접근성 트리

<!-- 장식용 캔버스: 접근성 트리에서 제외 -->
<canvas aria-hidden="true" role="presentation"></canvas>
<!-- 의미 있는 캔버스(제품 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 텍스트를 병행
.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이 클릭되게 한다.
  • 포커스가 캔버스 뒤 요소로 갔을 때 시각적 피드백이 가려지지 않는지 확인한다.
// 3D 오브젝트 = 링크인 경우, DOM 링크를 겹쳐 둔다
// <a href="/project-1" class="hotspot" style="top:...;left:...">Project 1</a>

7.5 색 대비

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

/* 텍스트 뒤에 반투명 레이어를 깔아 최소 대비를 보장 */
.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 하향
// 60fps 상한 (ProMotion 120Hz 기기 대응)
let lastFrame = 0
const MIN_INTERVAL = 1000 / 60 - 1
renderer.setAnimationLoop((now) => {
  if (now - lastFrame < MIN_INTERVAL) return
  lastFrame = now
  render()
})
// 배터리 상태 (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)
})
// 장시간 실행 시 자동 강등
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 동기화 갤러리에는 부적합)
// 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, {}))
}
// 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로 전달해야 한다.
  • TextureLoaderImageBitmapLoader로 대체해야 한다 (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() 호출 확인

자동화 스니펫

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