designpaca/packages/skill/references/three.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

598 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# three — 입체와 공간
4-3에서 읽는다. **레이아웃과 재질이 끝난 뒤에 온다.** three는 세 번째 도구지 첫 번째 아이디어가 아니다.
## 이 문서를 읽는 법
전부 읽지 마라. 필요한 절만 열어라.
| 상황 | 읽을 곳 |
|---|---|
| **three를 쓸지 아직 안 정했다** | **§0만.** 대부분 여기서 "안 쓴다"로 끝나고, **그게 가장 흔한 정답이다** |
| 쓰기로 했다 | §1 버전 핀 + §2 함정 + **§3 코드 A(지연 로드)** — 이 셋이 최소 필수 |
| 배경 이펙트가 필요하다 | + §4 코드 B (메시 그라디언트) |
| 모바일·저사양을 만난다 | + §5 코드 C (품질 티어) + §8 성능 |
| 이미지 그리드를 강화한다 | + §6 코드 D |
| 다른 패턴을 찾는다 | §7 표에서 고르고 `research/three/02-patterns.md`를 열어라 |
| 감사 직전 | §9 Lighthouse + §10 폴백 + §11 체크리스트 |
§0을 통과하지 못하면 나머지는 읽을 필요가 없다. **"안 쓴다"는 실패가 아니라 이 스킬의 규칙이다.**
---
## 0. 채택 게이트 — "쓰지 않는다"를 먼저 통과시켜라
| 원하는 것 | 먼저 시도 | three가 필요한 순간 |
|---|---|---|
| 유동적 그라디언트 배경 | `radial-gradient` 겹치기 + `filter: blur()` | 마우스/스크롤에 유기적으로 반응해야 |
| 유리·굴절 | `backdrop-filter` + SVG `feDisplacementMap` | 뒤 오브젝트가 3D로 왜곡돼야 |
| 그레인·노이즈 | SVG `feTurbulence` | 노이즈가 시간에 따라 흘러야 |
| 이미지 왜곡 | `clip-path` + `transform` | 픽셀 단위 변형·색수차가 필요 |
| 떠다니는 입자 | CSS 애니메이션 20개 | 1,000개 이상이거나 서로 반응해야 |
| 제품 회전 | 스프라이트 시퀀스(36장 WebP) | 사용자가 자유롭게 돌려야 |
**왼쪽 칸으로 되면 왼쪽이 정답이다.** 4-2에서 SVG 필터를 이미 썼다면 대부분 여기서 끝난다.
### 무거운 씬 임포트보다 셰이더 플레인 하나
| 방식 | 비용(gzip) | 언제 |
|---|---|---|
| **셰이더 플레인 1장** (§4) | **~95KB** | 기본값. 배경·재질·분위기 |
| GLTF 모델 + 환경광 | ~180KB + 모델 | 제품이 주인공일 때만 |
| Spline / 씬 임포트 | **800KB ~ 2MB** | 예산을 즉시 깬다. 사실상 금지 |
**"3D를 넣자"가 아니라 "이 인상에 필요한 최소 GPU 작업은 무엇인가"를 물어라.**
### 채택 조건 — 넷 다 만족해야 한다
1. CSS/SVG로 같은 인상이 안 나온다
2. 추가분이 **+200KB(gzip) 이내**다 (데모·포트폴리오 브리프면 +600KB, 단 올렸다고 명시)
3. **폴백을 같이 만든다.** 폴백 없이 채택하지 않는다
4. 2단계 한 문장 컨셉을 **강화**한다. "있으면 멋있어서"는 이유가 아니다
---
## 1. 버전 핀 — 지금 안전한 조합
```json
{ "three": "0.185.1", "@react-three/fiber": "^9.7.0", "@react-three/drei": "^10.7.8",
"@react-three/postprocessing": "^3.0.5", "postprocessing": "^6.39.4",
"overrides": { "three": "0.185.1" } }
```
| 경고 | 내용 |
|---|---|
| **three 0.186 금지** | `postprocessing`의 peer가 `three >=0.168.0 <0.186.0`. 올리면 포스트프로세싱이 깨진다 → **0.185.1에 핀** |
| **R3F v9 = React 19 전용** | peer `react >=19 <19.3`. 19.3+는 아직 지원 없음(v10 alpha) |
| **three 중복 설치 금지** | 두 벌이면 `instanceof` 검사가 전부 깨진다. `overrides`로 단일화 |
| **바닐라 우선** | 페이지가 React가 아니면 R3F를 쓰지 마라. React+R3F만 +75KB다 |
| **drei는 named import** | 배럴 임포트는 +100KB다 |
---
## 2. 오늘 사람 잡는 함정
| 함정 | 증상 | 고치는 법 |
|---|---|---|
| **sRGB 자동 지정 제거** [R3F v9] | 이미지가 어둡고 채도가 죽는다 | 컬러 텍스처에 `tex.colorSpace = THREE.SRGBColorSpace`. 노멀/러프니스맵엔 **지정 금지** |
| **`Clock` deprecated** [r183+] | 곧 제거됨 | `renderer.setAnimationLoop((t) => …)``t`(ms)를 쓴다 |
| **`RGBELoader` 개명** [r180+] | import 실패 | `import { HDRLoader } from 'three/addons/loaders/HDRLoader.js'` |
| **ACES 톤매핑 기본값** | 플랫 그라디언트가 뿌옇게 죽는다 | 2D 셰이더면 `toneMapping = THREE.NoToneMapping` (R3F는 `<Canvas flat>`) |
| **셰이더 색이 틀림** | hex를 `vec3`에 하드코딩 | 셰이더 안 `vec3`는 **linear 값**이다. 색은 JS에서 `THREE.Color`로 넘겨라 |
| **WebGL1 미지원** [r163+] | 구형 기기 백지 | `webgl2` 컨텍스트로 게이팅 (§3) |
| **drei `Environment preset`** | 외부 CDN에서 HDRI를 받는다 | `files="/hdri/studio_1k.hdr"` 자체 호스팅 |
---
## 3. 코드 A — 지연 로드 부트스트랩 (**이 문서에서 가장 중요**)
**언제**: three를 쓰기로 한 모든 경우. 예외 없다. three를 초기 번들에서 빼는 유일한 방법이다.
```js
// src/gl/boot.js — 계약: 모든 씬은 default export 로 mount(canvas, opts) => disposeFn 을 노출한다
export async function boot(canvas, loader) {
if (!canvas || !gate()) { canvas?.remove(); return null } // 폴백 배경이 그대로 남는다
await inViewport(canvas) // 진입 전엔 다운로드도 안 한다
const { default: mount } = await loader()
const css = getComputedStyle(document.documentElement)
const dispose = mount(canvas, { css })
const dur = css.getPropertyValue('--dur-normal').trim() || '350ms'
const ease = css.getPropertyValue('--ease-out').trim() || 'ease'
canvas.style.transition = `opacity ${dur} ${ease}`
requestAnimationFrame(() => { canvas.style.opacity = '1' })
addEventListener('pagehide', dispose, { once: true })
return dispose
}
function gate() { // WebGL2 + 사용자 선호 + 하드웨어를 한 번에 판정
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return false
if (navigator.connection?.saveData) return false
if ((navigator.deviceMemory ?? 8) < 4 || (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 }
}
const inViewport = (el) => new Promise((res) => {
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); res() } },
{ rootMargin: '200px' })
io.observe(el)
})
```
```js
// src/main.js — three 는 여기 어디에도 import 되지 않는다
import { boot } from './gl/boot.js'
boot(document.getElementById('gl'), () => import('./gl/scenes/hero.js'))
```
```html
<div class="hero__bg" aria-hidden="true"><canvas id="gl" style="opacity:0"></canvas></div>
```
```css
.hero__bg {
position: absolute; inset: 0; z-index: -1;
aspect-ratio: 16 / 9; /* CLS 차단 */
background: /* WebGL 실패 시 이것이 최종 결과물이다 */
radial-gradient(90% 70% at 20% 0%, var(--accent) 0%, transparent 60%),
linear-gradient(180deg, var(--surface-raised) 0%, var(--surface) 100%);
}
.hero__bg canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
@media (prefers-reduced-motion: reduce) { .hero__bg canvas { display: none; } }
```
**폴백 배경은 셰이더의 정지 프레임과 같은 인상이어야 한다.** 완성 후 `renderer.domElement.toDataURL('image/webp', .85)`로 한 프레임을 캡처해 대조해라.
### 공용 Stage — 모든 씬이 얹히는 최소 컨테이너
```js
// src/gl/stage.js
import * as THREE from 'three'
export function createStage(canvas, { dprMax = 1.5, alpha = false } = {}) {
const renderer = new THREE.WebGLRenderer({ canvas, alpha, antialias: false, stencil: false,
powerPreference: 'high-performance' })
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.NoToneMapping // 플랫 2D 셰이더 기준
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); camera.position.z = 5
const size = { w: 0, h: 0, dpr: 1 }, updaters = new Set()
let running = false, last = 0, t0 = performance.now()
const draw = (t, dt) => { for (const f of updaters) f(t, dt); renderer.render(scene, stage.camera) }
const tick = (now) => { const dt = Math.min((now - last) / 1000, 1 / 20); last = now; draw((now - t0) / 1000, dt) }
const start = () => { if (!running) { running = true; last = performance.now(); renderer.setAnimationLoop(tick) } }
const stop = () => { running = false; renderer.setAnimationLoop(null) }
function resize() {
const r = canvas.getBoundingClientRect()
const w = Math.max(1, Math.round(r.width)), h = Math.max(1, Math.round(r.height))
const dpr = Math.min(devicePixelRatio || 1, dprMax)
if (w === size.w && h === size.h && dpr === size.dpr) return
Object.assign(size, { w, h, dpr })
renderer.setPixelRatio(dpr); renderer.setSize(w, h, false)
stage.camera.aspect = w / h; stage.camera.updateProjectionMatrix()
stage.onResize?.(w, h, dpr)
if (!running) draw(0, 0)
}
const ro = new ResizeObserver(resize); ro.observe(canvas)
const io = new IntersectionObserver(([e]) => e.isIntersecting ? start() : stop(), { rootMargin: '10%' })
io.observe(canvas)
const onVis = () => document.hidden ? stop() : start()
document.addEventListener('visibilitychange', onVis)
const onLost = (e) => { e.preventDefault(); stop() } // 없으면 복구 이벤트가 안 온다
canvas.addEventListener('webglcontextlost', onLost)
const stage = { renderer, scene, camera, size, start, stop, renderOnce: () => draw(0, 0),
add(f) { updaters.add(f); return () => updaters.delete(f) },
setDprMax(v) { dprMax = v; size.dpr = -1; resize() },
dispose() {
stop(); ro.disconnect(); io.disconnect()
document.removeEventListener('visibilitychange', onVis)
canvas.removeEventListener('webglcontextlost', onLost)
scene.traverse((o) => {
o.geometry?.dispose()
for (const m of [o.material].flat().filter(Boolean)) {
for (const u of Object.values(m.uniforms ?? {})) if (u.value?.isTexture) u.value.dispose()
m.dispose()
}
})
scene.clear(); renderer.dispose(); renderer.forceContextLoss(); updaters.clear()
} }
resize()
return stage
}
```
---
## 4. 코드 B — 메시 그라디언트 셰이더 플레인
**언제**: 랜딩 히어로 배경의 기본 무기. three를 쓰기로 했다면 90%는 이것으로 끝난다. 드로우콜 1개, 텍스처 0장.
**색과 속도는 3단계 토큰에서 읽는다. 셰이더에 hex를 쓰지 마라** (하드 게이트 #1).
```js
// src/gl/scenes/hero.js
import * as THREE from 'three'
import { createStage } from '../stage.js'
import { resolveQuality, watchdog } from '../quality.js'
import frag from '../shaders/hero.frag'
const VERT = /* glsl */`void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }` // 이미 클립 공간
export default function mount(canvas, { css }) {
const q = resolveQuality()
const stage = createStage(canvas, { dprMax: q.dpr })
const color = (n) => { // 하드코딩 폴백을 두지 마라
const v = css.getPropertyValue(n).trim()
if (!v) throw new Error(`designpaca: 토큰 ${n} 이 없다. 3단계로 돌아가라`)
return new THREE.Color(v) // THREE.Color 는 linear 로 저장된다
}
const durSlow = parseFloat(css.getPropertyValue('--dur-slow')) || 600 // ms
const speed = 1000 / (durSlow * 20) // 셰이더도 같은 시간 문법: 1주기 = --dur-slow × 20
// 풀스크린 트라이앵글. 플레인(2 tri)보다 싸고 대각선 이음매가 없다
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position',
new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3))
const u = {
uTime: { value: 0 }, uSpeed: { value: speed },
uResolution: { value: new THREE.Vector2(1, 1) },
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
uSurface: { value: color('--surface') },
uRaised: { value: color('--surface-raised') },
uAccent: { value: color('--accent') },
}
const mesh = new THREE.Mesh(geometry, new THREE.ShaderMaterial({
vertexShader: VERT, fragmentShader: frag, uniforms: u,
defines: { OCTAVES: q.octaves }, depthTest: false, depthWrite: false,
}))
mesh.frustumCulled = false
stage.scene.add(mesh)
stage.onResize = (w, h, dpr) => u.uResolution.value.set(w * dpr, h * dpr)
stage.onResize(stage.size.w, stage.size.h, stage.size.dpr)
const target = new THREE.Vector2(0.5, 0.5)
const onMove = (e) => target.set(e.clientX / innerWidth, 1 - e.clientY / innerHeight)
addEventListener('pointermove', onMove, { passive: true })
const guard = watchdog(q, stage)
stage.add((t, dt) => {
u.uTime.value = t
u.uPointer.value.lerp(target, 1 - Math.pow(0.002, dt)) // 프레임레이트 독립 보간
guard(dt)
})
stage.start()
return () => { removeEventListener('pointermove', onMove); stage.dispose() }
}
```
```glsl
/* src/gl/shaders/hero.frag */
precision mediump float;
uniform float uTime, uSpeed;
uniform vec2 uResolution, uPointer;
uniform vec3 uSurface, uRaised, uAccent;
#ifndef OCTAVES
#define OCTAVES 3
#endif
float hash(vec2 p) { p = fract(p * vec2(233.34, 851.73)); p += dot(p, p + 23.45); return fract(p.x * p.y); }
float vnoise(vec2 p) {
vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x),
mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) {
float s = 0.0, a = 0.5;
for (int i = 0; i < OCTAVES; i++) { s += vnoise(p) * a; p *= 2.03; a *= 0.5; }
return s;
}
void main() {
/* 짧은 축 기준 정규화 — 안 하면 노이즈가 가로로 늘어진다 */
vec2 p = (gl_FragCoord.xy * 2.0 - uResolution) / min(uResolution.x, uResolution.y);
float t = uTime * uSpeed;
/* 도메인 워프: 노이즈로 좌표를 흔든 뒤 다시 노이즈 = "유기적"의 정체 */
float w = fbm(p * 1.1 + t);
float field = fbm(p * 1.7 + w * 0.9 + vec2(0.0, t * 1.3));
vec2 pc = (uPointer * 2.0 - 1.0) * vec2(uResolution.x / uResolution.y, 1.0);
field += exp(-dot(p - pc, p - pc) * 1.6) * 0.28; /* 포인터 근처를 부풀린다 */
vec3 col = mix(uSurface, uRaised, smoothstep(0.18, 0.62, field));
col = mix(col, uAccent, smoothstep(0.55, 0.95, field));
col *= 1.0 - dot(p, p) * 0.16; /* 비네트 */
col += (hash(gl_FragCoord.xy) - 0.5) / 255.0; /* 밴딩 제거. 사실상 필수 */
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
**조절**: `uSpeed`(0.03~0.15, 낮을수록 고급) · `OCTAVES`(2~4) · `p * 1.1`의 배율(작을수록 큰 덩어리) · 비네트 0.16.
`#include <…>``ShaderMaterial`에서만 동작한다. `RawShaderMaterial`은 컴파일 에러다.
---
## 5. 코드 C — 품질 티어 + 런타임 강등
**언제**: three를 쓰는 모든 씬. 한 번 판정해 씬 전체 설정을 결정한다. 컴포넌트마다 각자 판단하면 조합이 폭발한다.
```js
// src/gl/quality.js
const TIERS = {
low: { tier: 'low', dpr: 1, octaves: 2, particles: 4000, post: false, targetFps: 30 },
mid: { tier: 'mid', dpr: 1.5, octaves: 3, particles: 15000, post: 'minimal', targetFps: 60 },
high: { tier: 'high', dpr: 2, octaves: 4, particles: 40000, post: 'full', targetFps: 60 },
}
const ORDER = ['high', 'mid', 'low']
/** 동기 판정. detect-gpu(+12KB)는 GLTF 모델 씬에서만 추가로 쓴다 */
export function resolveQuality() {
const cached = sessionStorage.getItem('gl-tier')
if (cached && TIERS[cached]) return TIERS[cached]
const mobile = matchMedia('(max-width: 768px)').matches
const mem = navigator.deviceMemory ?? 8, cores = navigator.hardwareConcurrency ?? 8
const t = (mobile || mem < 6 || cores < 6) ? 'low'
: (mem >= 8 && cores >= 8) ? 'high' : 'mid'
sessionStorage.setItem('gl-tier', t)
return TIERS[t]
}
/** 4초 창에서 목표 fps의 75%를 30% 넘게 놓치면 한 단계 강등. low 에서도 실패하면 캔버스를 버린다 */
export function watchdog(quality, stage) {
let cur = quality, elapsed = 0, bad = 0, total = 0
return function update(dt) {
elapsed += dt; total++
if (1 / dt < cur.targetFps * 0.75) bad++
if (elapsed < 4) return
const failing = bad / total > 0.3
elapsed = 0; bad = 0; total = 0
if (!failing) return
const next = ORDER[ORDER.indexOf(cur.tier) + 1]
if (!next) { // 더 내릴 곳이 없다 → CSS 폴백만 남긴다
sessionStorage.setItem('gl-tier', 'low')
stage.renderer.domElement.remove(); stage.dispose(); return
}
cur = TIERS[next]
sessionStorage.setItem('gl-tier', next)
stage.setDprMax(cur.dpr) // 가장 효과가 큰 레버를 먼저 당긴다
}
}
```
---
## 6. 코드 D — DOM 동기화 이미지 hover 왜곡
**언제**: 이미지 그리드·포트폴리오. **폴백이 공짜인 유일한 패턴**이다 — 실패하면 원래 `<img>`가 그대로 남는다.
장식을 얹는 게 아니라 이미 있는 콘텐츠를 강화하므로 "레이아웃 → 재질 → 입체" 순서와도 맞는다.
캔버스는 `position: fixed; inset: 0; pointer-events: none;` 이어야 한다.
```js
// src/gl/scenes/gallery.js
import * as THREE from 'three'
import { createStage } from '../stage.js'
import { resolveQuality } from '../quality.js'
import frag from '../shaders/image.frag'
const VERT = /* glsl */`
uniform float uHover, uVelocity;
varying vec2 vUv;
void main() {
vUv = uv;
vec3 p = position;
p.y += sin(uv.x * 3.14159265) * uVelocity * 0.14; /* 스크롤 속도로 활처럼 휜다 */
p.z += sin(uv.y * 3.14159265) * uHover * 0.08;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`
export default function mount(canvas, { selector = 'img[data-gl]' } = {}) {
const q = resolveQuality()
const stage = createStage(canvas, { dprMax: q.dpr, alpha: true })
stage.camera = new THREE.PerspectiveCamera(45, 1, 100, 3000) // CSS 픽셀에 맞춘다
stage.camera.position.z = 800 // → 1 world unit = 1 px
const geometry = new THREE.PlaneGeometry(1, 1, 20, 20)
const loader = new THREE.TextureLoader()
const items = [], scroll = { cur: scrollY, vel: 0 }
for (const el of document.querySelectorAll(selector)) {
const u = { uTexture: { value: null }, uCover: { value: new THREE.Vector2(1, 1) },
uMouse: { value: new THREE.Vector2(.5, .5) },
uHover: { value: 0 }, uVelocity: { value: 0 }, uShift: { value: .006 } }
const mesh = new THREE.Mesh(geometry, new THREE.ShaderMaterial({
vertexShader: VERT, fragmentShader: frag, uniforms: u, transparent: true }))
mesh.visible = false
stage.scene.add(mesh)
const it = { el, mesh, u, hoverTarget: 0, box: null, ratio: 1 }
items.push(it)
loader.load(el.currentSrc || el.src, (tex) => {
tex.colorSpace = THREE.SRGBColorSpace // ← 빠뜨리면 이미지가 어두워진다
tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter
u.uTexture.value = tex
it.ratio = tex.image.width / tex.image.height
cover(it); mesh.visible = true
el.style.opacity = '0' // 텍스처 도착 후에만 DOM 이미지를 숨긴다
})
// 캔버스가 pointer-events:none 이므로 이벤트는 DOM 요소에서 받는다
el.addEventListener('pointerenter', () => { it.hoverTarget = 1 })
el.addEventListener('pointerleave', () => { it.hoverTarget = 0 })
el.addEventListener('pointermove', (e) => {
const r = el.getBoundingClientRect()
u.uMouse.value.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height)
})
}
const cover = (it) => { // object-fit: cover 를 JS 에서 계산 → 셰이더가 짧아진다
const pr = it.box ? it.box.w / it.box.h : 1
it.u.uCover.value.set(Math.min(pr / it.ratio, 1), Math.min(it.ratio / pr, 1))
}
const measure = () => { // 리플로우를 유발한다. 리사이즈 때만 부른다
for (const it of items) {
const r = it.el.getBoundingClientRect()
it.box = { top: r.top + scrollY, left: r.left + scrollX, w: r.width, h: r.height }
it.mesh.scale.set(r.width, r.height, 1); cover(it)
}
}
stage.onResize = (w, h) => {
stage.camera.fov = 2 * Math.atan(h / 2 / stage.camera.position.z) * (180 / Math.PI)
stage.camera.aspect = w / h; stage.camera.updateProjectionMatrix(); measure()
}
stage.onResize(stage.size.w, stage.size.h)
stage.add((t, dt) => {
// 스크롤 값은 rAF 에서 직접 읽는다. `scroll` 리스너는 하드 게이트 #10 위반이고,
// 어차피 매 프레임 필요한 값이다.
const k = 1 - Math.pow(.001, dt), hk = 1 - Math.pow(.0005, dt), prev = scroll.cur
scroll.cur += (scrollY - scroll.cur) * k
scroll.vel = THREE.MathUtils.clamp((scroll.cur - prev) / Math.max(dt, 1e-4) / 2500, -1, 1)
for (const it of items) {
if (!it.box) continue
it.mesh.position.x = it.box.left - stage.size.w / 2 + it.box.w / 2
it.mesh.position.y = -(it.box.top - scroll.cur) + stage.size.h / 2 - it.box.h / 2
it.u.uVelocity.value += (scroll.vel - it.u.uVelocity.value) * k
it.u.uHover.value += (it.hoverTarget - it.u.uHover.value) * hk
it.mesh.visible = !!it.u.uTexture.value &&
Math.abs(it.mesh.position.y) < stage.size.h / 2 + it.box.h
}
})
stage.start()
return () => {
items.forEach((it) => { it.el.style.opacity = '' })
geometry.dispose(); stage.dispose()
}
}
```
```glsl
/* src/gl/shaders/image.frag */
precision mediump float;
uniform sampler2D uTexture;
uniform vec2 uCover, uMouse;
uniform float uHover, uVelocity, uShift;
varying vec2 vUv;
void main() {
vec2 uv = vUv * uCover + (1.0 - uCover) * 0.5; /* cover */
uv = (uv - 0.5) * mix(1.0, 0.94, uHover) + 0.5; /* hover 줌 */
vec2 dir = normalize(vUv - uMouse + 1e-5);
uv += dir * exp(-distance(vUv, uMouse) * 4.0) * uHover * 0.016; /* 마우스 방향 밀림 */
float s = uShift * (uHover * 0.6 + abs(uVelocity) * 1.4); /* 색수차 */
vec3 col = vec3(texture2D(uTexture, uv + vec2(s, s * 0.35)).r,
texture2D(uTexture, uv).g,
texture2D(uTexture, uv - vec2(s, s * 0.35)).b);
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
**원본 이미지를 그대로 쓰면 VRAM이 터진다.** 1920×1080 RGBA = 8.3MB/장. 표시 크기 × 2까지만 서버에서 리사이즈해라.
---
## 7. 패턴 카탈로그 — 코드는 `research/three/02-patterns.md`에
| 효과 | 언제 | 비용 | 문서 |
|---|---|---|---|
| SDF 도트 그리드 + 마우스 트레일 | 텍스트가 위에 올라가는 배경. 가독성을 안 해친다 | +0KB · 낮음 | P2 |
| 파티클 필드 (Points) | 먼지·별 레이어. 5만 개 이하 | +0KB · 중간 | P3 |
| GPGPU 플로우필드 파티클 | 10만 개 이상, 유체 같은 흐름 | +0KB · 높음 | P4 |
| InstancedMesh 그리드 웨이브 | 입체 격자 인터랙션. 드로우콜 1 | +0KB · 중간 | P5 |
| 스크롤 카메라 리그 (Lenis+ScrollTrigger) | 스크롤텔링. WebGL이 페이지를 지배할 때 | **+45KB** · 낮음 | P6 |
| drei ScrollControls | R3F에서 페이지 전체가 캔버스일 때 | +0KB · 낮음 | P7 |
| 이미지 전환 (displacement) | 슬라이더·캐러셀 | +0KB · 낮음 | P9 |
| 유리·굴절 (MeshTransmissionMaterial) | 중심 오브제. **가장 비싸다** | +0KB · **매우높음** | P10 |
| 3D 텍스트 (troika SDF) | WebGL 안의 대형 타이포 | **+55KB** · 낮음 | P11 |
| 포스트프로세싱 (bloom/CA/grain) | 발광·필름 룩 | **+32KB** · 중간~높음 | P12 |
| GLTF 제품 씬 (DRACO/KTX2) | 제품이 주인공 | **+15KB** + 모델 · 중간 | P13 |
| 노이즈 디졸브 리빌 | 등장·전환 | +0KB · 낮음 | P15 |
**+45KB 이상 항목은 예산 계산에 반드시 넣어라.** 스크롤 리그(GSAP+Lenis)는 **한 줄도 안 썼는데 +200KB 예산의 22%를 먹고 시작한다.** 여기에 troika(+55KB)와 포스트프로세싱(+32KB)을 얹으면 +132KB — 예산의 66%가 라이브러리로만 사라지고 실제 씬에 쓸 몫은 68KB뿐이다.
---
## 8. 성능 — 효과 순서대로
1. **DPR 클램프가 1위다.** `min(devicePixelRatio, 2)` — 3→2면 픽셀 **55% 감소**, 육안 차이 없음. 배경 셰이더는 1.25~1.5까지 내려도 된다
2. **안 보일 때 안 그린다.** `visibilitychange` + `IntersectionObserver`. 정적 씬은 온디맨드(R3F `frameloop="demand"`). 배터리 80% 절약
3. **VRAM이 모바일의 진짜 한계다.** 4K 텍스처 = 67MB/장. **WebP는 다운로드만 줄이고 VRAM은 그대로**다. 줄이는 유일한 수단은 KTX2(ETC1S 1/8). 컨텍스트 로스의 최대 원인도 VRAM 고갈
4. **드로우콜 예산**: 데스크톱 150 / 모바일 60. `renderer.info.render.calls`로 측정. 넘으면 `InstancedMesh`
5. **`MeshTransmissionMaterial`은 매 프레임 씬을 재렌더한다.** `resolution`을 기본(풀스크린)으로 두지 마라 — 512 이하, 모바일은 `samples={2} resolution={256}`. 2개 이상이면 `transmissionSampler` 공유
6. **투명 파티클은 오버드로우가 병목**이다. 개수보다 `gl_PointSize`를 먼저 줄여라
7. **셰이더는 `mediump` 기본.** 모바일에서 ~2배 빠르다. 좌표·GPGPU만 `highp`
8. **실시간 그림자는 끈다.** `ContactShadows frames={1}` 또는 baked AO로 대체
**병목 격리**: 캔버스를 200×200으로 줄여 회복되면 프래그먼트 병목, 그대로면 드로우콜·CPU 병목이다.
---
## 9. Lighthouse — 캔버스를 LCP 요소로 만들지 마라
WebGL 랜딩페이지가 42점에서 98점으로 간 실측 사례의 핵심은 이 한 줄이다.
| 규칙 | 방법 |
|---|---|
| **LCP는 DOM 텍스트가 잡는다** | 히어로 `<h1>`이 LCP가 되게 하고 캔버스는 `z-index:-1` 배경 레이어로 |
| **CLS 차단** | 캔버스 컨테이너에 `aspect-ratio` 또는 `min-height`를 미리 준다 |
| **three는 LCP 이후에 요청된다** | 코드 A의 동적 import + IntersectionObserver. Network 탭에서 순서를 확인해라 |
| **셰이더 컴파일 분산** | 머티리얼 10개를 한 프레임에 처음 그리면 100ms 롱태스크다. `renderer.compileAsync(scene, camera)` [r170+] |
| **디코더 preload 금지** | DRACO·basis WASM(각 200~250KB)은 실제로 필요할 때만 받게 한다 |
---
## 10. 폴백과 접근성
| 상황 | 대체 |
|---|---|
| WebGL2 미지원 · 컨텍스트 생성 실패 | 캔버스 제거 → CSS 그라디언트 폴백 (코드 A) |
| 저사양(메모리<4GB, 코어<4) · `saveData` | 아예 로드하지 않는다 |
| **`prefers-reduced-motion: reduce`** | 캔버스 `display:none` + CSS 폴백. 굳이 띄워야 하면 `renderOnce()` 장만 |
| 런타임 프레임 저하 | 티어 강등 최종적으로 캔버스 제거 (코드 C) |
| 배터리 부족 · 3분 이상 실행 | DPR을 1로 내린다 (써멀 스로틀링 방지) |
| 컨텍스트 로스 | `e.preventDefault()` 복구 신호 정지. **three는 리소스를 자동 재생성하지 않는다** 씬을 통째로 다시 만들어라 |
- 장식 캔버스는 `aria-hidden="true"` + `pointer-events: none`
- **WebGL 안의 텍스트는 검색·선택·스크린리더가 된다.** 히어로 문구는 반드시 DOM에 둔다
- 의미 있는 3D 뷰는 `role="img"` + `aria-label` + 정적 이미지 대안
- 캔버스 텍스트는 뒤에 반투명 레이어를 깔아 대비 4.5:1을 보장한다
- 캔버스를 여러 만들지 마라. 컨텍스트 상한은 8~16개다 (drei `<View>` 또는 전환)
---
## 11. 채택 체크리스트
5단계 프리플라이트에서 그대로 돌린다. **하나라도 실패하면 채택 취소이거나 4-3 복귀다.**
- [ ] §0 채택 조건 4개를 통과했다 (CSS/SVG로 되고, 예산 내이고, 폴백이 있고, 컨셉을 강화한다)
- [ ] WebGL 추가분을 **실측**했다(build gzip). 예산 내이거나, 올린 이유를 명시했다
- [ ] `three` **초기 번들에 없다.** Network 탭에서 LCP 이후 요청됨을 확인했다
- [ ] 셰이더의 ·시간이 3단계 토큰(`--accent`, `--dur-*`)에서 파생됐다. hex 하드코딩이 없다
- [ ] 컬러 텍스처에 `colorSpace = SRGBColorSpace` 지정했다
- [ ] DPR을 클램프했다. `IntersectionObserver` + `visibilitychange` 렌더를 멈춘다
- [ ] `renderer.info.render.calls` 150(데스크톱) / 60(모바일)
- [ ] 캔버스를 `display:none`으로 껐을 페이지가 완성돼 있다 (프리플라이트 0-A와 동일)
- [ ] `prefers-reduced-motion`에서 애니메이션이 멈추고 폴백이 보인다
- [ ] 캔버스에 `aria-hidden="true"`, 히어로 텍스트는 DOM에 있다
- [ ] 컨텍스트 로스를 강제(`WEBGL_lose_context.loseContext()`)했을 크래시 없이 폴백이 뜬다
- [ ] 라우트 이탈 `dispose()` 호출된다. 3분 실행 `renderer.info.memory` 증가하지 않는다
- [ ] 실기기(중저가 안드로이드)에서 30fps 이상이다
- [ ] 6단계 `design.md` **채택한 이펙트 · 실측 추가분(KB) · 폴백** 적었다
하드 게이트 셋이 여기서 자주 걸린다: **#1**(셰이더 hex 하드코딩) · **#8**(`transform`/`opacity` 애니메이션 캔버스 페이드는 `opacity`) · **#10**(`scroll` 리스너 rAF에서 `scrollY` 읽어라).
> 근거: research/three/01~04 (조사일 2026-08-20)