웹 디자인 파이프라인 스킬과 이를 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)
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 |
🔴 버전 조합 함정 (가장 흔한 사고)
postprocessing의 three 상한:>= 0.168.0 < 0.186.0.three@0.186으로 올리면 포스트프로세싱이 깨진다. 포스트프로세싱을 쓸 거면 three를 0.185.x에 고정한다.- R3F v9는 React 19 전용이고
react >=19 <19.3. React 19.2.x에서 내부 reconciler가 비호환 변경돼서 R3F가 reconciler를 번들에 포함시켰다. React 19.3+에서는 R3F v10을 기다려야 한다. - 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 런타임( |
| 스크롤 전체를 지배하는 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 | 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개
THREE.Clock쓰지 마라 [r183+ deprecated].renderer.setAnimationLoop((timeMs) => ...)의 인자를 쓰거나new THREE.Timer()를 쓴다.- 컬러 텍스처는 항상
colorSpace명시 [r152+]. R3F v9는 자동 지정을 제거했다.const tex = await new THREE.TextureLoader().loadAsync('/img.jpg') tex.colorSpace = THREE.SRGBColorSpace // 컬러맵 // normalMap / roughnessMap / aoMap / displacementMap 은 지정하지 않는다 (LinearSRGB) - 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)로 클램프했다 visibilitychange와IntersectionObserver로 렌더 루프를 멈춘다prefers-reduced-motion에서renderOnce()한 장만 그린다webglcontextlost에preventDefault()를 걸었다- 언마운트/페이지 이탈 시
dispose()를 호출한다 - 컬러 텍스처에
colorSpace = SRGBColorSpace를 지정했다 - 플랫 2D 셰이더면
toneMapping = NoToneMapping(R3F는flat)을 썼다 - DRACO/KTX2 디코더를 자체 호스팅하고 로더를 싱글턴화했다