# 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는 ``) | | **셰이더 색이 틀림** | 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 ``` ```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 #include } ``` **조절**: `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 왜곡 **언제**: 이미지 그리드·포트폴리오. **폴백이 공짜인 유일한 패턴**이다 — 실패하면 원래 ``가 그대로 남는다. 장식을 얹는 게 아니라 이미 있는 콘텐츠를 강화하므로 "레이아웃 → 재질 → 입체" 순서와도 맞는다. 캔버스는 `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 #include } ``` **원본 이미지를 그대로 쓰면 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 텍스트가 잡는다** | 히어로 `

`이 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 `` 또는 씬 전환) --- ## 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)