designpaca/packages/skill/references/three.md
Yun Chan 9648d1d82a 소개 페이지를 스킬 전체 경로로 다시 만들고, 배운 것을 스킬에 반영
방향을 글래스로 바꿀 때 2단계 재결정을 하고도 전체 경로로 올리지 않았다.
레퍼런스 없이 재질만 갈아끼운 결과가 스위스 미니멀 뼈대에 유리를 바른
페이지였고, design.md 는 초판 결정을 그대로 담고 있었다. 0단계부터 다시 돌았다.

1단계 — 레퍼런스 3개를 computed style 까지 실측
  R1 zed.dev      컨테이너 1120px 과 전체폭이 교대 · radius 2px 621회
  R2 lusion.co    3D 는 액자 안, 텍스트는 액자 밖 · 배경 흰색 · h1 36px
  R3 Codrops      DOM 이 구조를 소유하고 WebGL 은 uniform 만 만진다

2단계 — 프리셋 swiss-minimal → dark-instrument, 컨셉 "검사대 위의 물체"
        유리는 액자에만 남긴다(헤더 · 검사대 · 명령창)

4단계 — 히어로 재구성. 캔버스를 배경에서 빼 액자 안으로, 텍스트는 밖으로
        판독기가 통과한 게이트를 표시한다(값이 바뀔 때만 DOM 을 쓴다)
        파이프라인을 2열로 열어 625px → 1113px

5단계에서 실제로 걸린 것
  게이트 3  radius 4종 → 2종 (3px / 14px, pill 제거)
  게이트 1  base.css 토큰 밖 rgb() 7곳 → 토큰
  게이트 7  헤더 English 2.59:1 → ink 로 상향
  카운트    히어로 h1 4줄 → 2줄
  셰이더    판이 정면이라 프레넬이 구조적으로 0. 법선을 굽히고 UV 로 테두리
  셰이더    gl_FragCoord 를 렌더 타겟 크기로 나눠 UV 가 1 을 넘고 있었다
  수치      페이지가 실은 "타입 단계 5단계"가 실측 9종이었다. lang 속성이
            스케일을 재정의해 링크 하나가 16px 로 새고 있었다
  측정      대비 스크립트가 반투명 배경을 불투명으로 계산했다. 알파 합성으로 교정

스킬 반영
  three.md    §0-A 배치 원리(3D 는 액자 안) · 프레넬 0 함정 · gl_FragCoord 함정
  preflight.md 대비 측정 시 알파 합성. 검사 도구가 틀리면 통과도 의미가 없다

최종: 하드 게이트 12/12, 대비 실패 0, 320px 오버플로 0, 테스트 22개 통과
2026-08-20 13:33:28 +09:00

633 lines
32 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-A. 배치가 먼저다 — 3D 는 액자 안, 텍스트는 액자 밖
채택을 결정했다면 **어디에 놓을지부터 정해라.** 이 결정이 씬의 품질보다 결과를 크게 바꾼다.
> **기본값: 캔버스를 배경 전면에 깔고 그 위에 본문을 얹지 마라.**
겹치면 두 가지가 동시에 일어난다. 3D 는 텍스트 가독성을 위해 흐리고 어둡게 눌려 얼룩이 되고,
텍스트는 대비를 확보하려고 글로우·스크림·그림자를 달게 된다. **둘 다 소리치다 둘 다 죽는다.**
실측 근거 — `lusion.co`(3D 프로덕션 스튜디오, 2026-08 측정):
| 관측 | 값 |
|---|---|
| 캔버스 배치 | `border-radius: 15px` 액자 **안**. 페이지 배경과 명확히 분리 |
| 페이지 배경 | **흰색**. 텍스트 색 **2단계**(검정 135회 / 흰 16회) |
| 폰트 패밀리 | **1개** |
| 강조색 | 캔버스 **안**에서만. DOM 에는 순청 4회뿐 |
| h1 크기 | **36px** |
3D 를 가장 잘 쓰는 곳이 타이포를 가장 조용하게 쓴다. **힘이 글자가 아니라 물체에서 나오기 때문에**
h1 이 36px 이어도 히어로가 압도적이다.
겹쳐야만 하는 경우(HAOQI.DESIGN 패턴)는 **DOM 이 구조를 소유하고 WebGL 은 셰이더 uniform 만 만지게** 한다.
기본 상태는 DOM 이 그리고, 상호작용 상태에서만 WebGL 이 나타난다. §6 이 그 구현이다.
캔버스 위에 글자를 올려야 한다면 **하나만** 올려라. 그리고 거기엔 바탕을 깔아라.
---
## 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"` 자체 호스팅 |
| **정면으로 선 평면의 프레넬** | 유리판이 "흐린 사각형"으로만 보이고 재질이 안 읽힌다 | 평면이 카메라를 정면으로 보면 `dot(normal, view) ≈ 1` 이라 **프레넬이 구조적으로 0**이다. 가장자리에서도 법선이 같아 변하지 않는다. 법선을 UV 로 미세하게 굽히고(`normalize(n + vec3((vUv-0.5)*0.9, 0.0))`), 두께는 **UV 경계**에서 따로 만들어라 |
| **`gl_FragCoord` 를 렌더 타겟 크기로 나눔** | 굴절 텍스처가 잘리거나 늘어난다 | `gl_FragCoord`**드로잉버퍼** 좌표다. 렌더 타겟을 0.6배로 줄여 썼다면 그 크기로 나누면 UV 가 1 을 넘는다. `renderer.getDrawingBufferSize()` 를 uniform 으로 넘겨라 |
**실측 사례**: 위 두 함정을 designpaca 소개 페이지가 동시에 밟았다. 판 여섯 장을 세워놓고
"그냥 흐릿한 사각형"이라는 평가를 받았는데, 원인은 배치도 색도 아니고 **유리로 읽히게 하는 신호가
계산되지 않고 있던 것**이었다. 셰이더는 에러를 내지 않는다 — 조용히 아무것도 안 한다.
---
## 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)