웹 디자인 파이프라인 스킬과 이를 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)
1047 lines
39 KiB
Markdown
1047 lines
39 KiB
Markdown
# 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 디코더를 자체 호스팅하고 로더를 싱글턴화했다
|