designpaca/research/three/01-stack-and-setup.md
Yun Chan 8808c672dc designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포.

스킬 (packages/skill)
- SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로
  본문은 절차와 인덱스만, 지식은 references/ 로 분리
- 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기
- 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외
- 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙,
  SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침

설치 CLI (packages/cli, packages/core)
- npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md
- 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다
- 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성
- AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입
- Windsurf 는 12,000자 상한 초과 시 설치를 차단

배포 (build/ci, .forgejo/workflows)
- 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러
  → draft 릴리스 → Cloudflare Pages. 재실행 멱등

근거 (research/)
- 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다

테스트 22개 통과 (core 16 · cli 6)
2026-08-20 10:48:00 +09:00

39 KiB

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로 단일화한다.
// 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만 1590KB 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

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)

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

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를 반드시 클라이언트 전용 동적 임포트로 감싼다.

// 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 설정

// 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를 쓴다면:

// 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

<!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를 전부 처리한다. 모든 씬은 이 위에 얹는다.

// 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 폴백

// 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 — 실제 씬 (풀스크린 셰이더)

// 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

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

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

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

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 outputEncodingoutputColorSpace(기본 SRGBColorSpace), Texture.encoding.colorSpace, ColorManagement.enabled 기본 true, uv2/uv3/uv4uv1/uv2/uv3 컬러 텍스처에 tex.colorSpace = THREE.SRGBColorSpace 수동 지정. 노멀/러프니스맵은 지정하지 말 것
r153 포스트프로세싱 렌더타깃 기본 타입 HalfFloatType
r154 셰이더 청크 encodings_fragmentcolorspace_fragment, output_fragmentopaque_fragment 커스텀 셰이더의 #include 이름 교체
r155 useLegacyLights 기본 false. 조명이 물리 단위로 기존 라이트 intensity를 재조정(대체로 크게 올려야 함)
r157 GLSL GeometricContext struct 제거 커스텀 라이트 셰이더 수정
r158 쿼터니언은 정규화 전제. bumpScale 스케일 불변
r160 HBAOPassGTAOPass
r161 build/three.js, three.min.js 제거. ESM만 <script src> 방식 중단. importmap 또는 번들러 사용
r162 WebGLMultipleRenderTargets 제거(→ count 옵션) MRT 코드 수정
r163 WebGL1 지원 종료, stencil 기본 false, TextGeometry.heightdepth, Scene.environmentIntensity 추가 스텐실 쓰면 명시적으로 stencil:true
r165 useLegacyLights 완전 제거
r167 WebGPU/TSL import 경로 재편
r169 PackedPhongMaterial, SDFGeometryGenerator 등 제거. Controls.activate/deactivateconnect/disconnect
r170 Material.type static화, CinematicCamera 제거 커스텀 머티리얼에서 type 덮어쓰기 금지
r171 three/webgpu, three/tsl 서브패스 확정. WebGPURenderer 프로덕션 레디
r175 Controls.connect()가 DOM 엘리먼트 인자 요구 controls.connect(renderer.domElement)
r176 CapsuleGeometrylengthheight
r177 ColorManagement.fromWorkingColorSpace()workingToColorSpace(), toWorkingColorSpace()colorSpaceToWorking()
r178 MultiplyBlending/SubtractiveBlendingpremultipliedAlpha = true 요구 블렌딩 쓰는 파티클 확인
r179 Timer가 코어로 이동(three에서 직접 import), TRAAPassNodeTRAANode, TSL label()setName()
r180 RGBELoaderHDRLoader, RGBMLoader 제거, resolution 프로퍼티가 스칼라 resolutionScale HDRI 로딩 코드 이름 변경
r181 PBR 간접 스페큘러 개선 → 거친 재질이 밝아짐. 셰이더 상수 PI2TWO_PI 룩 재조정
r182 PCFSoftShadowMap(WebGL) deprecated → PCFShadowMap. renderAsync() 등 async 메서드 deprecated
r183 PostProcessingRenderPipeline. 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는 자동 지정을 제거했다.
    const tex = await new THREE.TextureLoader().loadAsync('/img.jpg')
    tex.colorSpace = THREE.SRGBColorSpace   // 컬러맵
    // normalMap / roughnessMap / aoMap / displacementMap 은 지정하지 않는다 (LinearSRGB)
    
  3. HDRI는 HDRLoader [r180+]. RGBELoader라는 이름은 사라졌다.
    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 실용 한계 510만).

판단 기준

쓴다 안 쓴다
파티클 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 맛보기 (참고용)

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 디코더를 반복 다운로드한다.
// 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
}

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

// package.json
{
  "scripts": {
    "postinstall": "node scripts/copy-decoders.mjs"
  }
}
// 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)로 클램프했다
  • visibilitychangeIntersectionObserver로 렌더 루프를 멈춘다
  • prefers-reduced-motion에서 renderOnce() 한 장만 그린다
  • webglcontextlostpreventDefault()를 걸었다
  • 언마운트/페이지 이탈 시 dispose()를 호출한다
  • 컬러 텍스처에 colorSpace = SRGBColorSpace를 지정했다
  • 플랫 2D 셰이더면 toneMapping = NoToneMapping(R3F는 flat)을 썼다
  • DRACO/KTX2 디코더를 자체 호스팅하고 로더를 싱글턴화했다