웹 디자인 파이프라인 스킬과 이를 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)
1248 lines
39 KiB
Markdown
1248 lines
39 KiB
Markdown
# 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 폴백을 기본값으로 |
|