designpaca/packages/skill/references/three.md
Yun Chan 9c9ab2de7e 디자인 SSOT 를 세우고 3D 를 배경 재질로 되돌린다
지적받은 것: 모바일 마진·패딩, 폰트 웨이트, 버튼 스타일 불일치, SSOT,
스크롤바, 모바일 메뉴, 그리고 3D 를 왜 상자에 넣었는지.

3D 의 역할을 잘못 잡았다
  lusion.co 의 "3D 는 액자 안"은 3D 자체가 상품일 때의 규칙이다.
  우리는 절차를 파는데 액자에 넣을 상품이 없어 "저 상자는 뭐냐"가 됐고,
  의미를 게이트 판독기가 자막으로 설명하고 있었다.
  이 씬의 역할은 재질이다. 배경으로 되돌리고 값을 전부 낮췄다 —
  테두리 광 0.85->0.42, 알파 0.72->0.34, UV 경계 0.05->0.17, 이동 60%
  ResizeObserver 로 캔버스를 따라가게 했다. window.resize 만 듣는 동안
  히어로 높이 변화를 놓쳐 배경이 화면의 3분의 2만 덮었다

SSOT
  웨이트 6종(400/500/560/600/620/660) -> 3종. 셋은 토큰에 없던 값이었다
  .btn + btn-solid/btn-quiet. 두 버튼이 높이 36 vs 44, 패딩 16 vs 8,
    테두리 1px vs 0, 웨이트 400 vs 500 이었다
  --header-h. 간격을 clamp 로 바꾸자 관계가 끊겨 h1 이 헤더 뒤로 들어갔다
  --space-5~8 과 --gutter 를 clamp 로. 고정이면 모바일 여백이 화면의 26%
  scrollbar-color 지정

모바일
  좌우 여백 24 -> 32px, 섹션 상하 112 -> 56px
  헤더에 섹션 앵커 4개. 900px 미만은 details 기반 메뉴(JS 0바이트)

패럴랙스는 섹션마다 다른 말을 한다
  히어로   글자 상승 / 배경 하강 — 반대 방향이라야 시차가 보인다
  파이프라인 단계명 고정, 산출물만 지연 — 인과
  재료     타일이 서로 다른 깊이 — 표면은 한 겹이 아니다
  프리플라이트 뒤 항목일수록 늦게 — 로그는 흐른다
  수치     없음. 의도적 정지 — 계기판의 바늘은 떨지 않는다

스킬
  three.md  §0-A 를 역할별로 갈랐다. "액자 안"만 적어둔 것이 잘못된
            일반화였고 내가 거기 그대로 걸렸다. 재질일 때의 값 교정을 실측으로 남김
  tokens.md 웨이트·컨트롤·헤더 높이·반응형 간격 — 토큰이 아예 없던 네 축
  motion.md 섹션별 패럴랙스 배정표. 움직이지 않는 것도 결정이다

390px 실렌더: 가로 스크롤 0, 대비 실패 0, 웨이트 3종, radius 2종. 테스트 22개 통과
2026-08-20 14:15:30 +09:00

661 lines
34 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 스튜디오 |
| **재질** | 배경 전면. 텍스트가 그 위에 앉는다 | 공간감이 목적일 때 — 랜딩, 브랜드 사이트 |
**둘을 섞지 마라.** 재질로 쓸 씬을 액자에 가두면 "저 상자는 뭐냐"는 질문을 받는다.
액자는 안에 든 것이 볼 가치가 있다고 선언하는 장치라서, 그 안에 배경 무늬가 들어 있으면
선언과 내용이 어긋난다. 실측에서 그렇게 만들었다가 되돌렸다.
### 주인공일 때 — 액자 안
> **텍스트를 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.85 → 0.42`, 알파 `0.72 → 0.34`
- **경계를 푼다.** 평면의 UV 테두리를 좁게 잡으면 사각형 프레임이 또렷하게 서서
추상 패턴이 아니라 '상자'로 읽힌다. `smoothstep(0.0, 0.05, ...)``0.17` 로 넓히고 제곱해 번지게 한다
- **느리게 움직인다.** 스크롤 전 구간을 다 통과시키면 시선을 끌어 글자와 싸운다. 이동량을 60% 로 줄였다
- **글자 자리에 스크림을 깐다.** 배경이 균일하게 밝으면 본문 대비가 무너진다.
전면을 덮지 말고 **글자가 앉는 쪽만** 눌러라 — 반대쪽을 열어둬야 배경이 살아 있다
- **아래를 마스크로 지운다.** 섹션 경계에서 배경이 가로선으로 끊기면 두 장의 종이처럼 보인다.
`mask-image: linear-gradient(to bottom, black 62%, transparent 96%)`
- **캔버스 크기는 `ResizeObserver` 로 따라간다.** `window.resize` 만 들으면 폰트가 늦게 오거나
콘텐츠가 접힐 때 캔버스가 옛 크기로 남아 배경이 화면 일부만 덮는다. 그건 resize 이벤트가 아니다
---
## 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)