# 01 — 스택 선택과 셋업

> 기준 시점: **2026-08-20**. 이 문서의 모든 코드는 아래 "버전 스냅샷"에서 검증한 API를 기준으로 작성했다.
> 버전 의존적인 API에는 `[r1XX+]` 표기를 붙였다.

---

## 0. 버전 스냅샷

| 패키지 | 버전 | 비고 |
|---|---|---|
| `three` | **0.185.1** | r185. r163부터 WebGL1 미지원(WebGL2 전용) |
| `@react-three/fiber` | **9.7.0** | peer: `react >=19 <19.3`, `three >=0.156` |
| `@react-three/drei` | **10.7.8** | peer: `react ^19`, `three >=0.159`, `@react-three/fiber ^9` |
| `@react-three/postprocessing` | **3.0.5** | peer: `three >= 0.182`, `postprocessing ^6.36`, `fiber >=9.7` |
| `postprocessing` | **6.39.4** | peer: `three >= 0.168.0 < 0.186.0` ← **상한 존재. three 0.186부터 깨짐** |
| `gsap` | **3.15.0** | 3.13(2025-04)부터 ScrollTrigger/SplitText/MorphSVG 포함 **전 플러그인 무료** |
| `lenis` | **1.3.26** | 스무스 스크롤. `respectReducedMotion` 기본 `true` |
| `troika-three-text` | **0.52.5** | peer: `three >=0.125`. SDF 텍스트 |
| `detect-gpu` | 5.x | GPU tier 0~3 판정 |
| `vite-plugin-glsl` | 1.6.x | `.glsl/.vert/.frag` import + `#include` |

### 🔴 버전 조합 함정 (가장 흔한 사고)

1. **`postprocessing`의 three 상한**: `>= 0.168.0 < 0.186.0`. `three@0.186`으로 올리면 포스트프로세싱이 깨진다. 포스트프로세싱을 쓸 거면 **three를 0.185.x에 고정**한다.
2. **R3F v9는 React 19 전용**이고 `react >=19 <19.3`. React 19.2.x에서 내부 reconciler가 비호환 변경돼서 R3F가 reconciler를 번들에 포함시켰다. React 19.3+에서는 R3F v10을 기다려야 한다.
3. **package.json에 three를 한 번만**: `three`가 중복 설치되면 `instanceof` 검사가 전부 깨진다. 모노레포/pnpm에서는 `resolutions`/`overrides`로 단일화한다.

```json
// package.json — 안전한 핀
{
  "dependencies": {
    "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" },
  "pnpm": { "overrides": { "three": "0.185.1" } }
}
```

---

## 1. Vanilla three.js vs React Three Fiber — 결정 기준

### 한 줄 요약
**페이지가 React가 아니면 vanilla. React면 R3F.** 그 외 판단은 아래 표.

| 상황 | 권장 | 이유 |
|---|---|---|
| 정적 랜딩페이지(HTML/Astro/Webflow 등), 히어로 배경 셰이더 1개 | **Vanilla** | React 런타임(~45KB gz) 불필요. three만 15~90KB gz |
| 스크롤 전체를 지배하는 WebGL(카메라 이동, 섹션 전환) | **Vanilla + GSAP/Lenis** | 명령형 타임라인이 스크롤 연출과 궁합이 좋음 |
| DOM 이미지 다수를 WebGL 플레인으로 치환 | 둘 다 가능 (Vanilla 약간 유리) | DOM ↔ WebGL 좌표 동기화는 어차피 수동 |
| Next.js/React 앱 내부의 3D 섹션, 제품 컨피규레이터 | **R3F** | 상태-씬 바인딩, Suspense 로딩, drei 재사용 |
| 3D 모델 + 환경광 + 그림자 + 포스트프로세싱 조합 | **R3F + drei** | `<Environment>`, `<ContactShadows>`, `<EffectComposer>` 로 하루치 작업이 10줄 |
| 팀에 React 개발자만 있음 | **R3F** | 유지보수 비용이 성능 손해보다 큼 |
| 극한 성능(모바일 1st paint < 1.5s, 번들 < 150KB) | **Vanilla** | 트리셰이킹 통제권이 완전함 |

### 비용 비교 (gzip, 실측 근사)

| 구성 | 크기 |
|---|---|
| three 코어만 (`WebGLRenderer` + `Scene` + `PlaneGeometry` + `ShaderMaterial`) | **~90 KB** (실제 트리셰이킹 후) |
| three 전체 번들 import | ~170 KB |
| + `@react-three/fiber` | +30 KB |
| + `react` + `react-dom` | +45 KB |
| + `@react-three/drei` (전체 import 시) | +100 KB 이상 → **반드시 named import** |
| + `postprocessing` (Bloom만) | +25 KB |
| + `gsap` core + ScrollTrigger | +40 KB |
| + `lenis` | +5 KB |

> **현실 체크**: three는 트리셰이킹이 완전하지 않다. `WebGLRenderer` 하나가 셰이더 청크 전체를 끌고 온다.
> "three 쓰면 최소 90KB gz"를 예산의 바닥으로 잡아라. 이보다 작게 만들려면 three가 아니라 raw WebGL/OGL을 써야 한다.

### 혼합 전략 (추천 기본값)

랜딩페이지에서 가장 안전한 구조는 **"WebGL은 지연 로드되는 장식 레이어"**다.

```
DOM(SSR/정적) = 콘텐츠·SEO·LCP 담당
  └ <canvas>  = 장식. dynamic import, IntersectionObserver로 첫 진입 시 로드
```

이렇게 하면 JS 번들이 초기 경로에서 빠지고, WebGL 실패/저사양/`prefers-reduced-motion`에서 DOM만 남아도 페이지가 성립한다.

---

## 2. 설치

### Track A — Vanilla + Vite

```bash
npm create vite@latest my-site -- --template vanilla
cd my-site
npm i three@0.185.1
npm i -D vite-plugin-glsl
# 선택
npm i gsap lenis
npm i troika-three-text
npm i postprocessing@^6.39.4
npm i detect-gpu
```

### Track B — R3F + Vite (React 19)

```bash
npm create vite@latest my-site -- --template react-ts
cd my-site
npm i three@0.185.1 @react-three/fiber@^9.7.0 @react-three/drei@^10.7.8
npm i -D @types/three vite-plugin-glsl
# 선택
npm i @react-three/postprocessing@^3.0.5 postprocessing@^6.39.4
npm i gsap lenis maath
npm i -D r3f-perf leva
```

### Track C — Next.js (App Router) + R3F

```bash
npx create-next-app@latest my-site --typescript
cd my-site
npm i three@0.185.1 @react-three/fiber@^9.7.0 @react-three/drei@^10.7.8
npm i -D @types/three
```

Next.js에서는 Canvas를 **반드시 클라이언트 전용 동적 임포트**로 감싼다.

```tsx
// components/SceneClient.tsx
'use client'
import dynamic from 'next/dynamic'

const Scene = dynamic(() => import('./Scene'), {
  ssr: false,
  loading: () => <div className="scene-skeleton" aria-hidden="true" />,
})

export default Scene
```

---

## 3. Vite 설정

```js
// vite.config.js
import { defineConfig } from 'vite'
import glsl from 'vite-plugin-glsl'

export default defineConfig({
  plugins: [
    glsl({
      include: ['**/*.glsl', '**/*.wgsl', '**/*.vert', '**/*.frag', '**/*.vs', '**/*.fs'],
      warnDuplicatedImports: true,
      defaultExtension: 'glsl',
      compress: false,       // 프로덕션에서 true로
      watch: true,
    }),
  ],
  build: {
    target: 'es2022',
    rollupOptions: {
      output: {
        manualChunks(id) {
          // three를 별도 청크로 → 앱 코드 변경 시 캐시 유지
          if (id.includes('node_modules/three')) return 'three'
          if (id.includes('node_modules/gsap')) return 'gsap'
        },
      },
    },
  },
})
```

TypeScript를 쓴다면:

```json
// tsconfig.json
{ "compilerOptions": { "types": ["vite/client", "vite-plugin-glsl/ext"] } }
```

`vite-plugin-glsl`은 three 내장 청크(`#include <common>` 등)를 자동으로 건너뛴다. 내 셰이더 파일끼리의 `#include './noise.glsl'`만 인라인한다.

---

## 4. 최소 보일러플레이트 — Vanilla (완성 코드)

### 4.1 `index.html`

```html
<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>WebGL Hero</title>
  <style>
    :root { color-scheme: dark; }
    * { box-sizing: border-box; }
    body { margin: 0; background: #07070a; color: #f4f4f5; font-family: system-ui, sans-serif; }

    .hero { position: relative; min-height: 100svh; display: grid; place-items: center; isolation: isolate; }

    /* 캔버스는 장식. 접근성 트리에서 제외하고 포인터 이벤트도 막는다 */
    .hero__canvas {
      position: absolute; inset: 0; width: 100%; height: 100%;
      z-index: -1; display: block; pointer-events: none;
      /* WebGL 실패/미지원 시 그대로 보이는 폴백 배경 */
      background: radial-gradient(120% 90% at 50% 0%, #2a1f5c 0%, #0b0b16 55%, #07070a 100%);
    }
    .hero__inner { text-align: center; max-width: 42rem; padding: 2rem; }
    h1 { font-size: clamp(2.5rem, 8vw, 6rem); line-height: 0.95; margin: 0 0 1rem; letter-spacing: -0.04em; }
  </style>
</head>
<body>
  <section class="hero">
    <canvas class="hero__canvas" id="gl" aria-hidden="true"></canvas>
    <div class="hero__inner">
      <h1>Designpaca</h1>
      <p>WebGL은 장식이고, 콘텐츠는 DOM에 있다.</p>
    </div>
  </section>

  <script type="module" src="/src/main.js"></script>
</body>
</html>
```

### 4.2 `src/gl/Stage.js` — 재사용 가능한 렌더 루프 컨테이너

이 클래스가 **DPR 클램프 / 리사이즈 / 가시성 일시정지 / reduced-motion / 컨텍스트 로스 / dispose**를 전부 처리한다.
모든 씬은 이 위에 얹는다.

```js
// src/gl/Stage.js
import * as THREE from 'three'

export default class Stage {
  /**
   * @param {object} opts
   * @param {HTMLCanvasElement} opts.canvas
   * @param {number}  [opts.dprMax=2]        디바이스 픽셀비 상한
   * @param {boolean} [opts.antialias=false] DPR>=2면 끄는 게 이득
   * @param {boolean} [opts.alpha=false]     투명 배경 필요할 때만 true
   * @param {number}  [opts.fov=45]
   * @param {boolean} [opts.pauseOffscreen=true]
   */
  constructor({
    canvas,
    dprMax = 2,
    antialias = false,
    alpha = false,
    fov = 45,
    pauseOffscreen = true,
  }) {
    this.canvas = canvas
    this.dprMax = dprMax
    this.pauseOffscreen = pauseOffscreen

    this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches

    this.renderer = new THREE.WebGLRenderer({
      canvas,
      antialias,
      alpha,
      powerPreference: 'high-performance',
      stencil: false,
      depth: true,
    })
    // r152+ 기본값이지만 명시해두면 라이브러리 간섭에 안전하다
    this.renderer.outputColorSpace = THREE.SRGBColorSpace
    this.renderer.toneMapping = THREE.NoToneMapping // 플랫 그라디언트/2D 셰이더면 None이 정확
    this.renderer.setClearColor(0x000000, alpha ? 0 : 1)

    this.scene = new THREE.Scene()
    this.camera = new THREE.PerspectiveCamera(fov, 1, 0.1, 100)
    this.camera.position.set(0, 0, 5)

    this.clockStart = performance.now()
    this.time = 0
    this.delta = 0
    this._last = 0
    this._running = false
    this._updaters = new Set()

    this.size = { w: 0, h: 0, dpr: 1 }

    this._onResize = this._onResize.bind(this)
    this._onVisibility = this._onVisibility.bind(this)
    this._onContextLost = this._onContextLost.bind(this)
    this._onContextRestored = this._onContextRestored.bind(this)
    this._tick = this._tick.bind(this)

    this._resizeObserver = new ResizeObserver(this._onResize)
    this._resizeObserver.observe(canvas)
    document.addEventListener('visibilitychange', this._onVisibility)
    canvas.addEventListener('webglcontextlost', this._onContextLost, false)
    canvas.addEventListener('webglcontextrestored', this._onContextRestored, false)

    if (pauseOffscreen) {
      this._io = new IntersectionObserver(
        ([entry]) => (entry.isIntersecting ? this.start() : this.stop()),
        { rootMargin: '10%' }
      )
      this._io.observe(canvas)
    }

    this._onResize()
  }

  /** 매 프레임 호출될 콜백 등록. 반환된 함수를 호출하면 해제 */
  add(fn) {
    this._updaters.add(fn)
    return () => this._updaters.delete(fn)
  }

  start() {
    if (this._running) return
    this._running = true
    this._last = performance.now()
    this.renderer.setAnimationLoop(this._tick)
  }

  stop() {
    if (!this._running) return
    this._running = false
    this.renderer.setAnimationLoop(null)
  }

  /** 한 프레임만 그린다 (정지 상태에서 리사이즈/reduced-motion 대응) */
  renderOnce() {
    for (const fn of this._updaters) fn(this.time, 0)
    this.renderer.render(this.scene, this.camera)
  }

  _tick(now) {
    this.delta = Math.min((now - this._last) / 1000, 1 / 20) // 스파이크 클램프
    this._last = now
    this.time = (now - this.clockStart) / 1000

    for (const fn of this._updaters) fn(this.time, this.delta)
    this.renderer.render(this.scene, this.camera)
  }

  _onResize() {
    const rect = this.canvas.getBoundingClientRect()
    const w = Math.max(1, Math.round(rect.width))
    const h = Math.max(1, Math.round(rect.height))
    const dpr = Math.min(window.devicePixelRatio || 1, this.dprMax)
    if (w === this.size.w && h === this.size.h && dpr === this.size.dpr) return

    this.size = { w, h, dpr }
    this.renderer.setPixelRatio(dpr)
    this.renderer.setSize(w, h, false) // false = 캔버스 CSS 크기는 건드리지 않음
    this.camera.aspect = w / h
    this.camera.updateProjectionMatrix()

    this.onResize?.(w, h, dpr)
    if (!this._running) this.renderOnce()
  }

  _onVisibility() {
    if (document.hidden) this.stop()
    else if (!this.pauseOffscreen || this._isVisible()) this.start()
  }

  _isVisible() {
    const r = this.canvas.getBoundingClientRect()
    return r.bottom > 0 && r.top < window.innerHeight
  }

  _onContextLost(e) {
    e.preventDefault() // 복구를 원한다는 신호. 이거 없으면 restored 이벤트가 안 온다
    this.stop()
    console.warn('[Stage] WebGL context lost')
  }

  _onContextRestored() {
    console.warn('[Stage] WebGL context restored — 리소스 재생성 필요')
    this.onContextRestored?.()
    this.start()
  }

  /**
   * 씬 그래프 전체를 순회하며 GPU 리소스 해제.
   * three는 lifetime을 모르므로 반드시 앱이 호출해야 한다.
   */
  dispose() {
    this.stop()
    this._resizeObserver.disconnect()
    this._io?.disconnect()
    document.removeEventListener('visibilitychange', this._onVisibility)
    this.canvas.removeEventListener('webglcontextlost', this._onContextLost)
    this.canvas.removeEventListener('webglcontextrestored', this._onContextRestored)

    this.scene.traverse((obj) => {
      if (obj.geometry) obj.geometry.dispose()
      const mats = Array.isArray(obj.material) ? obj.material : obj.material ? [obj.material] : []
      for (const m of mats) {
        for (const key of Object.keys(m)) {
          const v = m[key]
          if (v && v.isTexture) {
            v.dispose()
            v.image?.close?.() // ImageBitmap은 close()까지 해야 GC됨
          }
        }
        if (m.uniforms) {
          for (const u of Object.values(m.uniforms)) {
            if (u.value && u.value.isTexture) u.value.dispose()
          }
        }
        m.dispose()
      }
    })
    this.scene.clear()
    this.renderer.dispose()
    this._updaters.clear()
  }
}
```

### 4.3 `src/main.js` — 지연 로딩 + WebGL 폴백

```js
// src/main.js
const canvas = document.getElementById('gl')

// WebGL2 지원 여부를 가벼운 프로브로 확인 (three를 로드하기 전에)
function hasWebGL2() {
  try {
    const c = document.createElement('canvas')
    const gl = c.getContext('webgl2')
    if (!gl) return false
    // 프로브 컨텍스트는 즉시 반납한다. 안 그러면 컨텍스트 슬롯을 낭비한다
    gl.getExtension('WEBGL_lose_context')?.loseContext()
    return true
  } catch {
    return false
  }
}

const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const saveData = navigator.connection?.saveData === true
const lowMemory = (navigator.deviceMemory ?? 8) < 4

if (!canvas || !hasWebGL2() || saveData || lowMemory) {
  // 아무것도 하지 않는다 → CSS 그라디언트 폴백이 그대로 남는다
  canvas?.remove()
} else {
  // 뷰포트에 들어올 때 비로소 three를 다운로드한다
  const io = new IntersectionObserver(async ([entry], obs) => {
    if (!entry.isIntersecting) return
    obs.disconnect()
    const { default: mountHero } = await import('./scenes/hero.js')
    mountHero(canvas, { reduced })
  }, { rootMargin: '200px' })

  io.observe(canvas)
}
```

### 4.4 `src/scenes/hero.js` — 실제 씬 (풀스크린 셰이더)

```js
// src/scenes/hero.js
import * as THREE from 'three'
import Stage from '../gl/Stage.js'
import vertexShader from '../shaders/fullscreen.vert'
import fragmentShader from '../shaders/hero.frag'

export default function mountHero(canvas, { reduced = false } = {}) {
  const stage = new Stage({ canvas, dprMax: 2, antialias: false, alpha: false })

  // 풀스크린 트라이앵글: 플레인(2 tri, 4 vtx)보다 저렴하고 대각선 이음매가 없다
  const geometry = new THREE.BufferGeometry()
  geometry.setAttribute('position', new THREE.BufferAttribute(
    new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3
  ))
  geometry.setAttribute('uv', new THREE.BufferAttribute(
    new Float32Array([0, 0, 2, 0, 0, 2]), 2
  ))

  const uniforms = {
    uTime:       { value: 0 },
    uResolution: { value: new THREE.Vector2(1, 1) },
    uPointer:    { value: new THREE.Vector2(0.5, 0.5) },
    uColorA:     { value: new THREE.Color('#4c1d95') },
    uColorB:     { value: new THREE.Color('#0ea5e9') },
    uColorC:     { value: new THREE.Color('#f43f5e') },
    uIntensity:  { value: reduced ? 0.25 : 1.0 },
  }

  const material = new THREE.ShaderMaterial({
    vertexShader,
    fragmentShader,
    uniforms,
    depthTest: false,
    depthWrite: false,
  })

  const mesh = new THREE.Mesh(geometry, material)
  mesh.frustumCulled = false // 풀스크린 트라이앵글은 절대 컬링되면 안 된다
  stage.scene.add(mesh)

  // 카메라는 쓰지 않지만(클립 공간에 직접 그림) 렌더러가 요구하므로 그대로 둔다
  stage.onResize = (w, h, dpr) => uniforms.uResolution.value.set(w * dpr, h * dpr)
  stage.onResize(stage.size.w, stage.size.h, stage.size.dpr)

  // 포인터: 즉시 반영하지 않고 lerp로 따라가게 한다
  const target = new THREE.Vector2(0.5, 0.5)
  const onPointer = (e) => {
    target.set(e.clientX / window.innerWidth, 1 - e.clientY / window.innerHeight)
  }
  window.addEventListener('pointermove', onPointer, { passive: true })

  stage.add((t, dt) => {
    uniforms.uTime.value = reduced ? 0 : t
    uniforms.uPointer.value.lerp(target, 1 - Math.pow(0.001, dt)) // 프레임레이트 독립 lerp
  })

  if (reduced) {
    stage.renderOnce() // 한 장만 그리고 멈춘다
  } else {
    stage.start()
  }

  return () => {
    window.removeEventListener('pointermove', onPointer)
    stage.dispose()
  }
}
```

### 4.5 `src/shaders/fullscreen.vert`

```glsl
varying vec2 vUv;

void main() {
  vUv = uv;
  // position은 이미 클립 공간(-1..1)이므로 행렬 곱 없이 그대로 출력
  gl_Position = vec4(position.xy, 0.0, 1.0);
}
```

### 4.6 `src/shaders/hero.frag`

```glsl
precision highp float;

uniform float uTime;
uniform vec2  uResolution;
uniform vec2  uPointer;
uniform vec3  uColorA;
uniform vec3  uColorB;
uniform vec3  uColorC;
uniform float uIntensity;

varying vec2 vUv;

// --- simplex noise 3D (Ashima / stegu, MIT) -------------------------------
vec3 mod289(vec3 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec4 mod289(vec4 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec4 permute(vec4 x){ return mod289(((x*34.0)+10.0)*x); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }

float snoise(vec3 v) {
  const vec2 C = vec2(1.0/6.0, 1.0/3.0);
  const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
  vec3 i  = floor(v + dot(v, C.yyy));
  vec3 x0 = v - i + dot(i, C.xxx);
  vec3 g = step(x0.yzx, x0.xyz);
  vec3 l = 1.0 - g;
  vec3 i1 = min(g.xyz, l.zxy);
  vec3 i2 = max(g.xyz, l.zxy);
  vec3 x1 = x0 - i1 + C.xxx;
  vec3 x2 = x0 - i2 + C.yyy;
  vec3 x3 = x0 - D.yyy;
  i = mod289(i);
  vec4 p = permute(permute(permute(
             i.z + vec4(0.0, i1.z, i2.z, 1.0))
           + i.y + vec4(0.0, i1.y, i2.y, 1.0))
           + i.x + vec4(0.0, i1.x, i2.x, 1.0));
  float n_ = 0.142857142857;
  vec3 ns = n_ * D.wyz - D.xzx;
  vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
  vec4 x_ = floor(j * ns.z);
  vec4 y_ = floor(j - 7.0 * x_);
  vec4 x = x_ * ns.x + ns.yyyy;
  vec4 y = y_ * ns.x + ns.yyyy;
  vec4 h = 1.0 - abs(x) - abs(y);
  vec4 b0 = vec4(x.xy, y.xy);
  vec4 b1 = vec4(x.zw, y.zw);
  vec4 s0 = floor(b0) * 2.0 + 1.0;
  vec4 s1 = floor(b1) * 2.0 + 1.0;
  vec4 sh = -step(h, vec4(0.0));
  vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
  vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
  vec3 p0 = vec3(a0.xy, h.x);
  vec3 p1 = vec3(a0.zw, h.y);
  vec3 p2 = vec3(a1.xy, h.z);
  vec3 p3 = vec3(a1.zw, h.w);
  vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
  p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
  vec4 m = max(0.5 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
  m = m * m;
  return 105.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
}
// -------------------------------------------------------------------------

void main() {
  // 화면 비율 보정: 짧은 축 기준으로 정규화해야 노이즈가 늘어지지 않는다
  vec2 uv = gl_FragCoord.xy / uResolution;
  vec2 p  = (gl_FragCoord.xy * 2.0 - uResolution) / min(uResolution.x, uResolution.y);

  float t = uTime * 0.08;

  // 마우스가 만드는 부드러운 워프
  vec2 toPointer = p - (uPointer * 2.0 - 1.0) * vec2(uResolution.x / uResolution.y, 1.0);
  float pointerFalloff = exp(-dot(toPointer, toPointer) * 1.6);

  // 2옥타브 도메인 워프
  float n1 = snoise(vec3(p * 1.1, t));
  float n2 = snoise(vec3(p * 2.3 + n1 * 0.6, t * 1.4 + 11.0));
  float field = (n1 * 0.65 + n2 * 0.35) * uIntensity + pointerFalloff * 0.45;

  // 세 색을 필드 값으로 섞는다 (uColorX는 THREE.Color → 이미 linear 공간)
  vec3 col = mix(uColorA, uColorB, smoothstep(-0.6, 0.5, field));
  col = mix(col, uColorC, smoothstep(0.25, 0.95, field + pointerFalloff * 0.3));

  // 비네트
  col *= 1.0 - 0.45 * dot(p, p) * 0.35;

  // 밴딩 제거용 디더 (8bit 프레임버퍼에서 필수)
  float dither = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
  col += (dither - 0.5) / 255.0;

  gl_FragColor = vec4(col, 1.0);

  // three가 주입하는 청크. 톤매핑 → 출력 색공간 변환 순서를 지킨다
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}
```

> **주의**: `#include <tonemapping_fragment>` / `<colorspace_fragment>`는 `ShaderMaterial`에서만 동작한다.
> `RawShaderMaterial`은 three의 프리픽스 주입을 건너뛰므로 이 청크들이 컴파일 에러를 낸다.

---

## 5. 최소 보일러플레이트 — R3F (완성 코드)

### 5.1 `src/App.tsx`

```tsx
import { Suspense, lazy, useEffect, useState } from 'react'

const HeroScene = lazy(() => import('./gl/HeroScene'))

function useWebGLReady() {
  const [ok, setOk] = useState(false)
  useEffect(() => {
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
    const saveData = (navigator as any).connection?.saveData === true
    const mem = (navigator as any).deviceMemory ?? 8
    if (reduced || saveData || mem < 4) return
    try {
      const c = document.createElement('canvas')
      const gl = c.getContext('webgl2')
      if (!gl) return
      gl.getExtension('WEBGL_lose_context')?.loseContext()
      setOk(true)
    } catch { /* noop */ }
  }, [])
  return ok
}

export default function App() {
  const ready = useWebGLReady()

  return (
    <section className="hero">
      <div className="hero__canvas" aria-hidden="true">
        {ready && (
          <Suspense fallback={null}>
            <HeroScene />
          </Suspense>
        )}
      </div>
      <div className="hero__inner">
        <h1>Designpaca</h1>
        <p>WebGL은 장식이고, 콘텐츠는 DOM에 있다.</p>
      </div>
    </section>
  )
}
```

### 5.2 `src/gl/HeroScene.tsx`

```tsx
import * as THREE from 'three'
import { Canvas, useFrame, useThree, extend, type ThreeElement } from '@react-three/fiber'
import { shaderMaterial, PerformanceMonitor } from '@react-three/drei'
import { useMemo, useRef, useState } from 'react'

import vertexShader from '../shaders/fullscreen.vert'
import fragmentShader from '../shaders/hero.frag'

/* ---------------------------------------------------------------
 * 1) drei의 shaderMaterial 로 커스텀 머티리얼을 만든다.
 *    uniforms의 각 키가 JSX prop 이자 인스턴스 프로퍼티가 된다.
 * ------------------------------------------------------------- */
const HeroMaterial = shaderMaterial(
  {
    uTime: 0,
    uResolution: new THREE.Vector2(1, 1),
    uPointer: new THREE.Vector2(0.5, 0.5),
    uColorA: new THREE.Color('#4c1d95'),
    uColorB: new THREE.Color('#0ea5e9'),
    uColorC: new THREE.Color('#f43f5e'),
    uIntensity: 1,
  },
  vertexShader,
  fragmentShader
)

extend({ HeroMaterial })

// R3F v9: 전역 JSX 네임스페이스가 아니라 ThreeElements 인터페이스를 확장한다
declare module '@react-three/fiber' {
  interface ThreeElements {
    heroMaterial: ThreeElement<typeof HeroMaterial>
  }
}

/* ---------------------------------------------------------------
 * 2) 풀스크린 트라이앵글
 * ------------------------------------------------------------- */
function FullscreenTriangle() {
  const matRef = useRef<THREE.ShaderMaterial & { uTime: number }>(null!)
  const { size, viewport } = useThree()
  const pointer = useRef(new THREE.Vector2(0.5, 0.5))

  const geometry = useMemo(() => {
    const g = new THREE.BufferGeometry()
    g.setAttribute('position', new THREE.BufferAttribute(
      new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3))
    g.setAttribute('uv', new THREE.BufferAttribute(
      new Float32Array([0, 0, 2, 0, 0, 2]), 2))
    return g
  }, [])

  useFrame((state, delta) => {
    const m = matRef.current
    if (!m) return
    m.uTime = state.clock.elapsedTime
    ;(m as any).uResolution.set(size.width * viewport.dpr, size.height * viewport.dpr)
    pointer.current.set(state.pointer.x * 0.5 + 0.5, state.pointer.y * 0.5 + 0.5)
    ;(m as any).uPointer.lerp(pointer.current, 1 - Math.pow(0.001, delta))
  })

  return (
    <mesh geometry={geometry} frustumCulled={false}>
      <heroMaterial
        ref={matRef}
        key={HeroMaterial.key}   // HMR 시 머티리얼 재생성
        depthTest={false}
        depthWrite={false}
      />
    </mesh>
  )
}

/* ---------------------------------------------------------------
 * 3) Canvas
 * ------------------------------------------------------------- */
export default function HeroScene() {
  const [dpr, setDpr] = useState(1.5)

  return (
    <Canvas
      // dpr 은 [min, max] 배열도 되지만, PerformanceMonitor 로 제어할 땐 숫자로 둔다
      dpr={dpr}
      // 플랫 2D 셰이더면 톤매핑 없음이 정확하다 (flat = NoToneMapping)
      flat
      gl={{
        antialias: false,
        alpha: false,
        powerPreference: 'high-performance',
        stencil: false,
      }}
      camera={{ position: [0, 0, 5], fov: 45 }}
      style={{ position: 'absolute', inset: 0 }}
    >
      <PerformanceMonitor
        onIncline={() => setDpr(Math.min(2, window.devicePixelRatio))}
        onDecline={() => setDpr(1)}
      />
      <FullscreenTriangle />
    </Canvas>
  )
}
```

### 5.3 Canvas 기본값 치트시트

| prop | 기본값 | 실무 권장 |
|---|---|---|
| `dpr` | `[1, 2]` | 배경 셰이더면 `[1, 1.5]`, 3D 모델이면 `[1, 2]` |
| `camera` | `{ fov: 75, near: 0.1, far: 1000, position: [0,0,5] }` | far를 씬 크기에 맞게 줄여라(깊이 정밀도) |
| `frameloop` | `'always'` | 정적 씬은 `'demand'` |
| `shadows` | `false` | 랜딩페이지에선 대부분 `false` + baked/ContactShadows |
| `flat` | `false` | 2D 셰이더 배경이면 `true` (= `NoToneMapping`) |
| `linear` | `false` | 거의 항상 `false` (색공간은 자동 관리에 맡긴다) |
| `legacy` | `false` | 절대 `true` 쓰지 마라 (r152 이전 색관리로 회귀) |
| `gl` | `{ antialias:true, alpha:true, powerPreference:'high-performance' }` | 히어로 배경은 `alpha:false, antialias:false` |
| `eventSource` | `gl.domElement.parentNode` | 캔버스가 `pointer-events:none`이면 상위 DOM으로 지정 |
| `resize` | `{ scroll: true, debounce: { scroll: 50, resize: 0 } }` | 모바일 주소창 리사이즈 튐이 심하면 `scroll:false` |

R3F Canvas는 기본으로 `outputColorSpace = SRGBColorSpace`, `toneMapping = ACESFilmicToneMapping`을 설정한다.
**플랫한 그라디언트/UI성 셰이더에 ACES가 걸리면 색이 죽는다** → `flat` 사용.

---

## 6. r150 → r185 브레이킹 체인지 지도 (웹디자인 작업에 영향 있는 것만)

| 버전 | 변경 | 대응 |
|---|---|---|
| **r152** | `outputEncoding` → `outputColorSpace`(기본 `SRGBColorSpace`), `Texture.encoding` → `.colorSpace`, `ColorManagement.enabled` 기본 `true`, `uv2/uv3/uv4` → `uv1/uv2/uv3` | 컬러 텍스처에 `tex.colorSpace = THREE.SRGBColorSpace` 수동 지정. 노멀/러프니스맵은 지정하지 말 것 |
| **r153** | 포스트프로세싱 렌더타깃 기본 타입 `HalfFloatType` | — |
| **r154** | 셰이더 청크 `encodings_fragment` → `colorspace_fragment`, `output_fragment` → `opaque_fragment` | 커스텀 셰이더의 `#include` 이름 교체 |
| **r155** | `useLegacyLights` 기본 `false`. 조명이 물리 단위로 | 기존 라이트 intensity를 재조정(대체로 크게 올려야 함) |
| **r157** | GLSL `GeometricContext` struct 제거 | 커스텀 라이트 셰이더 수정 |
| **r158** | 쿼터니언은 정규화 전제. bumpScale 스케일 불변 | — |
| **r160** | `HBAOPass` → `GTAOPass` | — |
| **r161** | `build/three.js`, `three.min.js` **제거**. ESM만 | `<script src>` 방식 중단. importmap 또는 번들러 사용 |
| **r162** | `WebGLMultipleRenderTargets` 제거(→ `count` 옵션) | MRT 코드 수정 |
| **r163** | **WebGL1 지원 종료**, `stencil` 기본 `false`, `TextGeometry.height` → `depth`, `Scene.environmentIntensity` 추가 | 스텐실 쓰면 명시적으로 `stencil:true` |
| **r165** | `useLegacyLights` **완전 제거** | — |
| **r167** | WebGPU/TSL import 경로 재편 | — |
| **r169** | `PackedPhongMaterial`, `SDFGeometryGenerator` 등 제거. `Controls.activate/deactivate` → `connect/disconnect` | — |
| **r170** | `Material.type` static화, `CinematicCamera` 제거 | 커스텀 머티리얼에서 `type` 덮어쓰기 금지 |
| **r171** | `three/webgpu`, `three/tsl` 서브패스 확정. **WebGPURenderer 프로덕션 레디** | — |
| **r175** | `Controls.connect()`가 DOM 엘리먼트 인자 요구 | `controls.connect(renderer.domElement)` |
| **r176** | `CapsuleGeometry`의 `length` → `height` | — |
| **r177** | `ColorManagement.fromWorkingColorSpace()` → `workingToColorSpace()`, `toWorkingColorSpace()` → `colorSpaceToWorking()` | — |
| **r178** | `MultiplyBlending`/`SubtractiveBlending`이 `premultipliedAlpha = true` 요구 | 블렌딩 쓰는 파티클 확인 |
| **r179** | `Timer`가 코어로 이동(`three`에서 직접 import), `TRAAPassNode` → `TRAANode`, TSL `label()` → `setName()` | — |
| **r180** | `RGBELoader` → **`HDRLoader`**, `RGBMLoader` 제거, resolution 프로퍼티가 스칼라 `resolutionScale` | HDRI 로딩 코드 이름 변경 |
| **r181** | PBR 간접 스페큘러 개선 → 거친 재질이 밝아짐. 셰이더 상수 `PI2` → `TWO_PI` | 룩 재조정 |
| **r182** | `PCFSoftShadowMap`(WebGL) deprecated → `PCFShadowMap`. `renderAsync()` 등 async 메서드 deprecated | — |
| **r183** | `PostProcessing` → `RenderPipeline`. **`Clock` deprecated → `Timer`**. `MeshPostProcessingMaterial` 제거 | `new THREE.Clock()` 대신 `new THREE.Timer()` 또는 `setAnimationLoop(t)` 인자 사용 |
| **r184** | `FBXLoader`가 +Z-up을 +Y-up으로 자동 변환. `FileLoader.load()` 반환값 없음 | — |
| **r185** | WebGPU premultiplied alpha 구현 변경, `AnamorphicNode` 제거, `DRACOLoader.setDecoderConfig()` deprecated(WASM 필수화 예정) | DRACO 디코더는 WASM 버전 사용 |

### 실전 규칙 3개

1. **`THREE.Clock` 쓰지 마라** [r183+ deprecated]. `renderer.setAnimationLoop((timeMs) => ...)`의 인자를 쓰거나 `new THREE.Timer()`를 쓴다.
2. **컬러 텍스처는 항상 `colorSpace` 명시** [r152+]. R3F v9는 자동 지정을 제거했다.
   ```js
   const tex = await new THREE.TextureLoader().loadAsync('/img.jpg')
   tex.colorSpace = THREE.SRGBColorSpace   // 컬러맵
   // normalMap / roughnessMap / aoMap / displacementMap 은 지정하지 않는다 (LinearSRGB)
   ```
3. **HDRI는 `HDRLoader`** [r180+]. `RGBELoader`라는 이름은 사라졌다.
   ```js
   import { HDRLoader } from 'three/addons/loaders/HDRLoader.js'
   ```

---

## 7. 색공간 규칙 (r152+) — 한 장 요약

```
[입력]                        [작업 공간]           [출력]
sRGB 텍스처 ─ colorSpace ──┐
CSS/hex 색 ─ THREE.Color ──┼→ Linear-sRGB ─ toneMapping ─→ outputColorSpace(sRGB) ─→ 화면
데이터 텍스처(노멀 등) ─────┘   (셰이더 연산)
```

| 대상 | 설정 |
|---|---|
| 컬러/알베도 텍스처 | `texture.colorSpace = THREE.SRGBColorSpace` |
| 노멀·러프니스·메탈니스·AO·디스플레이스먼트 | 건드리지 않음 (기본 `NoColorSpace`/`LinearSRGBColorSpace`) |
| `new THREE.Color('#ff0000')` | 자동으로 Linear-sRGB에 저장됨 → 셰이더 uniform으로 그대로 넘기면 정확 |
| 셰이더에서 하드코딩한 `vec3(1.0, 0.0, 0.0)` | **linear 값**이다. sRGB hex를 그대로 넣으면 색이 틀린다 → JS에서 `THREE.Color`로 넘겨라 |
| 커스텀 fragment 마지막 줄 | `#include <tonemapping_fragment>` → `#include <colorspace_fragment>` 순서 |
| 포스트프로세싱 사용 시 | 인라인 톤매핑은 화면 렌더에만 적용됨. `OutputPass`(또는 pp 라이브러리의 ToneMapping/Output)를 체인 끝에 둔다 |

---

## 8. WebGPU / TSL — 지금 써야 하나?

### 현황
- **r171(2025-09)부터 `WebGPURenderer`가 프로덕션 레디**로 선언됨. `import { WebGPURenderer } from 'three/webgpu'`, 미지원 브라우저에서 **WebGL2로 자동 폴백**.
- Safari 26(2025-09)이 WebGPU를 지원하면서 Chrome 113+/Edge/Firefox 141+/Safari 26+ 로 **주요 브라우저 커버리지 완성**.
- **TSL(Three Shading Language)**: JS로 셰이더 그래프를 작성하면 WGSL/GLSL로 동시 컴파일. `three/tsl`에서 import.
- 컴퓨트 셰이더로 100만+ 파티클 가능 (WebGL Points 실용 한계 ~5~10만).

### 판단 기준

| 쓴다 | 안 쓴다 |
|---|---|
| 파티클 10만 개 이상, GPU 시뮬레이션이 핵심 | 히어로 배경 셰이더 1장 |
| 드로우콜 수천 개 규모 씬 | DOM 이미지 왜곡, 스크롤 연출 |
| 노드 기반 머티리얼을 조합해야 함 | 기존 GLSL 자산을 재활용해야 함 |
| 2026년 이후까지 유지할 제품 | 3개월짜리 캠페인 사이트 |

### 현실적 리스크
- **`@react-three/postprocessing` / `postprocessing`은 WebGL 전용**이다. WebGPU로 가면 포스트프로세싱은 three 내장 `RenderPipeline`[r183+] + TSL 노드로 다시 짜야 한다.
- drei 컴포넌트 상당수가 `ShaderMaterial`(GLSL) 기반이라 WebGPU에서 안 돈다.
- 폴백 경로(WebGL2)에서 TSL이 GLSL로 트랜스파일되지만, 성능/룩이 항상 동일하진 않다. **두 경로를 다 테스트해야 한다.**

### 결론 (designpaca 기본값)
**WebGL2 + GLSL을 기본으로 한다.** 랜딩페이지 효과의 95%는 WebGL2로 충분하고, 생태계(포스트프로세싱·drei)가 여기 붙어 있다.
WebGPU는 "파티클 100만"처럼 명확한 이유가 있을 때만 별도 씬으로 분리해서 쓴다.

### TSL 맛보기 (참고용)

```js
import { WebGPURenderer, MeshStandardNodeMaterial } from 'three/webgpu'
import { Fn, uniform, time, sin, positionLocal, normalLocal, vec3, float } from 'three/tsl'

const renderer = new WebGPURenderer({ canvas, antialias: false })
await renderer.init()          // [r182+] 대부분 자동이지만 명시 호출이 안전

const uAmp  = uniform(0.25)
const uFreq = uniform(3.0)

const wave = sin(positionLocal.x.mul(uFreq).add(time))
const material = new MeshStandardNodeMaterial()
material.positionNode = positionLocal.add(normalLocal.mul(wave.mul(uAmp)))
material.colorNode = vec3(0.3, 0.4, 0.9).add(wave.mul(0.5))
```

---

## 9. 권장 폴더 구조

```
src/
├─ main.js|tsx
├─ gl/
│  ├─ Stage.js               # 렌더 루프 컨테이너 (vanilla)
│  ├─ loaders.js             # GLTF/DRACO/KTX2/meshopt 싱글턴
│  ├─ FBO.js                 # ping-pong 렌더타깃 유틸
│  └─ scenes/
│     ├─ hero.js
│     ├─ gallery.js
│     └─ product.js
├─ shaders/
│  ├─ lib/                   # 재사용 GLSL 청크
│  │  ├─ noise.glsl
│  │  ├─ curl.glsl
│  │  ├─ uv.glsl
│  │  └─ color.glsl
│  ├─ fullscreen.vert
│  ├─ hero.frag
│  └─ image.vert / image.frag
└─ styles/
```

- **셰이더는 반드시 별도 파일**로. 템플릿 리터럴 안에 넣으면 신택스 하이라이팅·`#include`·핫리로드를 전부 잃는다.
- `shaders/lib/`은 `#include './lib/noise.glsl'`로 재사용. `vite-plugin-glsl`이 인라인해준다.
- 로더는 **싱글턴**으로. `DRACOLoader`를 컴포넌트마다 새로 만들면 WASM 디코더를 반복 다운로드한다.

```js
// src/gl/loaders.js — 로더 싱글턴 (완성 코드)
import * as THREE from 'three'
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'

let _gltf = null
let _draco = null
let _ktx2 = null

/** @param {THREE.WebGLRenderer} renderer KTX2 지원 감지에 필요 */
export function getGLTFLoader(renderer) {
  if (_gltf) return _gltf

  _draco = new DRACOLoader()
  // decoder는 자체 호스팅 권장. node_modules/three/examples/jsm/libs/draco/ 를 public/에 복사
  _draco.setDecoderPath('/decoders/draco/')
  _draco.preload()

  _ktx2 = new KTX2Loader()
  _ktx2.setTranscoderPath('/decoders/basis/')
  _ktx2.detectSupport(renderer)   // 반드시 renderer 생성 후 호출

  _gltf = new GLTFLoader()
  _gltf.setDRACOLoader(_draco)
  _gltf.setKTX2Loader(_ktx2)
  _gltf.setMeshoptDecoder(MeshoptDecoder)
  return _gltf
}

export function disposeLoaders() {
  _draco?.dispose()
  _ktx2?.dispose()
  _gltf = _draco = _ktx2 = null
}
```

디코더 파일 복사 스크립트:

```json
// package.json
{
  "scripts": {
    "postinstall": "node scripts/copy-decoders.mjs"
  }
}
```

```js
// scripts/copy-decoders.mjs
import { cp, mkdir } from 'node:fs/promises'
const base = 'node_modules/three/examples/jsm/libs'
await mkdir('public/decoders', { recursive: true })
await cp(`${base}/draco`, 'public/decoders/draco', { recursive: true })
await cp(`${base}/basis`, 'public/decoders/basis', { recursive: true })
console.log('decoders copied')
```

---

## 10. 셋업 체크리스트

- [ ] `three` 버전을 **0.185.1로 핀**했다 (postprocessing 상한 회피)
- [ ] `overrides`/`resolutions`로 three 중복 설치를 막았다
- [ ] `vite-plugin-glsl`을 설정하고 셰이더를 별도 파일로 분리했다
- [ ] 캔버스에 `aria-hidden="true"`와 CSS 폴백 배경을 넣었다
- [ ] `hasWebGL2()` 프로브 + `saveData` + `deviceMemory` 체크로 게이팅했다
- [ ] three를 **동적 import**로 지연 로드했다 (IntersectionObserver)
- [ ] DPR을 `min(devicePixelRatio, 2)`로 클램프했다
- [ ] `visibilitychange`와 `IntersectionObserver`로 렌더 루프를 멈춘다
- [ ] `prefers-reduced-motion`에서 `renderOnce()` 한 장만 그린다
- [ ] `webglcontextlost`에 `preventDefault()`를 걸었다
- [ ] 언마운트/페이지 이탈 시 `dispose()`를 호출한다
- [ ] 컬러 텍스처에 `colorSpace = SRGBColorSpace`를 지정했다
- [ ] 플랫 2D 셰이더면 `toneMapping = NoToneMapping`(R3F는 `flat`)을 썼다
- [ ] DRACO/KTX2 디코더를 자체 호스팅하고 로더를 싱글턴화했다
