designpaca/research/three/02-patterns.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

3385 lines
114 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 02 — 바로 쓸 수 있는 패턴 15개
> 전제: **three 0.185.1**, `Stage` 클래스는 [01-stack-and-setup.md](./01-stack-and-setup.md) §4.2 참고.
> R3F 예제는 **@react-three/fiber 9.7 / drei 10.7 / React 19** 기준.
> 모든 코드는 생략 없는 완성 코드다. 파일 경로 주석을 그대로 따라 배치하면 동작한다.
>
> **비용 표기**
> - `번들`: 이 패턴이 추가로 요구하는 gzip 크기 (three 코어 ~90KB는 별도)
> - `GPU`: 저사양 모바일(iPhone SE / Adreno 6xx) 기준 부하. 낮음 / 중간 / 높음 / 매우높음
> - `프래그먼트 비용`: 풀스크린일 때 픽셀당 대략적 ALU 부담
---
## 목차
| # | 패턴 | 스택 | GPU |
|---|---|---|---|
| [P1](#p1--유동-메시-그라디언트-vertex-displacement) | 유동 메시 그라디언트 (vertex displacement) | Vanilla | 낮음 |
| [P2](#p2--sdf-도트그리드-배경--마우스-트레일) | SDF 도트 그리드 배경 + 마우스 트레일 | Vanilla | 낮음 |
| [P3](#p3--파티클-필드-points--커스텀-셰이더) | 파티클 필드 (Points + 커스텀 셰이더) | Vanilla | 중간 |
| [P4](#p4--gpgpu-플로우필드-파티클-pingpong-fbo--curl-noise) | GPGPU 플로우필드 파티클 (ping-pong FBO) | Vanilla | 높음 |
| [P5](#p5--instancedmesh-그리드-웨이브) | InstancedMesh 그리드 웨이브 | Vanilla | 중간 |
| [P6](#p6--스크롤-카메라-리그-lenis--gsap-scrolltrigger) | 스크롤 카메라 리그 (Lenis + ScrollTrigger) | Vanilla | 낮음 |
| [P7](#p7--r3f-scrollcontrols-스크롤-씬) | R3F ScrollControls 스크롤 씬 | R3F | 낮음 |
| [P8](#p8--dom-동기화-이미지-플레인--hover-왜곡--rgb-shift) | DOM 동기화 이미지 플레인 + hover 왜곡 | Vanilla | 중간 |
| [P9](#p9--이미지-전환-displacement-map-transition) | 이미지 전환 (displacement transition) | Vanilla | 낮음 |
| [P10](#p10--유리굴절-meshtransmissionmaterial--dispersion) | 유리/굴절 (Transmission + dispersion) | R3F / Vanilla | 매우높음 |
| [P11](#p11--3d-텍스트-troika--디스플레이스먼트-리빌) | 3D 텍스트 (troika) + 디스플레이스먼트 리빌 | Vanilla | 낮음 |
| [P12](#p12--포스트프로세싱-스택--커스텀-이펙트) | 포스트프로세싱 스택 + 커스텀 이펙트 | 양쪽 | 중간~높음 |
| [P13](#p13--gltf-제품-씬-draco--ktx2--환경광--스크롤-회전) | GLTF 제품 씬 (DRACO/KTX2 + 스크롤 회전) | R3F | 중간 |
| [P14](#p14--마우스-인터랙션-툴킷-raycast--lerp--trail) | 마우스 인터랙션 툴킷 (raycast/lerp/trail) | Vanilla | 낮음 |
| [P15](#p15--노이즈-디졸브-리빌전환) | 노이즈 디졸브 리빌/전환 | 양쪽 | 낮음 |
---
## P1 — 유동 메시 그라디언트 (vertex displacement)
**효과**: Stripe 홈페이지류의 "용암 램프" 그라디언트. 세그먼트가 많은 평면을 노이즈로 물결치게 하고, 정점 높이로 색을 섞는다.
프래그먼트 전용 그라디언트(01 §4.6)보다 **깊이감과 결(silhouette)** 이 살아난다.
**언제 쓰나**
- 히어로 섹션 배경, 풀블리드 CTA 섹션
- 브랜드 컬러 2~4개를 유기적으로 섞고 싶을 때
- 프래그먼트 노이즈만으로는 평평해 보일 때
**비용**: 번들 +0KB · GPU **낮음** · 정점 128×128 = 16,384 vtx (드로우콜 1) · 프래그먼트 비용 매우 낮음(노이즈가 vertex에 있음)
**조절 파라미터**
| uniform | 범위 | 의미 |
|---|---|---|
| `uAmplitude` | 0.05 ~ 0.6 | 물결 높이. 0.3 넘으면 실루엣이 거칠어짐 |
| `uFrequency` | 0.5 ~ 4.0 | 노이즈 스케일. 클수록 잔물결 |
| `uSpeed` | 0.02 ~ 0.4 | 시간 진행 속도. 0.1 이하가 "고급스러움" |
| `uOctaves`(define) | 1 ~ 4 | FBM 옥타브. 3 이상은 모바일에서 체감 비용 |
| `SEGMENTS` | 48 ~ 256 | 평면 세그먼트. 모바일 96, 데스크톱 160 권장 |
| `uColorA/B/C` | — | `THREE.Color` (자동으로 linear 변환됨) |
### 코드
```js
// src/scenes/meshGradient.js
import * as THREE from 'three'
import Stage from '../gl/Stage.js'
import vert from '../shaders/meshGradient.vert'
import frag from '../shaders/meshGradient.frag'
export default function mountMeshGradient(canvas, opts = {}) {
const {
colors = ['#3b0764', '#2563eb', '#f472b6'],
amplitude = 0.28,
frequency = 1.4,
speed = 0.09,
reduced = false,
} = opts
const stage = new Stage({ canvas, dprMax: 1.75, antialias: false, alpha: false })
// 카메라를 위에서 비스듬히 내려다보게 배치 → 물결의 실루엣이 보인다
stage.camera.fov = 30
stage.camera.position.set(0, 1.35, 3.2)
stage.camera.lookAt(0, 0, 0)
stage.camera.updateProjectionMatrix()
const isMobile = window.matchMedia('(max-width: 768px)').matches
const SEG = isMobile ? 96 : 160
const geometry = new THREE.PlaneGeometry(6, 6, SEG, SEG)
const uniforms = {
uTime: { value: 0 },
uAmplitude: { value: amplitude },
uFrequency: { value: frequency },
uSpeed: { value: speed },
uColorA: { value: new THREE.Color(colors[0]) },
uColorB: { value: new THREE.Color(colors[1]) },
uColorC: { value: new THREE.Color(colors[2]) },
uPointer: { value: new THREE.Vector2(0, 0) },
}
const material = new THREE.ShaderMaterial({
vertexShader: vert,
fragmentShader: frag,
uniforms,
defines: { OCTAVES: isMobile ? 2 : 3 },
side: THREE.DoubleSide,
})
const mesh = new THREE.Mesh(geometry, material)
mesh.rotation.x = -Math.PI * 0.5 // 바닥에 눕힌다
stage.scene.add(mesh)
const target = new THREE.Vector2()
const onPointer = (e) => {
target.set(
(e.clientX / window.innerWidth) * 2 - 1,
-(e.clientY / window.innerHeight) * 2 + 1
)
}
window.addEventListener('pointermove', onPointer, { passive: true })
stage.add((t, dt) => {
uniforms.uTime.value = t
uniforms.uPointer.value.lerp(target, 1 - Math.pow(0.002, dt))
// 카메라가 아주 미세하게 흔들리면 "살아있다"는 인상이 생긴다
stage.camera.position.x = uniforms.uPointer.value.x * 0.12
stage.camera.position.y = 1.35 + uniforms.uPointer.value.y * 0.06
stage.camera.lookAt(0, 0, 0)
})
if (reduced) stage.renderOnce()
else stage.start()
return () => {
window.removeEventListener('pointermove', onPointer)
stage.dispose()
}
}
```
```glsl
// src/shaders/meshGradient.vert
#include './lib/noise.glsl' // snoise(vec3) 정의. 03-shaders.md §4 참조
uniform float uTime;
uniform float uAmplitude;
uniform float uFrequency;
uniform float uSpeed;
uniform vec2 uPointer;
varying float vElevation;
varying vec2 vUv;
#ifndef OCTAVES
#define OCTAVES 3
#endif
float fbm(vec3 p) {
float sum = 0.0;
float amp = 0.5;
float freq = 1.0;
for (int i = 0; i < OCTAVES; i++) {
sum += snoise(p * freq) * amp;
freq *= 2.02;
amp *= 0.5;
}
return sum;
}
void main() {
vUv = uv;
vec3 pos = position;
float t = uTime * uSpeed;
// 도메인 워프: 노이즈로 좌표를 한 번 흔든 뒤 다시 노이즈를 샘플
vec3 q = vec3(pos.xy * uFrequency, t);
float warp = fbm(q) * 0.45;
float elevation = fbm(vec3(pos.xy * uFrequency + warp, t * 1.3));
// 마우스 근처를 부풀린다
float d = distance(pos.xy, uPointer * 2.4);
elevation += exp(-d * d * 1.1) * 0.35;
pos.z += elevation * uAmplitude;
vElevation = elevation;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
```
```glsl
// src/shaders/meshGradient.frag
precision highp float;
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform vec3 uColorC;
varying float vElevation;
varying vec2 vUv;
void main() {
float e = clamp(vElevation * 0.5 + 0.5, 0.0, 1.0);
vec3 col = mix(uColorA, uColorB, smoothstep(0.15, 0.62, e));
col = mix(col, uColorC, smoothstep(0.58, 0.96, e));
// 가장자리를 어둡게 → 평면 경계가 안 보이게
float edge = smoothstep(0.0, 0.18, vUv.x) * smoothstep(1.0, 0.82, vUv.x)
* smoothstep(0.0, 0.18, vUv.y) * smoothstep(1.0, 0.82, vUv.y);
col *= mix(0.35, 1.0, edge);
// 밴딩 제거
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);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
**튜닝 노트**
- 세그먼트를 늘리는 대신 `uFrequency`를 낮춰라. 정점 수 2배는 항상 손해다.
- 모바일에서 `OCTAVES 2`, `SEG 96`, `dprMax 1.5`로 내리면 60fps가 유지된다.
- 정점 변위는 노멀을 깨뜨린다. 조명을 쓸 거면 `computeVertexNormals()`가 아니라 셰이더에서 유한차분으로 노멀을 재계산해야 한다(03 §6.3).
---
## P2 — SDF 도트 그리드 배경 + 마우스 트레일
**효과**: 화면을 격자로 나누고 각 셀에 SDF 원을 그린다. 마우스 트레일 텍스처를 샘플해 도트 크기/밝기를 부풀린다.
Codrops의 "Subtle Shader Background" 계열. **매우 저렴하면서 고급스러운** 기본기.
**언제 쓰나**
- 텍스트가 위에 올라가는 배경 (가독성을 해치지 않음)
- 다크 테마 랜딩페이지의 "테크" 인상
- 파티클을 쓰고 싶지만 예산이 없을 때
**비용**: 번들 +0KB · GPU **낮음** · 드로우콜 1 · 트레일 텍스처는 128×128 2D 캔버스 (CPU 비용 무시 가능)
**조절 파라미터**
| uniform | 범위 | 의미 |
|---|---|---|
| `uGridSize` | 20 ~ 120 | 화면 짧은 축 기준 셀 개수 |
| `uDotSize` | 0.06 ~ 0.3 | 셀 내 도트 반경(셀 크기 대비) |
| `uDotOpacity` | 0.1 ~ 1.0 | 기본 밝기 |
| `uTrailStrength` | 0 ~ 4 | 트레일이 도트를 얼마나 키우는가 |
| trail `radius` | 0.05 ~ 0.2 | 브러시 반경(정규화) |
| trail `decay` | 0.9 ~ 0.99 | 잔상 길이. 클수록 오래 남음 |
### 코드
```js
// src/gl/TrailTexture.js — 2D 캔버스 기반 마우스 트레일 (vanilla)
import * as THREE from 'three'
export default class TrailTexture {
/**
* @param {object} o
* @param {number} [o.size=128] 트레일 버퍼 해상도
* @param {number} [o.radius=0.1] 브러시 반경(0~1 정규화)
* @param {number} [o.decay=0.95] 프레임당 감쇠 (1에 가까울수록 길게 남음)
* @param {number} [o.intensity=1]
*/
constructor({ size = 128, radius = 0.1, decay = 0.95, intensity = 1 } = {}) {
this.size = size
this.radius = radius
this.decay = decay
this.intensity = intensity
this.canvas = document.createElement('canvas')
this.canvas.width = this.canvas.height = size
this.ctx = this.canvas.getContext('2d', { willReadFrequently: false })
this.ctx.fillStyle = '#000'
this.ctx.fillRect(0, 0, size, size)
this.texture = new THREE.CanvasTexture(this.canvas)
this.texture.minFilter = THREE.LinearFilter
this.texture.magFilter = THREE.LinearFilter
this.texture.wrapS = this.texture.wrapT = THREE.ClampToEdgeWrapping
// 트레일은 데이터다. 색공간 변환을 하면 안 된다
this.texture.colorSpace = THREE.NoColorSpace
this._points = []
}
/** uv: 0~1, y는 위가 1 */
addPoint(u, v) {
this._points.push({ x: u * this.size, y: (1 - v) * this.size, life: 1 })
if (this._points.length > 40) this._points.shift()
}
update() {
const { ctx, size } = this
// 1) 전체를 조금씩 검게 → 지수 감쇠
ctx.globalCompositeOperation = 'source-over'
ctx.fillStyle = `rgba(0, 0, 0, ${1 - this.decay})`
ctx.fillRect(0, 0, size, size)
// 2) 브러시를 additive 로 찍는다
ctx.globalCompositeOperation = 'lighter'
const r = this.radius * size
for (let i = this._points.length - 1; i >= 0; i--) {
const p = this._points[i]
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r)
const a = p.life * this.intensity
g.addColorStop(0, `rgba(255,255,255,${a})`)
g.addColorStop(1, 'rgba(255,255,255,0)')
ctx.fillStyle = g
ctx.beginPath()
ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
ctx.fill()
p.life *= 0.86
if (p.life < 0.01) this._points.splice(i, 1)
}
ctx.globalCompositeOperation = 'source-over'
this.texture.needsUpdate = true
}
dispose() {
this.texture.dispose()
this._points.length = 0
}
}
```
```js
// src/scenes/dotGrid.js
import * as THREE from 'three'
import Stage from '../gl/Stage.js'
import TrailTexture from '../gl/TrailTexture.js'
import vert from '../shaders/fullscreen.vert'
import frag from '../shaders/dotGrid.frag'
export default function mountDotGrid(canvas, opts = {}) {
const {
bgColor = '#07070a',
dotColor = '#8b5cf6',
gridSize = 56,
dotSize = 0.13,
reduced = false,
} = opts
const stage = new Stage({ canvas, dprMax: 1.5, antialias: false })
const trail = new TrailTexture({ size: 128, radius: 0.09, decay: 0.94 })
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) },
uTrail: { value: trail.texture },
uBgColor: { value: new THREE.Color(bgColor) },
uDotColor: { value: new THREE.Color(dotColor) },
uGridSize: { value: gridSize },
uDotSize: { value: dotSize },
uDotOpacity: { value: 0.5 },
uTrailStrength: { value: 2.2 },
}
const mesh = new THREE.Mesh(geometry, new THREE.ShaderMaterial({
vertexShader: vert, fragmentShader: frag, uniforms,
depthTest: false, depthWrite: false,
}))
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)
const onPointer = (e) => {
trail.addPoint(e.clientX / window.innerWidth, 1 - e.clientY / window.innerHeight)
}
if (!reduced) window.addEventListener('pointermove', onPointer, { passive: true })
stage.add((t) => {
uniforms.uTime.value = t
trail.update()
})
if (reduced) stage.renderOnce()
else stage.start()
return () => {
window.removeEventListener('pointermove', onPointer)
trail.dispose()
stage.dispose()
}
}
```
```glsl
// src/shaders/dotGrid.frag
precision highp float;
uniform float uTime;
uniform vec2 uResolution;
uniform sampler2D uTrail;
uniform vec3 uBgColor;
uniform vec3 uDotColor;
uniform float uGridSize;
uniform float uDotSize;
uniform float uDotOpacity;
uniform float uTrailStrength;
varying vec2 vUv;
// 원 SDF: 중심에서의 거리 - 반지름 (음수면 내부)
float sdfCircle(vec2 p, float r) {
return length(p - 0.5) - r;
}
void main() {
vec2 screenUv = gl_FragCoord.xy / uResolution;
// 종횡비 보정: 짧은 축 기준으로 정사각 셀을 만든다
float aspect = uResolution.x / uResolution.y;
vec2 uv = screenUv;
uv.x *= aspect;
vec2 grid = uv * uGridSize;
vec2 cellUv = fract(grid);
vec2 cellId = floor(grid);
// 셀 중심의 화면 좌표 → 트레일을 셀 단위로 샘플하면 "픽셀화"된 반응이 나온다
vec2 cellCenter = (cellId + 0.5) / uGridSize;
cellCenter.x /= aspect;
float trail = texture2D(uTrail, cellCenter).r;
// 시간에 따라 흐르는 잔잔한 마스크
float wave = sin(uTime * 1.2 - length(screenUv - vec2(0.5, 0.9)) * 9.0) * 0.5 + 0.5;
wave *= 0.28;
float boost = max(trail * uTrailStrength, wave);
float d = sdfCircle(cellUv, uDotSize * (1.0 + boost * 0.9));
float dot0 = smoothstep(0.02, -0.01, d); // 안티에일리어싱된 원
// 아래로 갈수록 옅어지는 그라디언트 마스크
float fade = smoothstep(0.0, 0.85, 1.0 - screenUv.y);
float radial = 1.0 - smoothstep(0.15, 0.95, distance(screenUv, vec2(0.5, 0.62)));
float alpha = dot0 * uDotOpacity * (fade * 0.55 + radial * 0.65) * (1.0 + boost * 2.0);
vec3 col = mix(uBgColor, uDotColor, clamp(alpha, 0.0, 1.0));
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
**R3F 버전**: drei의 `useTrailTexture()`를 쓰면 `TrailTexture` 클래스가 불필요하다.
```tsx
import { useTrailTexture } from '@react-three/drei'
const [trail, onMove] = useTrailTexture({
size: 128, radius: 0.3, maxAge: 500, interpolate: 1,
ease: (x: number) => 1 - Math.pow(1 - x, 3),
})
// <mesh onPointerMove={onMove}> ... uTrail={trail}
```
---
## P3 — 파티클 필드 (Points + 커스텀 셰이더)
**효과**: 수만 개의 점을 3D 공간에 뿌리고, 노이즈로 흐르게 하고, 마우스로 밀어낸다. GPU 시뮬레이션 없이 **정적 attribute + 시간 함수**만으로 구현하므로 매우 견고하다.
**언제 쓰나**
- 히어로 배경의 "먼지/별/입자" 레이어
- 로고나 텍스트를 파티클로 형성했다가 흩어뜨리기 (attribute에 목표 위치 추가)
- 파티클이 5만 개 이하이고 상호작용이 단순할 때 → **P4(GPGPU)보다 이걸 써라**
**비용**: 번들 +0KB · GPU **중간** (오버드로우가 주범) · 드로우콜 1 · 3만 파티클 = position/scale/seed attribute 약 **480KB VRAM**
**조절 파라미터**
| 항목 | 범위 | 의미 |
|---|---|---|
| `COUNT` | 3,000 ~ 60,000 | 모바일 8,000 / 데스크톱 30,000 권장 |
| `uSize` | 4 ~ 60 | 기준 포인트 크기(픽셀, DPR 곱해짐) |
| `uNoiseScale` | 0.2 ~ 2.0 | 흐름의 굵기 |
| `uNoiseSpeed` | 0.02 ~ 0.3 | 흐름 속도 |
| `uPointerRadius` | 0.3 ~ 2.0 | 마우스 반발 반경(월드 단위) |
| `uPointerForce` | 0 ~ 2.0 | 반발 세기 |
> ⚠️ **`gl_PointSize`는 iOS에서 상한(보통 64~256px)이 있다.** 큰 입자가 필요하면 Points 대신 InstancedMesh 쿼드를 써야 한다.
### 코드
```js
// src/scenes/particleField.js
import * as THREE from 'three'
import Stage from '../gl/Stage.js'
import vert from '../shaders/particles.vert'
import frag from '../shaders/particles.frag'
/** 부드러운 원형 스프라이트를 코드로 생성 (텍스처 파일 불필요) */
function makeSpriteTexture(size = 64) {
const c = document.createElement('canvas')
c.width = c.height = size
const ctx = c.getContext('2d')
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2)
g.addColorStop(0.0, 'rgba(255,255,255,1)')
g.addColorStop(0.45, 'rgba(255,255,255,0.55)')
g.addColorStop(1.0, 'rgba(255,255,255,0)')
ctx.fillStyle = g
ctx.fillRect(0, 0, size, size)
const tex = new THREE.CanvasTexture(c)
tex.colorSpace = THREE.NoColorSpace // 알파 마스크. 색공간 변환 금지
return tex
}
export default function mountParticleField(canvas, opts = {}) {
const {
colorA = '#a78bfa',
colorB = '#22d3ee',
reduced = false,
} = opts
const stage = new Stage({ canvas, dprMax: 1.75, antialias: false, alpha: true })
stage.camera.fov = 45
stage.camera.position.set(0, 0, 6)
stage.camera.updateProjectionMatrix()
const isMobile = window.matchMedia('(max-width: 768px)').matches
const COUNT = isMobile ? 8000 : 30000
const positions = new Float32Array(COUNT * 3)
const seeds = new Float32Array(COUNT) // 개체별 위상차
const scales = new Float32Array(COUNT) // 개체별 크기 변주
for (let i = 0; i < COUNT; i++) {
// 원반 형태로 분포 (sqrt 를 써야 중심 쏠림이 없다)
const r = Math.sqrt(Math.random()) * 4.2
const a = Math.random() * Math.PI * 2
positions[i * 3 + 0] = Math.cos(a) * r
positions[i * 3 + 1] = (Math.random() - 0.5) * 2.4
positions[i * 3 + 2] = Math.sin(a) * r
seeds[i] = Math.random()
scales[i] = 0.35 + Math.random() * 0.9
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1))
geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1))
geometry.computeBoundingSphere()
const sprite = makeSpriteTexture(64)
const uniforms = {
uTime: { value: 0 },
uSize: { value: isMobile ? 18 : 26 },
uDpr: { value: stage.size.dpr },
uSprite: { value: sprite },
uColorA: { value: new THREE.Color(colorA) },
uColorB: { value: new THREE.Color(colorB) },
uNoiseScale: { value: 0.55 },
uNoiseSpeed: { value: 0.12 },
uPointer: { value: new THREE.Vector3() },
uPointerRadius: { value: 1.1 },
uPointerForce: { value: 0.9 },
}
const material = new THREE.ShaderMaterial({
vertexShader: vert,
fragmentShader: frag,
uniforms,
transparent: true,
depthWrite: false, // 알파 파티클은 깊이 기록 금지
depthTest: true,
blending: THREE.AdditiveBlending, // 밝은 입자. 어두운 배경 전용
})
const points = new THREE.Points(geometry, material)
stage.scene.add(points)
stage.onResize = (w, h, dpr) => { uniforms.uDpr.value = dpr }
// 마우스를 z=0 평면에 투영해서 월드 좌표로
const raycaster = new THREE.Raycaster()
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0)
const ndc = new THREE.Vector2()
const hit = new THREE.Vector3()
const targetPointer = new THREE.Vector3()
const onPointer = (e) => {
ndc.set(
(e.clientX / window.innerWidth) * 2 - 1,
-(e.clientY / window.innerHeight) * 2 + 1
)
raycaster.setFromCamera(ndc, stage.camera)
if (raycaster.ray.intersectPlane(plane, hit)) targetPointer.copy(hit)
}
if (!reduced) window.addEventListener('pointermove', onPointer, { passive: true })
stage.add((t, dt) => {
uniforms.uTime.value = t
uniforms.uPointer.value.lerp(targetPointer, 1 - Math.pow(0.005, dt))
points.rotation.y = t * 0.02
})
if (reduced) stage.renderOnce()
else stage.start()
return () => {
window.removeEventListener('pointermove', onPointer)
sprite.dispose()
stage.dispose()
}
}
```
```glsl
// src/shaders/particles.vert
#include './lib/noise.glsl'
uniform float uTime;
uniform float uSize;
uniform float uDpr;
uniform float uNoiseScale;
uniform float uNoiseSpeed;
uniform vec3 uPointer;
uniform float uPointerRadius;
uniform float uPointerForce;
attribute float aSeed;
attribute float aScale;
varying float vSeed;
varying float vDepth;
void main() {
vec3 pos = position;
float t = uTime * uNoiseSpeed;
// 3방향 노이즈로 유사 curl 흐름 (진짜 curl은 03 §4.4)
float n1 = snoise(vec3(pos * uNoiseScale + vec3(0.0, 0.0, t)));
float n2 = snoise(vec3(pos * uNoiseScale + vec3(31.4, 0.0, t)));
float n3 = snoise(vec3(pos * uNoiseScale + vec3(0.0, 17.7, t)));
pos += vec3(n1, n2, n3) * 0.42;
// 개체별 부유
pos.y += sin(uTime * 0.6 + aSeed * 6.2831) * 0.08;
// 마우스 반발
vec3 toP = pos - uPointer;
float d = length(toP);
float push = smoothstep(uPointerRadius, 0.0, d) * uPointerForce;
pos += normalize(toP + 1e-5) * push;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_Position = projectionMatrix * mv;
// 원근 감쇠: 멀수록 작게. -mv.z 는 카메라로부터의 거리
gl_PointSize = uSize * aScale * uDpr * (1.0 / max(-mv.z, 0.001));
vSeed = aSeed;
vDepth = clamp((-mv.z - 2.0) / 8.0, 0.0, 1.0);
}
```
```glsl
// src/shaders/particles.frag
precision mediump float; // 파티클은 mediump 로 충분. 모바일에서 ~2배 빠르다
uniform sampler2D uSprite;
uniform vec3 uColorA;
uniform vec3 uColorB;
varying float vSeed;
varying float vDepth;
void main() {
// gl_PointCoord: 포인트 스프라이트 내부의 0~1 좌표
float mask = texture2D(uSprite, gl_PointCoord).a;
if (mask < 0.01) discard; // 얼리 아웃으로 블렌딩 부하 절감
vec3 col = mix(uColorA, uColorB, vSeed);
float alpha = mask * mix(0.85, 0.12, vDepth); // 멀수록 흐리게 = 안개
gl_FragColor = vec4(col, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
**튜닝 노트**
- `AdditiveBlending`은 밝은 배경에서 형체가 사라진다. 밝은 테마면 `NormalBlending` + `depthWrite:false` + 정렬 문제 감수.
- 파티클이 화면을 크게 덮으면 **오버드로우**가 지배적이다. 개수보다 `uSize`를 먼저 줄여라.
- `discard`는 일부 모바일 GPU에서 early-Z를 깨뜨린다. 파티클처럼 `depthWrite:false`인 경우엔 손해가 없다.
---
## P4 — GPGPU 플로우필드 파티클 (ping-pong FBO + curl noise)
**효과**: 파티클 위치를 텍스처(RGB = XYZ)에 저장하고, 매 프레임 셰이더로 갱신한다.
파티클이 **경로를 기억**하므로 소용돌이·유체 같은 움직임이 나온다. P3로는 절대 못 만드는 룩.
**언제 쓰나**
- 10만 개 이상 파티클
- 파티클이 상태(속도, 수명)를 가져야 할 때
- 마우스가 유체를 휘젓는 인터랙션
**비용**: 번들 +0KB · GPU **높음** · 512×512 = 262,144 파티클, 렌더타깃 2장 × HalfFloat RGBA = **약 2MB VRAM** · 시뮬 패스 1 + 렌더 패스 1
**조절 파라미터**
| 항목 | 범위 | 의미 |
|---|---|---|
| `SIZE` | 64 ~ 512 | 파티클 수 = SIZE². 모바일 128(=16K), 데스크톱 256(=65K) |
| `uCurlScale` | 0.3 ~ 2.5 | 소용돌이 크기 |
| `uCurlSpeed` | 0.05 ~ 0.5 | 흐름 속도 |
| `uDamping` | 0.3 ~ 1.5 | 스텝당 이동량 배율. 클수록 빠르게 흐름 (속도 버퍼가 없는 1-버퍼 구조) |
| `uReturn` | 0.0 ~ 0.05 | 원래 위치로 되돌아가는 힘 (0이면 영원히 흩어짐) |
| `uPointerForce` | 0 ~ 3 | 마우스 밀어내기 |
### 코드
```js
// src/gl/PingPongFBO.js — 재사용 가능한 ping-pong 렌더타깃
import * as THREE from 'three'
export default class PingPongFBO {
/**
* @param {THREE.WebGLRenderer} renderer
* @param {number} size 정사각 해상도
* @param {THREE.DataTexture} initial 초기 상태 텍스처
* @param {string} fragmentShader 시뮬레이션 셰이더
* @param {object} uniforms 추가 uniform (uTexture는 자동 주입)
*/
constructor(renderer, size, initial, fragmentShader, uniforms = {}) {
this.renderer = renderer
this.size = size
const params = {
minFilter: THREE.NearestFilter,
magFilter: THREE.NearestFilter,
format: THREE.RGBAFormat,
// FloatType 은 일부 모바일에서 render target 으로 못 쓴다. HalfFloat 이 안전
type: THREE.HalfFloatType,
depthBuffer: false,
stencilBuffer: false,
generateMipmaps: false,
wrapS: THREE.ClampToEdgeWrapping,
wrapT: THREE.ClampToEdgeWrapping,
}
this.rtA = new THREE.WebGLRenderTarget(size, size, params)
this.rtB = new THREE.WebGLRenderTarget(size, size, params)
this.scene = new THREE.Scene()
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)
this.uniforms = {
uTexture: { value: initial },
uInitial: { value: initial },
uTime: { value: 0 },
uDelta: { value: 0.016 },
...uniforms,
}
this.material = new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`,
fragmentShader,
depthTest: false,
depthWrite: false,
})
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(
new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3))
geo.setAttribute('uv', new THREE.BufferAttribute(
new Float32Array([0, 0, 2, 0, 0, 2]), 2))
const quad = new THREE.Mesh(geo, this.material)
quad.frustumCulled = false
this.scene.add(quad)
// 초기 상태를 두 타깃 모두에 굽는다
this._blit(this.rtA)
this._blit(this.rtB)
this.uniforms.uTexture.value = this.rtA.texture
}
_blit(target) {
const prev = this.renderer.getRenderTarget()
this.renderer.setRenderTarget(target)
this.renderer.render(this.scene, this.camera)
this.renderer.setRenderTarget(prev)
}
/** 한 스텝 시뮬레이션. 결과 텍스처를 반환 */
update(time, delta) {
this.uniforms.uTime.value = time
this.uniforms.uDelta.value = Math.min(delta, 1 / 30)
// 읽기: rtA, 쓰기: rtB
this.uniforms.uTexture.value = this.rtA.texture
this._blit(this.rtB)
// swap
const tmp = this.rtA
this.rtA = this.rtB
this.rtB = tmp
return this.rtA.texture
}
get texture() { return this.rtA.texture }
dispose() {
this.rtA.dispose()
this.rtB.dispose()
this.material.dispose()
this.scene.traverse(o => o.geometry?.dispose())
}
}
```
```js
// src/scenes/gpgpuParticles.js
import * as THREE from 'three'
import Stage from '../gl/Stage.js'
import PingPongFBO from '../gl/PingPongFBO.js'
import simFrag from '../shaders/gpgpu.sim.frag'
import renderVert from '../shaders/gpgpu.render.vert'
import renderFrag from '../shaders/gpgpu.render.frag'
/** 초기 파티클 위치를 담은 DataTexture 생성 */
function makeInitialPositions(size) {
const data = new Float32Array(size * size * 4)
for (let i = 0; i < size * size; i++) {
// 구 표면 근처에 균등 분포
const u = Math.random()
const v = Math.random()
const theta = u * Math.PI * 2
const phi = Math.acos(2 * v - 1)
const r = 1.6 * (0.75 + Math.random() * 0.25)
data[i * 4 + 0] = r * Math.sin(phi) * Math.cos(theta)
data[i * 4 + 1] = r * Math.sin(phi) * Math.sin(theta)
data[i * 4 + 2] = r * Math.cos(phi)
data[i * 4 + 3] = Math.random() // w = 개체별 시드
}
const tex = new THREE.DataTexture(data, size, size, THREE.RGBAFormat, THREE.FloatType)
tex.needsUpdate = true
tex.minFilter = tex.magFilter = THREE.NearestFilter
tex.colorSpace = THREE.NoColorSpace
return tex
}
export default function mountGpgpuParticles(canvas, opts = {}) {
const { colorA = '#f0abfc', colorB = '#38bdf8', reduced = false } = opts
const stage = new Stage({ canvas, dprMax: 1.75, antialias: false, alpha: true })
stage.camera.fov = 40
stage.camera.position.set(0, 0, 5.5)
stage.camera.updateProjectionMatrix()
const isMobile = window.matchMedia('(max-width: 768px)').matches
const SIZE = isMobile ? 128 : 256 // 파티클 수 = SIZE²
const COUNT = SIZE * SIZE
const initial = makeInitialPositions(SIZE)
const simUniforms = {
uCurlScale: { value: 0.9 },
uCurlSpeed: { value: 0.12 },
uDamping: { value: 0.965 },
uReturn: { value: 0.012 },
uPointer: { value: new THREE.Vector3(999, 999, 999) },
uPointerForce: { value: 1.6 },
}
const sim = new PingPongFBO(stage.renderer, SIZE, initial, simFrag, simUniforms)
// 렌더용 지오메트리: 각 파티클이 "시뮬 텍스처의 어느 픽셀인가"만 알면 된다
const refs = new Float32Array(COUNT * 2)
const seeds = new Float32Array(COUNT)
for (let i = 0; i < COUNT; i++) {
refs[i * 2 + 0] = (i % SIZE) / SIZE + 0.5 / SIZE
refs[i * 2 + 1] = Math.floor(i / SIZE) / SIZE + 0.5 / SIZE
seeds[i] = Math.random()
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3))
geometry.setAttribute('aRef', new THREE.BufferAttribute(refs, 2))
geometry.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1))
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 8) // 컬링 오작동 방지
const renderUniforms = {
uPositions: { value: sim.texture },
uSize: { value: isMobile ? 12 : 16 },
uDpr: { value: stage.size.dpr },
uColorA: { value: new THREE.Color(colorA) },
uColorB: { value: new THREE.Color(colorB) },
uTime: { value: 0 },
}
const material = new THREE.ShaderMaterial({
vertexShader: renderVert,
fragmentShader: renderFrag,
uniforms: renderUniforms,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
})
const points = new THREE.Points(geometry, material)
points.frustumCulled = false
stage.scene.add(points)
stage.onResize = (w, h, dpr) => { renderUniforms.uDpr.value = dpr }
// 포인터 → 월드 (z=0 평면)
const raycaster = new THREE.Raycaster()
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0)
const ndc = new THREE.Vector2()
const hit = new THREE.Vector3()
const onPointer = (e) => {
ndc.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1)
raycaster.setFromCamera(ndc, stage.camera)
if (raycaster.ray.intersectPlane(plane, hit)) simUniforms.uPointer.value.copy(hit)
}
const onLeave = () => simUniforms.uPointer.value.set(999, 999, 999)
if (!reduced) {
window.addEventListener('pointermove', onPointer, { passive: true })
window.addEventListener('pointerleave', onLeave, { passive: true })
}
stage.add((t, dt) => {
renderUniforms.uTime.value = t
renderUniforms.uPositions.value = sim.update(t, dt)
points.rotation.y = t * 0.04
})
if (reduced) {
// reduced-motion: 시뮬을 30스텝만 돌려 정지 이미지를 만든다
for (let i = 0; i < 30; i++) sim.update(i * 0.016, 0.016)
renderUniforms.uPositions.value = sim.texture
stage.renderOnce()
} else {
stage.start()
}
return () => {
window.removeEventListener('pointermove', onPointer)
window.removeEventListener('pointerleave', onLeave)
sim.dispose()
initial.dispose()
stage.dispose()
}
}
```
```glsl
// src/shaders/gpgpu.sim.frag
precision highp float; // 위치 시뮬레이션은 반드시 highp
#include './lib/noise.glsl'
#include './lib/curl.glsl' // curlNoise(vec3) — 03 §4.4
uniform sampler2D uTexture; // 이전 프레임 위치
uniform sampler2D uInitial; // 원래 위치 (되돌아가는 힘용)
uniform float uTime;
uniform float uDelta;
uniform float uCurlScale;
uniform float uCurlSpeed;
uniform float uDamping;
uniform float uReturn;
uniform vec3 uPointer;
uniform float uPointerForce;
varying vec2 vUv;
void main() {
vec4 state = texture2D(uTexture, vUv);
vec4 home = texture2D(uInitial, vUv);
vec3 pos = state.xyz;
float seed = home.w;
// 1) curl noise 로 속도장 샘플
vec3 flow = curlNoise(pos * uCurlScale + vec3(0.0, 0.0, uTime * uCurlSpeed));
// 2) 개체별로 속도를 다르게 → 뭉치지 않는다
flow *= 0.35 + seed * 0.65;
// 3) 원래 위치로 복귀하는 스프링
vec3 back = (home.xyz - pos) * uReturn;
// 4) 마우스 반발
vec3 toP = pos - uPointer;
float d = length(toP);
vec3 push = normalize(toP + 1e-5) * smoothstep(1.4, 0.0, d) * uPointerForce;
// 이 시뮬은 위치만 저장한다(속도 버퍼 없음).
// 따라서 uDamping 은 "한 스텝에 얼마나 이동하는가"를 조절한다.
// 진짜 관성이 필요하면 두 번째 렌더타깃에 속도를 저장하는 2-버퍼 구조로 확장하라.
vec3 vel = (flow * 0.6 + back + push) * uDamping;
pos += vel * uDelta;
// 너무 멀리 나간 파티클은 리셋
if (length(pos) > 6.0) pos = home.xyz;
gl_FragColor = vec4(pos, seed);
}
```
```glsl
// src/shaders/gpgpu.render.vert
uniform sampler2D uPositions;
uniform float uSize;
uniform float uDpr;
uniform float uTime;
attribute vec2 aRef;
attribute float aSeed;
varying float vSeed;
varying float vSpeed;
varying float vDepth;
void main() {
vec4 state = texture2D(uPositions, aRef);
vec3 pos = state.xyz;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_Position = projectionMatrix * mv;
gl_PointSize = uSize * uDpr * (0.4 + aSeed * 0.8) * (1.0 / max(-mv.z, 0.001));
vSeed = aSeed;
vSpeed = clamp(length(pos) / 3.0, 0.0, 1.0);
vDepth = clamp((-mv.z - 3.0) / 6.0, 0.0, 1.0);
}
```
```glsl
// src/shaders/gpgpu.render.frag
precision mediump float;
uniform vec3 uColorA;
uniform vec3 uColorB;
varying float vSeed;
varying float vSpeed;
varying float vDepth;
void main() {
// 텍스처 없이 원형 마스크
vec2 c = gl_PointCoord - 0.5;
float d = dot(c, c);
if (d > 0.25) discard;
float mask = smoothstep(0.25, 0.02, d);
vec3 col = mix(uColorA, uColorB, vSeed * 0.6 + vSpeed * 0.4);
float alpha = mask * mix(0.9, 0.15, vDepth);
gl_FragColor = vec4(col, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
**함정 정리**
- 렌더타깃 텍스처는 `NearestFilter`여야 한다. Linear면 이웃 파티클 상태가 섞여 뭉개진다.
- `HalfFloatType`은 정밀도가 약 3자리다. 좌표 범위를 ±10 이내로 유지해라. 넘으면 지터가 생긴다.
- `Points``frustumCulled = false` 또는 큰 `boundingSphere`를 반드시 지정. 안 하면 시뮬로 이동한 파티클이 통째로 사라진다.
- 시뮬 패스는 **렌더 전에** 돌려야 한다. `stage.add()` 콜백 안에서 갱신하고 있으므로 순서가 맞다.
- three 내장 `GPUComputationRenderer`(`three/addons/misc/GPUComputationRenderer.js`)를 쓰면 이 클래스를 대체할 수 있다. 다변수(위치+속도) 시뮬이 필요하면 그쪽이 낫다.
---
## P5 — InstancedMesh 그리드 웨이브
**효과**: 동일한 지오메트리 수천 개를 **드로우콜 1번**에 그린다. 마우스 위치를 중심으로 파동이 퍼지며 각 인스턴스가 솟구치고 회전한다.
**언제 쓰나**
- "타일/블록/막대"의 격자 인터랙션 (Awwwards 단골)
- 데이터 시각화형 히어로
- Points로는 표현 못 하는 **입체감 있는 입자**
**비용**: 번들 +0KB · GPU **중간** · 40×40=1,600 인스턴스 × 박스(12 tri) = 19,200 tri, 드로우콜 1
**조절 파라미터**
| 항목 | 범위 | 의미 |
|---|---|---|
| `GRID` | 20 ~ 80 | 한 변 개수 (총 GRID²) |
| `uWaveSpeed` | 0.5 ~ 4 | 파동 진행 속도 |
| `uWaveFreq` | 1 ~ 12 | 파동 밀도 |
| `uLift` | 0.1 ~ 2 | 마우스 근처 솟구침 높이 |
| `uFalloff` | 0.5 ~ 4 | 마우스 영향 반경 감쇠 |
### 코드
```js
// src/scenes/instancedGrid.js
import * as THREE from 'three'
import Stage from '../gl/Stage.js'
export default function mountInstancedGrid(canvas, opts = {}) {
const {
gridSize = 44,
spacing = 0.28,
colorLow = '#1e1b4b',
colorHigh = '#a78bfa',
reduced = false,
} = opts
const stage = new Stage({ canvas, dprMax: 1.75, antialias: true, alpha: false })
stage.camera.fov = 35
stage.camera.position.set(0, 7.5, 8.5)
stage.camera.lookAt(0, 0, 0)
stage.camera.updateProjectionMatrix()
stage.scene.add(new THREE.AmbientLight(0xffffff, 1.2))
const dir = new THREE.DirectionalLight(0xffffff, 2.4)
dir.position.set(4, 8, 5)
stage.scene.add(dir)
const isMobile = window.matchMedia('(max-width: 768px)').matches
const GRID = isMobile ? Math.round(gridSize * 0.6) : gridSize
const COUNT = GRID * GRID
const geometry = new THREE.BoxGeometry(spacing * 0.55, 1, spacing * 0.55)
geometry.translate(0, 0.5, 0) // 피벗을 바닥으로 → scaleY 가 위로만 자란다
const material = new THREE.MeshStandardMaterial({
roughness: 0.45,
metalness: 0.1,
vertexColors: false,
})
// 인스턴스별 색상: instanceColor 는 three 가 자동으로 셰이더에 연결한다
const mesh = new THREE.InstancedMesh(geometry, material, COUNT)
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage) // 매 프레임 갱신 힌트
stage.scene.add(mesh)
const cLow = new THREE.Color(colorLow)
const cHigh = new THREE.Color(colorHigh)
const tmpColor = new THREE.Color()
mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(COUNT * 3), 3)
// 인스턴스별 정적 데이터
const basePos = new Float32Array(COUNT * 2)
const seeds = new Float32Array(COUNT)
const half = (GRID - 1) * 0.5
for (let i = 0; i < COUNT; i++) {
const x = (i % GRID) - half
const z = Math.floor(i / GRID) - half
basePos[i * 2 + 0] = x * spacing
basePos[i * 2 + 1] = z * spacing
seeds[i] = Math.random()
}
const dummy = new THREE.Object3D()
// 포인터 → 바닥 평면
const raycaster = new THREE.Raycaster()
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
const ndc = new THREE.Vector2()
const hit = new THREE.Vector3()
const pointer = new THREE.Vector2(0, 0)
const pointerTarget = new THREE.Vector2(0, 0)
const onPointer = (e) => {
ndc.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1)
raycaster.setFromCamera(ndc, stage.camera)
if (raycaster.ray.intersectPlane(groundPlane, hit)) pointerTarget.set(hit.x, hit.z)
}
if (!reduced) window.addEventListener('pointermove', onPointer, { passive: true })
const uWaveSpeed = 1.8
const uWaveFreq = 5.0
const uLift = 0.85
const uFalloff = 1.6
function updateInstances(t) {
for (let i = 0; i < COUNT; i++) {
const x = basePos[i * 2 + 0]
const z = basePos[i * 2 + 1]
const seed = seeds[i]
const r = Math.hypot(x, z)
const wave = Math.sin(r * uWaveFreq - t * uWaveSpeed) * 0.5 + 0.5
const dx = x - pointer.x
const dz = z - pointer.y
const dp = Math.hypot(dx, dz)
const influence = Math.exp(-dp * dp * uFalloff)
const h = 0.06 + wave * 0.22 + influence * uLift + seed * 0.03
dummy.position.set(x, 0, z)
dummy.scale.set(1, h, 1)
dummy.rotation.y = influence * 1.2 + seed * 0.3
dummy.updateMatrix()
mesh.setMatrixAt(i, dummy.matrix)
tmpColor.copy(cLow).lerp(cHigh, Math.min(1, h / (uLift * 0.9)))
mesh.setColorAt(i, tmpColor)
}
mesh.instanceMatrix.needsUpdate = true
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true
}
stage.add((t, dt) => {
pointer.lerp(pointerTarget, 1 - Math.pow(0.004, dt))
updateInstances(reduced ? 0 : t)
})
if (reduced) stage.renderOnce()
else stage.start()
return () => {
window.removeEventListener('pointermove', onPointer)
stage.dispose()
}
}
```
**튜닝 노트**
- CPU 루프가 병목이다. 2,000개를 넘기면 **위 계산을 vertex 셰이더로 옮겨라**(`InstancedBufferAttribute`로 basePos/seed만 넘기고 `instanceMatrix`는 고정).
- `setUsage(THREE.DynamicDrawUsage)`를 빼먹으면 드라이버가 매 프레임 버퍼를 재할당한다.
- `setColorAt()`을 쓰려면 `mesh.instanceColor`가 있어야 한다. three가 첫 `setColorAt` 호출에서 만들어주지만, 명시적으로 만드는 편이 안전하다.
- 그림자는 인스턴스 수만큼 비싸다. 랜딩페이지에서는 **끄고** `ContactShadows`나 baked AO로 대체.
---
## P6 — 스크롤 카메라 리그 (Lenis + GSAP ScrollTrigger)
**효과**: 페이지 스크롤에 카메라 위치·회전·FOV, 오브젝트 상태를 바인딩한다. 섹션마다 다른 3D 상태로 전환.
**언제 쓰나**
- 스크롤텔링 랜딩페이지 (제품을 돌려보며 설명)
- 섹션 진입 시 3D가 반응해야 할 때
- **WebGL이 페이지 전체를 지배하는 구성**
**비용**: 번들 **+45KB** (gsap core 30 + ScrollTrigger 10 + lenis 5) · GPU 낮음(연출만) · CPU: 스크롤당 uniform 업데이트
**조절 파라미터**
| 항목 | 범위 | 의미 |
|---|---|---|
| Lenis `lerp` | 0.06 ~ 0.15 | 작을수록 무겁고 느긋함 |
| Lenis `duration` | 0.8 ~ 2.0 | `wheelMultiplier`와 함께 조율 |
| ScrollTrigger `scrub` | `true` 또는 0.3~1.5 | 숫자면 관성. **0.5~1.0이 스위트스팟** |
| `end` | `'+=100%'` ~ `'+=300%'` | 핀 구간 길이 |
### 코드
```js
// src/scroll/smoothScroll.js
import Lenis from 'lenis'
import 'lenis/dist/lenis.css'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
let lenis = null
export function initSmoothScroll() {
if (lenis) return lenis
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
lenis = new Lenis({
duration: 1.15,
lerp: 0.09,
smoothWheel: true,
syncTouch: false, // 모바일은 네이티브 스크롤이 더 낫다
wheelMultiplier: 1,
autoRaf: false, // GSAP ticker 에 태울 것이므로 false
anchors: true,
respectReducedMotion: true, // reduced-motion 이면 Lenis 가 스스로 비활성화
})
// Lenis 스크롤 → ScrollTrigger 갱신
lenis.on('scroll', ScrollTrigger.update)
// 단일 RAF 루프: GSAP ticker 하나로 통일한다 (RAF 두 개 = 프레임 밀림)
gsap.ticker.add((time) => lenis.raf(time * 1000))
gsap.ticker.lagSmoothing(0)
if (reduced) lenis.stop()
return lenis
}
export function getLenis() { return lenis }
export function destroySmoothScroll() {
ScrollTrigger.getAll().forEach(st => st.kill())
lenis?.destroy()
lenis = null
}
```
```js
// src/scenes/scrollStory.js
import * as THREE from 'three'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import Stage from '../gl/Stage.js'
import { initSmoothScroll } from '../scroll/smoothScroll.js'
/**
* DOM 구조 전제:
* <div id="story">
* <section data-step="0">...</section>
* <section data-step="1">...</section>
* <section data-step="2">...</section>
* </div>
* <canvas id="gl" style="position:fixed; inset:0; z-index:-1"></canvas>
*/
export default function mountScrollStory(canvas, opts = {}) {
const { reduced = false } = opts
initSmoothScroll()
const stage = new Stage({ canvas, dprMax: 1.75, antialias: true, pauseOffscreen: false })
stage.camera.fov = 40
stage.camera.position.set(0, 0, 6)
stage.camera.updateProjectionMatrix()
stage.scene.add(new THREE.AmbientLight(0xffffff, 1.6))
const key = new THREE.DirectionalLight(0xffffff, 3)
key.position.set(3, 4, 5)
stage.scene.add(key)
const geo = new THREE.IcosahedronGeometry(1.2, 4)
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color('#c4b5fd'), roughness: 0.25, metalness: 0.6,
})
const hero = new THREE.Mesh(geo, mat)
stage.scene.add(hero)
// 스크롤로 조작할 상태 객체. GSAP 이 이 값을 트윈한다
const state = {
camX: 0, camY: 0, camZ: 6,
rotX: 0, rotY: 0,
scale: 1,
fov: 40,
hue: 0,
}
stage.add(() => {
stage.camera.position.set(state.camX, state.camY, state.camZ)
stage.camera.lookAt(0, 0, 0)
if (stage.camera.fov !== state.fov) {
stage.camera.fov = state.fov
stage.camera.updateProjectionMatrix()
}
hero.rotation.set(state.rotX, state.rotY, 0)
hero.scale.setScalar(state.scale)
mat.color.setHSL(0.72 + state.hue * 0.25, 0.6, 0.72)
})
if (reduced) {
stage.renderOnce()
return () => stage.dispose()
}
stage.start()
// --- 스크롤 타임라인 ---------------------------------------------------
const tl = gsap.timeline({
scrollTrigger: {
trigger: '#story',
start: 'top top',
end: 'bottom bottom',
scrub: 0.8, // 관성 있는 스크럽. true 보다 훨씬 부드럽다
invalidateOnRefresh: true,
},
})
tl.to(state, { camZ: 4.2, rotY: Math.PI * 0.8, scale: 1.25, hue: 0.4, ease: 'none' })
.to(state, { camX: 1.8, camY: -0.6, rotX: 0.5, fov: 55, ease: 'none' })
.to(state, { camX: 0, camY: 0, camZ: 7, rotY: Math.PI * 2, scale: 0.85, hue: 1, ease: 'none' })
// --- 섹션별 개별 트리거 (진입 시 1회 재생) ------------------------------
document.querySelectorAll('[data-step]').forEach((section) => {
ScrollTrigger.create({
trigger: section,
start: 'top 60%',
onEnter: () => section.classList.add('is-active'),
onLeaveBack: () => section.classList.remove('is-active'),
})
})
// 폰트/이미지 로드로 레이아웃이 바뀌면 반드시 refresh
window.addEventListener('load', () => ScrollTrigger.refresh())
document.fonts?.ready.then(() => ScrollTrigger.refresh())
return () => {
tl.scrollTrigger?.kill()
tl.kill()
ScrollTrigger.getAll().forEach(st => st.kill())
stage.dispose()
}
}
```
**함정 정리**
- **RAF 루프는 하나여야 한다.** Lenis `autoRaf:false` + `gsap.ticker.add()`. `renderer.setAnimationLoop`은 별개 RAF지만 three 렌더 전용이라 충돌하지 않는다. 다만 GSAP 값 갱신과 three 렌더 사이 한 프레임 지연이 생길 수 있으니, 완벽한 동기가 필요하면 three 렌더도 `gsap.ticker`로 옮겨라.
- `gsap.ticker.lagSmoothing(0)` 필수. 안 하면 무거운 프레임에서 Lenis가 튄다.
- `position:fixed` 캔버스는 `pauseOffscreen:false`로 둬야 한다. (항상 화면에 있으므로)
- ScrollTrigger는 **레이아웃 확정 후** `refresh()`해야 한다. 웹폰트·이미지 로드 후 필수.
- 카메라를 직접 트윈하지 말고 **plain object를 트윈하고 렌더 루프에서 적용**해라. GSAP이 매 프레임 `updateProjectionMatrix()`를 호출하는 사고를 막는다.
---
## P7 — R3F ScrollControls 스크롤 씬
**효과**: drei의 `ScrollControls`가 캔버스 위에 가상 스크롤 컨테이너를 만들고, `useScroll()`로 0~1 진행도를 제공한다. HTML도 `<Scroll html>`로 같이 스크롤된다.
**언제 쓰나**
- R3F 앱에서 **페이지 전체가 하나의 3D 씬**
- Lenis/ScrollTrigger를 안 쓰고 싶을 때(의존성 0)
- 3D와 HTML이 정확히 같은 스크롤을 공유해야 할 때
**비용**: 번들 +0KB(drei에 포함) · GPU 낮음
**조절 파라미터**
| prop | 기본 | 의미 |
|---|---|---|
| `pages` | 1 | 스크롤 길이 = 뷰포트 높이 × pages |
| `damping` | 0.2 | 관성(초). 0.1 = 빠릿, 0.4 = 묵직 |
| `distance` | 1 | 스크롤바 이동 배율 |
| `horizontal` | false | 가로 스크롤 |
| `infinite` | false | 무한 루프(실험적) |
`useScroll()` API: `offset`(0~1), `delta`, `range(start,len,margin?)`, `curve(start,len,margin?)`(0→1→0), `visible(start,len,margin?)`
### 코드
```tsx
// src/gl/ScrollScene.tsx
import * as THREE from 'three'
import { Canvas, useFrame } from '@react-three/fiber'
import { ScrollControls, Scroll, useScroll, Environment, Float } from '@react-three/drei'
import { useRef } from 'react'
function Shape() {
const ref = useRef<THREE.Mesh>(null!)
const scroll = useScroll()
useFrame((state, delta) => {
const m = ref.current
// range(start, length): 해당 구간에서 0→1 로 올라가는 값
const enter = scroll.range(0, 1 / 3)
const mid = scroll.range(1 / 3, 1 / 3)
const out = scroll.range(2 / 3, 1 / 3)
m.rotation.y = scroll.offset * Math.PI * 4
m.rotation.x = mid * 0.9
m.position.x = THREE.MathUtils.lerp(0, 1.6, mid) - out * 1.6
m.scale.setScalar(THREE.MathUtils.lerp(0.7, 1.35, enter) * (1 - out * 0.4))
// curve(): 구간 중앙에서 최대가 되는 0→1→0 곡선. "지나갈 때 반짝" 연출에 최적
const flash = scroll.curve(1 / 3, 1 / 3)
;(m.material as THREE.MeshStandardMaterial).emissiveIntensity = flash * 1.4
// 카메라도 스크롤에 반응
state.camera.position.z = THREE.MathUtils.lerp(6, 3.4, scroll.offset)
state.camera.updateProjectionMatrix()
})
return (
<Float speed={1.2} rotationIntensity={0.35} floatIntensity={0.5}>
<mesh ref={ref}>
<icosahedronGeometry args={[1.2, 4]} />
<meshStandardMaterial
color="#c4b5fd"
emissive="#7c3aed"
emissiveIntensity={0}
roughness={0.22}
metalness={0.65}
/>
</mesh>
</Float>
)
}
/** 스크롤에 따라 나타나는 HTML 섹션 */
function Sections() {
return (
<Scroll html style={{ width: '100%' }}>
<section style={{ height: '100vh', display: 'grid', placeItems: 'center' }}>
<h1 style={{ fontSize: 'clamp(2.5rem,8vw,6rem)', margin: 0 }}>Scroll</h1>
</section>
<section style={{ height: '100vh', display: 'grid', placeItems: 'center' }}>
<h2>중간 지점에서 발광한다</h2>
</section>
<section style={{ height: '100vh', display: 'grid', placeItems: 'center' }}>
<h2>그리고 빠져나간다</h2>
</section>
</Scroll>
)
}
export default function ScrollScene() {
return (
<Canvas
dpr={[1, 1.75]}
gl={{ antialias: true, powerPreference: 'high-performance' }}
camera={{ position: [0, 0, 6], fov: 40 }}
style={{ position: 'fixed', inset: 0 }}
>
<color attach="background" args={['#07070a']} />
<Environment preset="city" environmentIntensity={0.6} />
<ambientLight intensity={0.4} />
<ScrollControls pages={3} damping={0.22}>
<Scroll>
<Shape />
</Scroll>
<Sections />
</ScrollControls>
</Canvas>
)
}
```
**주의**
- `ScrollControls`는 **자체 스크롤 컨테이너**를 만든다. 페이지의 다른 DOM과 스크롤이 공유되지 않으므로, 일반 페이지에 부분적으로 넣으면 안 된다. "페이지 전체가 캔버스"일 때만 쓴다.
- 페이지 일부만 3D로 하고 싶다면 → **P6(Lenis + ScrollTrigger)** 또는 drei `<View>`를 써라.
- SEO: `<Scroll html>` 내부 콘텐츠는 클라이언트 렌더링이다. 중요한 텍스트는 별도 SSR DOM에 두고 캔버스는 장식으로 유지해라.
---
## P8 — DOM 동기화 이미지 플레인 + hover 왜곡 + RGB shift
**효과**: 페이지의 `<img>`를 숨기고 같은 위치·크기에 WebGL 플레인을 배치한다. hover하면 마우스 속도에 따라 휘고, R 채널이 어긋나며 색수차가 생긴다. Codrops의 고전이자 **에이전시 포트폴리오의 표준**.
**언제 쓰나**
- 이미지 그리드/갤러리/프로젝트 리스트
- "DOM 레이아웃은 그대로 두고 비주얼만 업그레이드"하고 싶을 때
- 이미지가 3~30장 정도
**비용**: 번들 +0KB · GPU **중간** (텍스처 VRAM = 이미지 해상도 × 4byte) · 드로우콜 = 이미지 수 · 이미지 1920×1080 RGBA = **8.3MB VRAM/장** → 반드시 리사이즈
**조절 파라미터**
| uniform | 범위 | 의미 |
|---|---|---|
| `uHover` | 0 ~ 1 | hover 보간값 (JS에서 lerp) |
| `uBend` | 0 ~ 0.4 | 스크롤 속도에 따른 휨 |
| `uRgbShift` | 0 ~ 0.02 | 색수차 세기. 0.008 이상은 촌스러움 |
| `uScale` | 1.0 ~ 1.12 | hover 시 텍스처 줌 |
| `uVelocity` | -1 ~ 1 | 스크롤 속도(정규화) |
### 코드
```js
// src/gl/DomGallery.js
import * as THREE from 'three'
import Stage from './Stage.js'
import vert from '../shaders/imagePlane.vert'
import frag from '../shaders/imagePlane.frag'
/**
* DOM 구조 전제:
* <figure class="tile"><img data-webgl src="/a.jpg" alt="..."></figure>
* 캔버스: position:fixed; inset:0; pointer-events:none; z-index:1
*/
export default class DomGallery {
constructor(canvas, { selector = 'img[data-webgl]', reduced = false } = {}) {
this.reduced = reduced
this.stage = new Stage({
canvas, dprMax: 1.75, antialias: false, alpha: true, pauseOffscreen: false,
})
// 직교(orthographic) 대신 원근 카메라를 CSS 픽셀에 맞춘다.
// fov 를 이렇게 잡으면 z=0 평면에서 1 unit = 1 CSS px 이 된다.
this.camera = new THREE.PerspectiveCamera(45, 1, 100, 3000)
this.camera.position.z = 800
this.stage.camera = this.camera
this.geometry = new THREE.PlaneGeometry(1, 1, 24, 24)
this.items = []
this.loader = new THREE.TextureLoader()
this.scroll = { current: 0, target: 0, velocity: 0 }
this._onResize = this._onResize.bind(this)
this._onScroll = this._onScroll.bind(this)
this.stage.onResize = this._onResize
window.addEventListener('scroll', this._onScroll, { passive: true })
this._collect(selector)
this._onResize(this.stage.size.w, this.stage.size.h, this.stage.size.dpr)
this.stage.add(this._update.bind(this))
if (reduced) this.stage.renderOnce()
else this.stage.start()
}
_collect(selector) {
const nodes = [...document.querySelectorAll(selector)]
for (const el of nodes) {
const uniforms = {
uTexture: { value: null },
uPlaneSize: { value: new THREE.Vector2(1, 1) },
uImageSize: { value: new THREE.Vector2(1, 1) },
uHover: { value: 0 },
uMouse: { value: new THREE.Vector2(0.5, 0.5) },
uVelocity: { value: 0 },
uBend: { value: 0.14 },
uRgbShift: { value: 0.006 },
uScale: { value: 1.06 },
uTime: { value: 0 },
uReveal: { value: 0 },
}
const material = new THREE.ShaderMaterial({
vertexShader: vert,
fragmentShader: frag,
uniforms,
transparent: true,
})
const mesh = new THREE.Mesh(this.geometry, material)
mesh.visible = false
this.stage.scene.add(mesh)
const item = { el, mesh, uniforms, hoverTarget: 0, revealTarget: 0, bounds: null }
this.items.push(item)
// 텍스처 로드
this.loader.load(el.currentSrc || el.src, (tex) => {
tex.colorSpace = THREE.SRGBColorSpace // 컬러 텍스처 → 반드시 지정
tex.minFilter = THREE.LinearFilter // 밉맵 없이 (메모리 33% 절약)
tex.generateMipmaps = false
tex.anisotropy = Math.min(4, this.stage.renderer.capabilities.getMaxAnisotropy())
uniforms.uTexture.value = tex
uniforms.uImageSize.value.set(tex.image.width, tex.image.height)
mesh.visible = true
el.style.opacity = '0' // DOM 이미지를 감춘다
})
// 인터랙션은 DOM 요소에 건다 (캔버스는 pointer-events:none)
el.addEventListener('pointerenter', () => { item.hoverTarget = 1 })
el.addEventListener('pointerleave', () => { item.hoverTarget = 0 })
el.addEventListener('pointermove', (e) => {
const r = el.getBoundingClientRect()
uniforms.uMouse.value.set(
(e.clientX - r.left) / r.width,
1 - (e.clientY - r.top) / r.height
)
})
// 뷰포트 진입 시 리빌
const io = new IntersectionObserver(([entry]) => {
item.revealTarget = entry.isIntersecting ? 1 : 0
}, { rootMargin: '-10% 0px' })
io.observe(el)
item.io = io
}
}
_onResize(w, h) {
// fov 를 화면 높이에 맞춰 재계산 → 1 unit = 1 px
const fov = 2 * Math.atan(h / 2 / this.camera.position.z) * (180 / Math.PI)
this.camera.fov = fov
this.camera.aspect = w / h
this.camera.updateProjectionMatrix()
this.viewport = { w, h }
for (const item of this.items) this._measure(item)
}
_measure(item) {
const r = item.el.getBoundingClientRect()
item.bounds = {
top: r.top + window.scrollY,
left: r.left + window.scrollX,
width: r.width,
height: r.height,
}
item.mesh.scale.set(r.width, r.height, 1)
item.uniforms.uPlaneSize.value.set(r.width, r.height)
}
_onScroll() { this.scroll.target = window.scrollY }
_update(t, dt) {
const k = 1 - Math.pow(0.001, dt)
const prev = this.scroll.current
this.scroll.current += (this.scroll.target - this.scroll.current) * k
this.scroll.velocity = (this.scroll.current - prev) / Math.max(dt, 1e-4)
const vNorm = THREE.MathUtils.clamp(this.scroll.velocity / 2500, -1, 1)
for (const item of this.items) {
if (!item.bounds) continue
const { mesh, uniforms, bounds } = item
// DOM 좌표 → WebGL 좌표 (원점이 화면 중앙, y 위쪽 +)
mesh.position.x = bounds.left - this.viewport.w / 2 + bounds.width / 2
mesh.position.y = -(bounds.top - this.scroll.current) + this.viewport.h / 2 - bounds.height / 2
uniforms.uTime.value = t
uniforms.uVelocity.value += (vNorm - uniforms.uVelocity.value) * k
uniforms.uHover.value += (item.hoverTarget - uniforms.uHover.value) * (1 - Math.pow(0.0005, dt))
uniforms.uReveal.value += (item.revealTarget - uniforms.uReveal.value) * (1 - Math.pow(0.01, dt))
// 화면 밖이면 그리지 않는다
const offY = Math.abs(mesh.position.y)
mesh.visible = uniforms.uTexture.value !== null &&
offY < this.viewport.h / 2 + bounds.height
}
}
/** 레이아웃이 바뀌었을 때 (폰트 로드, 필터링 등) */
refresh() {
for (const item of this.items) this._measure(item)
}
dispose() {
window.removeEventListener('scroll', this._onScroll)
for (const item of this.items) {
item.io?.disconnect()
item.el.style.opacity = ''
}
this.geometry.dispose()
this.stage.dispose()
}
}
```
```glsl
// src/shaders/imagePlane.vert
uniform float uHover;
uniform float uVelocity;
uniform float uBend;
uniform float uReveal;
uniform vec2 uMouse;
varying vec2 vUv;
varying float vWave;
const float PI = 3.141592653589793;
void main() {
vUv = uv;
vec3 pos = position;
// 1) 스크롤 속도에 따라 플레인이 활처럼 휜다
float bend = sin(uv.x * PI) * uVelocity * uBend;
pos.y += bend;
pos.z += -abs(uVelocity) * uBend * 0.6 * sin(uv.y * PI);
// 2) hover 시 마우스를 중심으로 살짝 부푼다
float d = distance(uv, uMouse);
float bulge = exp(-d * d * 8.0) * uHover * 0.09;
pos.z += bulge;
// 3) 리빌: 아래에서 올라오며 펴진다
float reveal = smoothstep(0.0, 1.0, uReveal);
pos.y += (1.0 - reveal) * -0.12;
pos.z += (1.0 - reveal) * sin(uv.x * PI) * 0.25;
vWave = bend + bulge;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
```
```glsl
// src/shaders/imagePlane.frag
precision highp float;
uniform sampler2D uTexture;
uniform vec2 uPlaneSize;
uniform vec2 uImageSize;
uniform float uHover;
uniform vec2 uMouse;
uniform float uVelocity;
uniform float uRgbShift;
uniform float uScale;
uniform float uReveal;
uniform float uTime;
varying vec2 vUv;
varying float vWave;
/** object-fit: cover 와 동일한 UV 보정 */
vec2 coverUv(vec2 uv, vec2 planeSize, vec2 imageSize) {
vec2 ratio = vec2(
min((planeSize.x / planeSize.y) / (imageSize.x / imageSize.y), 1.0),
min((planeSize.y / planeSize.x) / (imageSize.y / imageSize.x), 1.0)
);
return vec2(
uv.x * ratio.x + (1.0 - ratio.x) * 0.5,
uv.y * ratio.y + (1.0 - ratio.y) * 0.5
);
}
void main() {
vec2 uv = coverUv(vUv, uPlaneSize, uImageSize);
// hover 줌 (중심 기준)
float zoom = mix(1.0, 1.0 / uScale, uHover);
uv = (uv - 0.5) * zoom + 0.5;
// 마우스 방향으로의 미세한 밀림
vec2 dir = normalize(vUv - uMouse + 1e-5);
float dist = distance(vUv, uMouse);
uv += dir * exp(-dist * dist * 6.0) * uHover * 0.018;
// RGB shift: 속도 + hover 에 비례. 채널별로 다른 오프셋
float shift = uRgbShift * (uHover * 0.6 + abs(uVelocity) * 1.4);
vec2 offset = vec2(shift, shift * 0.35);
float r = texture2D(uTexture, uv + offset).r;
vec2 gb = texture2D(uTexture, uv).gb;
float b2 = texture2D(uTexture, uv - offset).b;
vec3 color = vec3(r, gb.x, mix(gb.y, b2, 0.7));
// 휨에 따른 명암 → 종이가 접히는 느낌
color *= 1.0 + vWave * 1.6;
// 리빌: 아래에서 위로 열리는 마스크
float reveal = smoothstep(0.0, 1.0, uReveal);
float mask = smoothstep(0.0, 0.55, reveal - (1.0 - vUv.y) * 0.55);
// 얇은 그레인
float grain = fract(sin(dot(gl_FragCoord.xy + uTime, vec2(12.9898, 78.233))) * 43758.5453);
color += (grain - 0.5) * 0.018;
gl_FragColor = vec4(color, mask);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
**함정 정리**
- 이미지 원본을 그대로 쓰면 VRAM이 폭발한다. **표시 크기 × DPR 상한**으로 서버에서 리사이즈해라. 1200px 이상은 거의 항상 낭비.
- `generateMipmaps = false` + `minFilter = LinearFilter`는 메모리 33%를 아끼지만, 이미지가 축소 표시되면 앨리어싱이 생긴다. 축소가 크면 밉맵을 켜라.
- `el.style.opacity = '0'`은 **텍스처 로드 후**에 해야 한다. 안 그러면 로딩 중 빈 화면이 보인다.
- 스크롤 위치는 `window.scrollY`가 아니라 **Lenis의 `animatedScroll`**을 써야 스무스 스크롤과 동기된다(P6와 조합 시).
- `getBoundingClientRect()`는 리플로우를 유발한다. `_measure()`는 리사이즈/리빌드 때만 호출하고 프레임마다 부르지 마라.
---
## P9 — 이미지 전환 (displacement map transition)
**효과**: 두 이미지를 흑백 디스플레이스먼트 맵으로 섞는다. 맵의 밝기에 따라 전환 시점이 달라져 "찢어지듯/녹아들듯" 넘어간다.
**언제 쓰나**
- 히어로 슬라이더, 프로젝트 썸네일 hover 전환
- Before/After
- 캐러셀
**비용**: 번들 +0KB · GPU **낮음** · 텍스처 3장(A, B, disp)
**조절 파라미터**
| uniform | 범위 | 의미 |
|---|---|---|
| `uProgress` | 0 ~ 1 | 전환 진행도 |
| `uIntensity` | 0.1 ~ 1.5 | 디스플레이스먼트 세기 |
| `uAngle` | 0 ~ 2π | 흐르는 방향 |
| disp map | — | 노이즈/스트라이프/링 등. 룩을 결정하는 가장 큰 변수 |
### 코드
```js
// src/scenes/imageTransition.js
import * as THREE from 'three'
import Stage from '../gl/Stage.js'
import gsap from 'gsap'
import vert from '../shaders/transition.vert'
import frag from '../shaders/transition.frag'
/** 디스플레이스먼트 맵을 코드로 생성 (파일 없이) */
function makeDisplacementTexture(size = 512) {
const c = document.createElement('canvas')
c.width = c.height = size
const ctx = c.getContext('2d')
// 여러 개의 부드러운 방사 그라디언트를 겹쳐 유기적 패턴을 만든다
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, size, size)
ctx.globalCompositeOperation = 'lighter'
for (let i = 0; i < 22; i++) {
const x = Math.random() * size
const y = Math.random() * size
const r = size * (0.08 + Math.random() * 0.28)
const g = ctx.createRadialGradient(x, y, 0, x, y, r)
g.addColorStop(0, 'rgba(255,255,255,0.75)')
g.addColorStop(1, 'rgba(255,255,255,0)')
ctx.fillStyle = g
ctx.beginPath()
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.fill()
}
const tex = new THREE.CanvasTexture(c)
tex.wrapS = tex.wrapT = THREE.RepeatWrapping
tex.colorSpace = THREE.NoColorSpace // 데이터 텍스처
return tex
}
export default function mountImageTransition(canvas, urls, opts = {}) {
const { intensity = 0.65, angle = Math.PI * 0.25, duration = 1.2, reduced = false } = opts
const stage = new Stage({ canvas, dprMax: 1.75, antialias: false })
stage.camera = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0, 10)
stage.camera.position.z = 1
const loader = new THREE.TextureLoader()
const textures = urls.map((u) => {
const t = loader.load(u)
t.colorSpace = THREE.SRGBColorSpace
t.minFilter = THREE.LinearFilter
t.generateMipmaps = false
return t
})
const disp = makeDisplacementTexture(512)
const uniforms = {
uTextureA: { value: textures[0] },
uTextureB: { value: textures[1 % textures.length] },
uDisp: { value: disp },
uProgress: { value: 0 },
uIntensity: { value: intensity },
uAngle: { value: angle },
uResolution:{ value: new THREE.Vector2(1, 1) },
uSizeA: { value: new THREE.Vector2(1, 1) },
uSizeB: { value: new THREE.Vector2(1, 1) },
}
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.ShaderMaterial({ vertexShader: vert, fragmentShader: frag, uniforms })
)
stage.scene.add(mesh)
stage.onResize = (w, h, dpr) => {
uniforms.uResolution.value.set(w * dpr, h * dpr)
// 오쏘 카메라를 화면 비율에 맞춘다
const aspect = w / h
stage.camera.left = -0.5 * aspect
stage.camera.right = 0.5 * aspect
stage.camera.updateProjectionMatrix()
mesh.scale.set(aspect, 1, 1)
}
stage.onResize(stage.size.w, stage.size.h, stage.size.dpr)
// 텍스처 로드가 끝나면 크기를 기록
Promise.all(urls.map(u => loader.loadAsync(u))).then((texs) => {
texs.forEach((t, i) => { t.colorSpace = THREE.SRGBColorSpace; textures[i] = t })
uniforms.uSizeA.value.set(texs[0].image.width, texs[0].image.height)
uniforms.uSizeB.value.set(texs[1 % texs.length].image.width, texs[1 % texs.length].image.height)
})
let index = 0
let animating = false
/** 다음 이미지로 전환 */
function goTo(next) {
if (animating || next === index) return
animating = true
uniforms.uTextureB.value = textures[next]
uniforms.uSizeB.value.set(textures[next].image?.width ?? 1, textures[next].image?.height ?? 1)
gsap.fromTo(uniforms.uProgress, { value: 0 }, {
value: 1,
duration: reduced ? 0 : duration,
ease: 'power2.inOut',
onComplete: () => {
uniforms.uTextureA.value = textures[next]
uniforms.uSizeA.value.copy(uniforms.uSizeB.value)
uniforms.uProgress.value = 0
index = next
animating = false
},
})
}
stage.start()
return {
next: () => goTo((index + 1) % textures.length),
goTo,
dispose() {
textures.forEach(t => t.dispose())
disp.dispose()
stage.dispose()
},
}
}
```
```glsl
// src/shaders/transition.vert
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
```
```glsl
// src/shaders/transition.frag
precision highp float;
uniform sampler2D uTextureA;
uniform sampler2D uTextureB;
uniform sampler2D uDisp;
uniform float uProgress;
uniform float uIntensity;
uniform float uAngle;
uniform vec2 uResolution;
uniform vec2 uSizeA;
uniform vec2 uSizeB;
varying vec2 vUv;
vec2 coverUv(vec2 uv, vec2 planeSize, vec2 imageSize) {
vec2 ratio = vec2(
min((planeSize.x / planeSize.y) / (imageSize.x / imageSize.y), 1.0),
min((planeSize.y / planeSize.x) / (imageSize.y / imageSize.x), 1.0)
);
return vec2(uv.x * ratio.x + (1.0 - ratio.x) * 0.5,
uv.y * ratio.y + (1.0 - ratio.y) * 0.5);
}
void main() {
vec2 dir = vec2(cos(uAngle), sin(uAngle));
float disp = texture2D(uDisp, vUv).r;
// 진행도에 따라 A는 밀려나고 B는 들어온다
float pA = uProgress;
float pB = 1.0 - uProgress;
vec2 uvA = coverUv(vUv, uResolution, uSizeA) + dir * disp * uIntensity * pA;
vec2 uvB = coverUv(vUv, uResolution, uSizeB) - dir * disp * uIntensity * pB;
vec4 a = texture2D(uTextureA, uvA);
vec4 b = texture2D(uTextureB, uvB);
// 디스플레이스먼트 값에 따라 전환 시점이 픽셀마다 다르다 → "찢어지는" 느낌
float threshold = smoothstep(disp * 0.5, disp * 0.5 + 0.5, uProgress);
vec4 color = mix(a, b, threshold);
// 전환 순간의 색수차
float edge = 1.0 - abs(uProgress * 2.0 - 1.0);
float ca = edge * 0.006;
color.r = mix(a, b, smoothstep(disp * 0.5, disp * 0.5 + 0.5, uProgress + ca)).r;
color.b = mix(a, b, smoothstep(disp * 0.5, disp * 0.5 + 0.5, uProgress - ca)).b;
gl_FragColor = color;
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
**변형 아이디어**
- `uDisp`를 스트라이프(세로줄)로 바꾸면 블라인드 전환.
- `threshold` 대신 `step(disp, uProgress)`를 쓰면 딱딱한 디졸브.
- `uAngle`을 마우스 방향으로 바꾸면 "밀어내는" 인터랙션.
---
## P10 — 유리/굴절 (MeshTransmissionMaterial + dispersion)
**효과**: 배경을 굴절시키는 유리 오브젝트. 두께·거칠기·색수차로 아크릴/크리스탈/물방울을 만든다.
**언제 쓰나**
- 히어로의 중심 오브제 (로고를 유리로)
- 유리 뒤로 텍스트가 왜곡되어 보이는 연출
- **주의: 가장 비싼 효과다.** 매 프레임 씬을 별도 버퍼로 다시 렌더한다.
**비용**: 번들 +0KB(drei) · GPU **매우높음** · `samples=6`이면 프래그먼트당 텍스처 페치 6회 + 씬 재렌더 1회 · 저사양 모바일에서는 **끄는 것을 기본으로** 하라
**조절 파라미터** (drei `MeshTransmissionMaterial`)
| prop | 기본 | 실용 범위 | 의미 |
|---|---|---|---|
| `transmission` | 1 | 0.9 ~ 1 | 투과율 |
| `thickness` | 0 | 0.2 ~ 2.5 | 굴절 깊이. 클수록 왜곡 강함 |
| `roughness` | 0 | 0 ~ 0.35 | 뒤 배경이 뿌옇게 |
| `chromaticAberration` | 0.03 | 0.02 ~ 0.2 | 색분산. 0.15 넘으면 장난감 느낌 |
| `anisotropicBlur` | 0.1 | 0 ~ 1 | 방향성 블러 |
| `distortion` | 0 | 0 ~ 0.6 | 표면 왜곡 |
| `distortionScale` | 0.5 | 0.1 ~ 1 | 왜곡 패턴 크기 |
| `temporalDistortion` | 0 | 0 ~ 0.4 | 왜곡의 시간 변화 |
| `samples` | 6 | 2 ~ 10 | **성능 직결**. 모바일 2~4 |
| `resolution` | undefined(풀스크린) | 256 ~ 1024 | 버퍼 해상도. **반드시 낮춰라** |
| `backside` | false | — | 뒷면까지 렌더(2배 비쌈) |
| `transmissionSampler` | false | — | three 내장 샘플러 공유(여러 개일 때 필수) |
| `ior` | 1.5 | 1.1 ~ 2.4 | 굴절률. 물 1.33, 유리 1.5, 다이아 2.4 |
### R3F 코드
```tsx
// src/gl/GlassHero.tsx
import * as THREE from 'three'
import { Canvas, useFrame } from '@react-three/fiber'
import {
MeshTransmissionMaterial, Environment, Text, Float, useDetectGPU,
} from '@react-three/drei'
import { useRef, useMemo } from 'react'
function GlassObject({ quality }: { quality: 'low' | 'mid' | 'high' }) {
const ref = useRef<THREE.Mesh>(null!)
const config = useMemo(() => ({
low: { samples: 2, resolution: 256, thickness: 0.6, chromaticAberration: 0.04 },
mid: { samples: 4, resolution: 512, thickness: 1.0, chromaticAberration: 0.08 },
high: { samples: 8, resolution: 1024, thickness: 1.4, chromaticAberration: 0.12 },
})[quality], [quality])
useFrame((state, delta) => {
ref.current.rotation.y += delta * 0.25
ref.current.rotation.x = Math.sin(state.clock.elapsedTime * 0.4) * 0.18
// 마우스로 살짝 기울인다
ref.current.rotation.z = THREE.MathUtils.lerp(
ref.current.rotation.z, state.pointer.x * 0.25, 1 - Math.pow(0.005, delta)
)
})
return (
<Float speed={1.4} rotationIntensity={0.2} floatIntensity={0.6}>
<mesh ref={ref}>
<torusKnotGeometry args={[1, 0.34, 180, 32]} />
<MeshTransmissionMaterial
transmission={1}
thickness={config.thickness}
roughness={0.05}
chromaticAberration={config.chromaticAberration}
anisotropicBlur={0.35}
distortion={0.25}
distortionScale={0.35}
temporalDistortion={0.12}
samples={config.samples}
resolution={config.resolution}
ior={1.5}
backside={false}
background={new THREE.Color('#07070a')}
attenuationDistance={2.4}
attenuationColor="#a78bfa"
color="#ffffff"
/>
</mesh>
</Float>
)
}
/** 유리 뒤에서 왜곡될 텍스트 */
function BackText() {
return (
<Text
position={[0, 0, -2.2]}
fontSize={1.15}
letterSpacing={-0.05}
color="#e9d5ff"
anchorX="center"
anchorY="middle"
maxWidth={7}
>
REFRACT
</Text>
)
}
export default function GlassHero() {
const gpu = useDetectGPU()
// tier: 0 미지원/블록리스트, 1 저사양, 2 중간, 3 고사양
const quality = gpu.tier >= 3 && !gpu.isMobile ? 'high' : gpu.tier >= 2 ? 'mid' : 'low'
if (gpu.tier === 0) return null // 폴백 DOM 이 그대로 보인다
return (
<Canvas
dpr={quality === 'high' ? [1, 2] : [1, 1.5]}
gl={{ antialias: quality !== 'low', powerPreference: 'high-performance' }}
camera={{ position: [0, 0, 5], fov: 40 }}
style={{ position: 'absolute', inset: 0 }}
>
<color attach="background" args={['#07070a']} />
<Environment preset="studio" environmentIntensity={0.9} />
<ambientLight intensity={0.35} />
<directionalLight position={[4, 5, 3]} intensity={2} />
<BackText />
<GlassObject quality={quality} />
</Canvas>
)
}
```
### Vanilla 대안 — 화면 굴절 셰이더
drei 없이, 이전 프레임의 화면을 굴절시키는 저렴한 방식.
```js
// src/scenes/glassVanilla.js (핵심 부분)
import * as THREE from 'three'
// 1) 배경 씬을 렌더타깃에 그린다
const rt = new THREE.WebGLRenderTarget(
Math.round(window.innerWidth * 0.5), // 절반 해상도면 충분하다
Math.round(window.innerHeight * 0.5),
{ minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, samples: 0 }
)
const glassMat = new THREE.ShaderMaterial({
uniforms: {
uScene: { value: rt.texture },
uResolution: { value: new THREE.Vector2() },
uIor: { value: 1.42 },
uDispersion: { value: 0.012 },
},
vertexShader: /* glsl */`
varying vec3 vWorldNormal;
varying vec3 vViewDir;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldNormal = normalize(mat3(modelMatrix) * normal);
vViewDir = normalize(cameraPosition - worldPos.xyz);
gl_Position = projectionMatrix * viewMatrix * worldPos;
}
`,
fragmentShader: /* glsl */`
precision highp float;
uniform sampler2D uScene;
uniform vec2 uResolution;
uniform float uIor;
uniform float uDispersion;
varying vec3 vWorldNormal;
varying vec3 vViewDir;
void main() {
vec2 screenUv = gl_FragCoord.xy / uResolution;
vec3 n = normalize(vWorldNormal);
vec3 v = normalize(vViewDir);
// 채널별로 다른 굴절률 → 분산(dispersion)
vec3 rR = refract(-v, n, 1.0 / (uIor - uDispersion));
vec3 rG = refract(-v, n, 1.0 / uIor);
vec3 rB = refract(-v, n, 1.0 / (uIor + uDispersion));
float k = 0.16; // 굴절 오프셋 세기
float r = texture2D(uScene, screenUv + rR.xy * k).r;
float g = texture2D(uScene, screenUv + rG.xy * k).g;
float b = texture2D(uScene, screenUv + rB.xy * k).b;
// 프레넬: 가장자리가 밝게
float fres = pow(1.0 - max(dot(n, v), 0.0), 3.0);
vec3 col = vec3(r, g, b) + fres * 0.55;
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
})
// 렌더 루프
function render() {
glassMesh.visible = false
renderer.setRenderTarget(rt)
renderer.render(scene, camera) // 배경만
renderer.setRenderTarget(null)
glassMesh.visible = true
glassMat.uniforms.uResolution.value.set(
renderer.domElement.width, renderer.domElement.height
)
renderer.render(scene, camera) // 전체
}
```
**성능 규칙**
- `resolution`을 절대 풀스크린으로 두지 마라. 512면 대부분 충분하다.
- 유리 오브젝트가 2개 이상이면 `transmissionSampler={true}`로 버퍼를 공유해라. 안 그러면 개수만큼 씬을 다시 그린다.
- 모바일은 `samples={2}`, `resolution={256}`, `backside={false}`로 시작해서 올려라.
- `MeshTransmissionMaterial` 뒤의 오브젝트만 굴절된다. 유리 자체는 `EffectComposer`의 depth 기반 효과와 충돌할 수 있다.
---
## P11 — 3D 텍스트 (troika) + 디스플레이스먼트 리빌
**효과**: SDF 기반 텍스트를 WebGL에 렌더하고 커스텀 셰이더로 왜곡/리빌한다. `TextGeometry`(폰트를 메쉬로 압출)와 달리 **폴리곤이 아니라 SDF**라 가볍고 선명하다.
**언제 쓰나**
- WebGL 씬 안의 대형 타이포그래피
- 글자가 파티클/노이즈로 흩어지는 연출
- 3D 공간에 배치되는 라벨
**비용**: 번들 **+55KB** (troika-three-text + troika-three-utils, 폰트 파싱은 Web Worker) · GPU 낮음 · 폰트 파일(.woff/.ttf) 별도
**조절 파라미터**
| 항목 | 범위 | 의미 |
|---|---|---|
| `fontSize` | — | 월드 단위 |
| `letterSpacing` | -0.08 ~ 0.1 | em 단위 |
| `sdfGlyphSize` | 64 / 128 / 256 | SDF 아틀라스 해상도. 큰 텍스트면 128+ |
| `outlineWidth` | 0 ~ 0.1 | 아웃라인 |
| `uProgress` | 0 ~ 1 | 리빌 진행도 |
| `uDisplace` | 0 ~ 1 | 글자 왜곡 세기 |
### 코드
```js
// src/scenes/glText.js
import * as THREE from 'three'
import { Text, preloadFont } from 'troika-three-text'
import Stage from '../gl/Stage.js'
import gsap from 'gsap'
/**
* troika 의 Text 는 내부적으로 자체 머티리얼을 쓰지만,
* `material` 에 우리 ShaderMaterial 을 넣으면 troika 가 SDF 처리를 주입해준다.
* 커스텀 셰이더는 troika 가 제공하는 `troikaSdfDerivedUniforms` 를 통해 확장된다.
* 가장 단순하고 안전한 방법은 material 을 그대로 두고 **onBeforeCompile** 로 훅킹하는 것.
*/
export default function mountGlText(canvas, opts = {}) {
const {
text = 'DESIGNPACA',
fontUrl = '/fonts/Inter-Bold.woff',
color = '#f4f4f5',
reduced = false,
} = opts
const stage = new Stage({ canvas, dprMax: 2, antialias: false, alpha: true })
stage.camera.fov = 35
stage.camera.position.set(0, 0, 5)
stage.camera.updateProjectionMatrix()
const uniforms = {
uTime: { value: 0 },
uProgress: { value: 0 },
uDisplace: { value: 0 },
uPointer: { value: new THREE.Vector2(0, 0) },
}
const material = new THREE.MeshBasicMaterial({
color: new THREE.Color(color),
transparent: true,
})
// onBeforeCompile 로 troika 가 생성한 셰이더에 우리 코드를 주입한다
material.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, uniforms)
shader.vertexShader = shader.vertexShader
.replace(
'#include <common>',
/* glsl */`
#include <common>
uniform float uTime;
uniform float uProgress;
uniform float uDisplace;
uniform vec2 uPointer;
varying vec2 vLocalPos;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
`
)
.replace(
'#include <begin_vertex>',
/* glsl */`
#include <begin_vertex>
vLocalPos = position.xy;
// 글자마다 다른 위상: x 좌표를 시드로 사용
float seed = hash(vec2(floor(position.x * 3.0), 0.0));
// 리빌: 왼쪽부터 순차적으로 올라온다
float local = clamp(uProgress * 2.2 - position.x * 0.25 - 0.2, 0.0, 1.0);
local = local * local * (3.0 - 2.0 * local); // smoothstep
transformed.y += (1.0 - local) * (-0.55 - seed * 0.4);
transformed.z += (1.0 - local) * (seed - 0.5) * 1.6;
// 마우스 근처 밀어내기
float d = distance(position.xy, uPointer * 2.0);
transformed.xy += normalize(position.xy - uPointer * 2.0 + 1e-5)
* exp(-d * d * 1.4) * uDisplace * 0.22;
// 상시 미세 부유
transformed.y += sin(uTime * 0.9 + position.x * 1.6) * 0.012;
`
)
shader.fragmentShader = shader.fragmentShader
.replace(
'#include <common>',
/* glsl */`
#include <common>
uniform float uProgress;
varying vec2 vLocalPos;
`
)
.replace(
'#include <dithering_fragment>',
/* glsl */`
#include <dithering_fragment>
float local = clamp(uProgress * 2.2 - vLocalPos.x * 0.25 - 0.2, 0.0, 1.0);
gl_FragColor.a *= smoothstep(0.0, 0.35, local);
`
)
}
material.customProgramCacheKey = () => 'glText-v1' // 셰이더 캐시 키 고정
const label = new Text()
label.text = text
label.font = fontUrl
label.fontSize = 0.62
label.letterSpacing = -0.045
label.lineHeight = 1
label.anchorX = 'center'
label.anchorY = 'middle'
label.maxWidth = 8
label.sdfGlyphSize = 128 // 큰 텍스트일수록 크게
label.material = material
label.sync() // 레이아웃 계산 (Web Worker)
stage.scene.add(label)
const target = new THREE.Vector2()
const onPointer = (e) => {
target.set(
(e.clientX / innerWidth) * 2 - 1,
-(e.clientY / innerHeight) * 2 + 1
)
}
window.addEventListener('pointermove', onPointer, { passive: true })
stage.add((t, dt) => {
uniforms.uTime.value = t
uniforms.uPointer.value.lerp(target, 1 - Math.pow(0.003, dt))
})
if (reduced) {
uniforms.uProgress.value = 1
stage.renderOnce()
} else {
stage.start()
gsap.to(uniforms.uProgress, { value: 1, duration: 1.6, ease: 'power3.out', delay: 0.2 })
gsap.to(uniforms.uDisplace, { value: 1, duration: 1.2, ease: 'power2.out', delay: 1.2 })
}
return () => {
window.removeEventListener('pointermove', onPointer)
label.dispose() // troika Text 는 자체 dispose 를 가진다
material.dispose()
stage.dispose()
}
}
// 폰트 프리로드 (첫 sync 지연 제거)
export function preload(fontUrl, sampleText) {
return new Promise((resolve) => {
preloadFont({ font: fontUrl, characters: sampleText, sdfGlyphSize: 128 }, resolve)
})
}
```
### R3F 버전 (drei `<Text>`)
```tsx
import { Text } from '@react-three/drei'
import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
function GlText() {
const matRef = useRef<THREE.MeshBasicMaterial>(null!)
const progress = useRef(0)
useFrame((_, delta) => {
progress.current = Math.min(1, progress.current + delta * 0.6)
// onBeforeCompile 로 주입한 uniform 접근
const u = (matRef.current as any).userData.uniforms
if (u) u.uProgress.value = progress.current
})
return (
<Text
font="/fonts/Inter-Bold.woff"
fontSize={0.62}
letterSpacing={-0.045}
anchorX="center"
anchorY="middle"
maxWidth={8}
sdfGlyphSize={128}
>
DESIGNPACA
<meshBasicMaterial ref={matRef} transparent color="#f4f4f5" />
</Text>
)
}
```
**주의**
- **`onBeforeCompile`을 쓸 때는 `customProgramCacheKey`를 반드시 지정**해라. 안 하면 three가 셰이더를 매번 재컴파일하거나, 반대로 다른 머티리얼의 컴파일 결과를 재사용해 버그가 난다.
- `.woff2`는 troika가 지원하지 않는다. **`.woff` 또는 `.ttf`/`.otf`**를 써라.
- `preloadFont()`로 미리 SDF 아틀라스를 구워두면 첫 렌더의 깜빡임이 사라진다.
- 텍스트가 작다면 troika 대신 **DOM 텍스트를 캔버스 위에 겹치는 게** 항상 더 선명하고 접근성이 좋다.
---
## P12 — 포스트프로세싱 스택 + 커스텀 이펙트
**효과**: 렌더 결과에 bloom / 색수차 / 그레인 / 비네트 / DOF를 얹는다. `postprocessing`(pmndrs) 라이브러리는 여러 이펙트를 **하나의 셰이더로 병합**해서 three 기본 `EffectComposer`보다 훨씬 빠르다.
**언제 쓰나**
- 발광 오브젝트/네온 (Bloom 없이는 안 산다)
- 필름 룩 (grain + vignette + subtle CA)
- 전체 톤을 통일해야 할 때
**비용**: 번들 **+25~40KB** (postprocessing, 사용 이펙트에 따라) · GPU **중간~높음** · Bloom은 다운샘플 체인 때문에 풀스크린 패스 5~7회
**조절 파라미터**
| 이펙트 | 핵심 prop | 실용 범위 |
|---|---|---|
| `Bloom` | `intensity`, `luminanceThreshold`, `luminanceSmoothing`, `mipmapBlur` | th 0.6~0.95, int 0.4~1.6, `mipmapBlur` **항상 true** |
| `ChromaticAberration` | `offset` | `[0.0005, 0.0008]` ~ `[0.003, 0.004]` |
| `Noise` | `opacity`, `premultiply` | 0.015 ~ 0.06 |
| `Vignette` | `offset`, `darkness` | offset 0.15~0.45, darkness 0.5~1.2 |
| `DepthOfField` | `focusDistance`, `focalLength`, `bokehScale` | bokehScale 1~4. **모바일에서 끄기** |
| `ToneMapping` | `mode` | `ACES_FILMIC` 또는 `AGX` |
### R3F 코드
```tsx
// src/gl/PostFX.tsx
import {
EffectComposer, Bloom, ChromaticAberration, Noise, Vignette,
DepthOfField, ToneMapping, SMAA,
} from '@react-three/postprocessing'
import { BlendFunction, KernelSize, ToneMappingMode } from 'postprocessing'
import * as THREE from 'three'
import { useMemo } from 'react'
type Quality = 'low' | 'mid' | 'high'
export default function PostFX({ quality }: { quality: Quality }) {
const caOffset = useMemo(() => new THREE.Vector2(0.0008, 0.0012), [])
if (quality === 'low') {
// 저사양: 비네트만. 풀스크린 패스 1회
return (
<EffectComposer multisampling={0} enableNormalPass={false}>
<Vignette offset={0.32} darkness={0.75} blendFunction={BlendFunction.NORMAL} />
<ToneMapping mode={ToneMappingMode.AGX} />
</EffectComposer>
)
}
return (
<EffectComposer
multisampling={quality === 'high' ? 4 : 0}
enableNormalPass={false}
depthBuffer={quality === 'high'}
frameBufferType={THREE.HalfFloatType} // HDR bloom 을 위해 필요
>
<Bloom
intensity={0.85}
luminanceThreshold={0.72}
luminanceSmoothing={0.28}
mipmapBlur // 필수. 없으면 계단현상 + 느림
kernelSize={KernelSize.LARGE}
radius={0.72}
/>
{quality === 'high' && (
<DepthOfField
focusDistance={0.012}
focalLength={0.035}
bokehScale={2.4}
height={480}
/>
)}
<ChromaticAberration
offset={caOffset}
radialModulation
modulationOffset={0.18}
blendFunction={BlendFunction.NORMAL}
/>
<Noise opacity={0.028} premultiply blendFunction={BlendFunction.SCREEN} />
<Vignette offset={0.28} darkness={0.72} />
<ToneMapping mode={ToneMappingMode.AGX} />
{quality === 'high' && <SMAA />}
</EffectComposer>
)
}
```
> `EffectComposer`를 쓰면 `Canvas`의 `antialias`가 무의미해진다(오프스크린 버퍼에 그리므로).
> 대신 `multisampling={4}` 또는 `<SMAA />`를 써라. 모바일에서는 둘 다 끄는 게 정답.
### Vanilla 코드
```js
// src/gl/composer.js
import * as THREE from 'three'
import {
EffectComposer, RenderPass, EffectPass,
BloomEffect, ChromaticAberrationEffect, NoiseEffect, VignetteEffect,
ToneMappingEffect, ToneMappingMode, BlendFunction, KernelSize,
} from 'postprocessing'
export function createComposer(renderer, scene, camera, { quality = 'mid' } = {}) {
const composer = new EffectComposer(renderer, {
frameBufferType: THREE.HalfFloatType,
multisampling: quality === 'high' ? 4 : 0,
})
composer.addPass(new RenderPass(scene, camera))
const effects = []
if (quality !== 'low') {
effects.push(new BloomEffect({
intensity: 0.85,
luminanceThreshold: 0.72,
luminanceSmoothing: 0.28,
mipmapBlur: true,
kernelSize: KernelSize.LARGE,
radius: 0.72,
}))
effects.push(new ChromaticAberrationEffect({
offset: new THREE.Vector2(0.0008, 0.0012),
radialModulation: true,
modulationOffset: 0.18,
}))
}
effects.push(new NoiseEffect({
blendFunction: BlendFunction.SCREEN, premultiply: true,
}))
effects[effects.length - 1].blendMode.opacity.value = 0.028
effects.push(new VignetteEffect({ offset: 0.28, darkness: 0.72 }))
effects.push(new ToneMappingEffect({ mode: ToneMappingMode.AGX }))
// EffectPass 하나에 몰아넣으면 셰이더가 병합되어 훨씬 빠르다
composer.addPass(new EffectPass(camera, ...effects))
return {
composer,
render(dt) { composer.render(dt) },
setSize(w, h) { composer.setSize(w, h) },
dispose() {
composer.dispose()
effects.forEach(e => e.dispose?.())
},
}
}
```
렌더 루프에서 `renderer.render()` 대신 `composer.render(delta)`를 호출한다.
```js
const fx = createComposer(stage.renderer, stage.scene, stage.camera, { quality: 'mid' })
stage.renderer.render = () => {} // Stage 의 기본 렌더를 무력화하거나
stage.add((t, dt) => { /* uniform 갱신 */ })
// 더 깔끔하게는 Stage._tick 을 오버라이드하거나 Stage 에 renderFn 훅을 추가
```
> **권장**: `Stage`에 `this.renderFn = () => this.renderer.render(this.scene, this.camera)` 필드를 추가하고
> `_tick`에서 `this.renderFn()`을 호출하도록 바꾼 뒤, 컴포저를 쓸 때 `stage.renderFn = () => fx.render(stage.delta)`로 교체해라.
### 커스텀 이펙트 만들기
`postprocessing``Effect`를 상속하면 **다른 이펙트와 하나의 셰이더로 병합**된다. 별도 패스보다 훨씬 싸다.
```glsl
/* src/shaders/effects/scanline.frag
* 사용 가능한 uniform: resolution, texelSize, cameraNear, cameraFar, aspect, time
* 사용 가능한 varying: vUv
* inputBuffer, depthBuffer(DEPTH attribute 지정 시)
*/
uniform float uIntensity;
uniform float uCount;
uniform float uSpeed;
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
float line = sin((uv.y + time * uSpeed) * uCount * 3.14159265);
float scan = 1.0 - uIntensity * (0.5 + 0.5 * line);
// 가장자리로 갈수록 미세하게 어긋나는 색수차
vec2 dir = uv - 0.5;
float amt = dot(dir, dir) * uIntensity * 0.02;
float r = texture2D(inputBuffer, uv + dir * amt).r;
float b = texture2D(inputBuffer, uv - dir * amt).b;
outputColor = vec4(vec3(r, inputColor.g, b) * scan, inputColor.a);
}
```
```js
// src/gl/effects/ScanlineEffect.js
import { Effect, BlendFunction } from 'postprocessing'
import { Uniform } from 'three'
import fragmentShader from '../../shaders/effects/scanline.frag'
export default class ScanlineEffect extends Effect {
constructor({ intensity = 0.12, count = 420, speed = 0.06 } = {}) {
super('ScanlineEffect', fragmentShader, {
blendFunction: BlendFunction.NORMAL,
uniforms: new Map([
['uIntensity', new Uniform(intensity)],
['uCount', new Uniform(count)],
['uSpeed', new Uniform(speed)],
]),
})
}
get intensity() { return this.uniforms.get('uIntensity').value }
set intensity(v) { this.uniforms.get('uIntensity').value = v }
/** 매 프레임 호출됨 (선택적 오버라이드) */
update(renderer, inputBuffer, deltaTime) {
// 필요하면 여기서 uniform 갱신
}
}
```
R3F에서 쓰려면 `wrapEffect`로 감싼다.
```tsx
import { wrapEffect } from '@react-three/postprocessing'
import ScanlineEffect from './effects/ScanlineEffect'
const Scanline = wrapEffect(ScanlineEffect)
// <EffectComposer><Scanline intensity={0.12} /></EffectComposer>
```
**함정 정리**
- **Bloom에는 `frameBufferType: HalfFloatType`이 필요**하다. 안 하면 1.0에서 클리핑돼서 발광이 안 산다.
- 이펙트를 여러 `EffectPass`에 나눠 담으면 패스 수만큼 풀스크린 렌더가 늘어난다. **하나의 `EffectPass`에 몰아라.**
- `DepthOfField``depthBuffer: true`가 필요하고, 모바일에서 프레임을 반으로 깎는다.
- 톤매핑은 **체인의 마지막**에. `renderer.toneMapping`은 컴포저를 쓰면 화면 렌더에만 적용되므로 `ToneMappingEffect`로 옮겨야 한다.
- `postprocessing`은 three `>= 0.168 < 0.186`만 지원한다(2026-08 기준).
---
## P13 — GLTF 제품 씬 (DRACO/KTX2 + 환경광 + 스크롤 회전)
**효과**: 압축된 3D 모델을 로드하고, HDRI 환경광으로 사실적으로 조명하고, 스크롤에 따라 회전시킨다.
**언제 쓰나**
- 제품 페이지 (신발/시계/디바이스)
- 컨피규레이터
- 브랜드 마스코트
**비용**: 번들 **+15KB**(로더) + 디코더 WASM(**DRACO 약 200KB**, **basis 약 250KB**, 둘 다 캐시됨) · 모델 크기에 따라 GPU 가변 · HDRI 1K = **약 2MB**
**모델 최적화 파이프라인** (`gltf-transform` CLI)
```bash
npm i -g @gltf-transform/cli
# 1) 기본 최적화: 중복 제거, 미사용 데이터 제거, 인스턴싱
gltf-transform optimize input.glb output.glb \
--compress draco \
--texture-compress webp \
--texture-size 1024
# 2) 세밀하게 제어하려면 단계별로
gltf-transform prune in.glb s1.glb # 미사용 노드/머티리얼 제거
gltf-transform dedup s1.glb s2.glb # 중복 accessor/텍스처 병합
gltf-transform weld s2.glb s3.glb # 정점 병합
gltf-transform simplify s3.glb s4.glb --ratio 0.6 --error 0.001
gltf-transform resize s4.glb s5.glb --width 1024 --height 1024
gltf-transform uastc s5.glb s6.glb --level 4 --rdo 4 --zstd true # KTX2(고품질)
# 또는 etc1s (더 작음, 품질 낮음)
gltf-transform etc1s s5.glb s6.glb --quality 200
gltf-transform meshopt s6.glb final.glb # 지오메트리 압축(DRACO 대안)
```
| 압축 | 크기 감소 | 디코드 속도 | VRAM 절감 | 비고 |
|---|---|---|---|---|
| **DRACO** (지오메트리) | 90~95% | 느림(WASM) | 없음 | 다운로드 최적화 |
| **meshopt** (지오메트리) | 80~90% + gzip | **빠름** | 없음 | DRACO보다 디코드 5~10배 빠름. **권장** |
| **KTX2/ETC1S** (텍스처) | 80~90% | 중간 | **~10배** | 노멀맵엔 부적합(아티팩트) |
| **KTX2/UASTC** (텍스처) | 50~70% | 중간 | ~4배 | 노멀맵/고품질용 |
| WebP/AVIF (텍스처) | 70~85% | 빠름 | **없음** | GPU에선 여전히 RGBA로 펼쳐짐 |
> **핵심**: WebP는 다운로드만 줄이고 **VRAM은 그대로**다. 모바일 VRAM이 문제면 **KTX2가 유일한 답**이다.
### R3F 코드
```tsx
// src/gl/ProductScene.tsx
import * as THREE from 'three'
import { Canvas, useFrame } from '@react-three/fiber'
import {
useGLTF, Environment, ContactShadows, Center, PerformanceMonitor,
Preload, AdaptiveDpr, Html, useProgress,
} from '@react-three/drei'
import { Suspense, useRef, useState, useEffect } from 'react'
import { useGSAP } from '@gsap/react' // 선택
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
// drei 의 useGLTF 는 DRACO/meshopt 를 자동 설정한다.
// 자체 호스팅 디코더를 쓰려면 두 번째 인자로 경로 지정
useGLTF.preload('/models/product.glb', '/decoders/draco/')
function Model({ scrollRef }: { scrollRef: React.MutableRefObject<number> }) {
const { scene } = useGLTF('/models/product.glb', '/decoders/draco/')
const group = useRef<THREE.Group>(null!)
// 머티리얼 일괄 조정 (모델러가 준 세팅이 웹에 안 맞는 경우가 대부분)
useEffect(() => {
scene.traverse((o) => {
if ((o as THREE.Mesh).isMesh) {
const m = o as THREE.Mesh
m.castShadow = false
m.receiveShadow = false
m.frustumCulled = true
const mat = m.material as THREE.MeshStandardMaterial
if (mat.isMeshStandardMaterial) {
mat.envMapIntensity = 1.1
// 컬러 텍스처만 sRGB. 나머지는 그대로 둔다
if (mat.map) mat.map.colorSpace = THREE.SRGBColorSpace
if (mat.emissiveMap) mat.emissiveMap.colorSpace = THREE.SRGBColorSpace
mat.needsUpdate = true
}
}
})
}, [scene])
useFrame((state, delta) => {
const t = scrollRef.current // 0~1
const g = group.current
g.rotation.y = THREE.MathUtils.lerp(g.rotation.y, t * Math.PI * 2.2, 1 - Math.pow(0.01, delta))
g.position.y = THREE.MathUtils.lerp(g.position.y, Math.sin(t * Math.PI) * 0.25, 0.08)
g.rotation.x = THREE.MathUtils.lerp(g.rotation.x, state.pointer.y * 0.12, 0.06)
})
return (
<Center>
<group ref={group}>
<primitive object={scene} />
</group>
</Center>
)
}
function Loader() {
const { progress } = useProgress()
return (
<Html center>
<div style={{ color: '#a1a1aa', fontSize: 13, fontVariantNumeric: 'tabular-nums' }}>
{progress.toFixed(0)}%
</div>
</Html>
)
}
export default function ProductScene() {
const scrollRef = useRef(0)
const [dpr, setDpr] = useState(1.5)
useGSAP(() => {
ScrollTrigger.create({
trigger: '#product-section',
start: 'top bottom',
end: 'bottom top',
scrub: true,
onUpdate: (self) => { scrollRef.current = self.progress },
})
}, [])
return (
<Canvas
dpr={dpr}
shadows={false}
gl={{ antialias: true, powerPreference: 'high-performance', alpha: true }}
camera={{ position: [0, 0.4, 4.2], fov: 35 }}
// 정적인 제품 씬이면 demand 로 바꿔 배터리를 아낀다 (스크롤 시 invalidate 필요)
frameloop="always"
>
<PerformanceMonitor
onIncline={() => setDpr(Math.min(2, window.devicePixelRatio))}
onDecline={() => setDpr(1)}
/>
<AdaptiveDpr pixelated />
<Suspense fallback={<Loader />}>
{/* files 로 자체 HDRI 를 쓰면 preset 다운로드(외부 CDN)를 피할 수 있다 */}
<Environment files="/hdri/studio_1k.hdr" environmentIntensity={0.9} />
<Model scrollRef={scrollRef} />
<ContactShadows
position={[0, -1.05, 0]}
opacity={0.55}
scale={8}
blur={2.6}
far={2.2}
resolution={512}
frames={1} // 정적 씬이면 1프레임만 굽고 멈춘다
/>
<Preload all />
</Suspense>
</Canvas>
)
}
```
**함정 정리**
- `<Environment preset="...">`**외부 CDN(github)에서 HDRI를 받는다.** 프로덕션에서는 `files="/hdri/..."`로 자체 호스팅해라.
- HDRI는 1K(2048×1024)면 충분하다. 4K는 20MB가 넘고 파싱에 수백 ms가 걸린다.
- `ContactShadows``frames={1}`은 정적 씬에서 프레임당 그림자 렌더를 없앤다. 오브젝트가 움직이면 `frames={Infinity}`(기본).
- 모델의 `castShadow`/`receiveShadow`는 랜딩페이지에서 거의 항상 끄는 게 맞다. `ContactShadows`가 훨씬 싸고 예쁘다.
- `useGLTF.preload()`는 컴포넌트 밖(모듈 최상위)에서 호출해야 효과가 있다.
---
## P14 — 마우스 인터랙션 툴킷 (raycast / lerp / trail)
**효과**: 세 가지 기본기를 한 파일에 모은 유틸. 어느 씬에나 얹을 수 있다.
1. **포인터 스무딩**: 프레임레이트 독립 lerp
2. **레이캐스팅**: 오브젝트 픽킹 + hover 상태 관리
3. **관성 추적**: 마우스 속도/방향 → 셰이더 uniform
**언제 쓰나**: 거의 모든 씬. 특히 hover 반응이 필요한 3D 오브젝트
**비용**: 번들 +0KB · CPU: 레이캐스팅은 오브젝트 수에 비례(수백 개면 `three-mesh-bvh` 고려)
### 코드
```js
// src/gl/Pointer.js
import * as THREE from 'three'
/**
* 프레임레이트 독립 지수 감쇠 lerp.
* factor: 1초에 남는 비율. 0.001 = 매우 빠름, 0.5 = 느긋함
*/
export function damp(current, target, factor, dt) {
return current + (target - current) * (1 - Math.pow(factor, dt))
}
export function dampVec(currentVec, targetVec, factor, dt) {
return currentVec.lerp(targetVec, 1 - Math.pow(factor, dt))
}
export default class Pointer {
/**
* @param {HTMLElement} domElement 이벤트를 받을 엘리먼트
* @param {THREE.Camera} camera
* @param {object} [opts]
* @param {number} [opts.smoothing=0.002] 낮을수록 빠르게 따라옴
*/
constructor(domElement, camera, { smoothing = 0.002 } = {}) {
this.dom = domElement
this.camera = camera
this.smoothing = smoothing
/** NDC (-1..1) 즉시값 */
this.ndc = new THREE.Vector2()
/** NDC 스무딩 값 */
this.smooth = new THREE.Vector2()
/** 정규화 화면 UV (0..1) */
this.uv = new THREE.Vector2(0.5, 0.5)
/** 프레임간 이동량 (NDC) */
this.velocity = new THREE.Vector2()
/** 이동 속도의 크기 (0..1 정도로 클램프) */
this.speed = 0
this.isDown = false
this.isInside = false
this.raycaster = new THREE.Raycaster()
this._prev = new THREE.Vector2()
this._plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0)
this._worldPoint = new THREE.Vector3()
this._hovered = null
this._targets = []
this._handlers = { enter: null, leave: null, click: null }
this._onMove = this._onMove.bind(this)
this._onDown = this._onDown.bind(this)
this._onUp = this._onUp.bind(this)
this._onEnter = () => { this.isInside = true }
this._onLeave = this._onLeave.bind(this)
const opt = { passive: true }
this.dom.addEventListener('pointermove', this._onMove, opt)
this.dom.addEventListener('pointerdown', this._onDown, opt)
this.dom.addEventListener('pointerup', this._onUp, opt)
this.dom.addEventListener('pointerenter', this._onEnter, opt)
this.dom.addEventListener('pointerleave', this._onLeave, opt)
}
_onMove(e) {
const r = this.dom.getBoundingClientRect()
const x = (e.clientX - r.left) / r.width
const y = (e.clientY - r.top) / r.height
this.uv.set(x, 1 - y)
this.ndc.set(x * 2 - 1, -(y * 2 - 1))
this.isInside = true
}
_onDown() { this.isDown = true }
_onUp() { this.isDown = false }
_onLeave() {
this.isInside = false
this.isDown = false
if (this._hovered) {
this._handlers.leave?.(this._hovered)
this._hovered = null
document.body.style.cursor = ''
}
}
/** 레이캐스트 대상 등록 */
setTargets(objects, { onEnter, onLeave, onClick } = {}) {
this._targets = objects
this._handlers = { enter: onEnter, leave: onLeave, click: onClick }
if (onClick) {
this._onClick = () => {
if (this._hovered) onClick(this._hovered)
}
this.dom.addEventListener('click', this._onClick)
}
}
/** 카메라 기준 임의 평면과의 교점을 월드 좌표로 반환 */
intersectPlane(normal = new THREE.Vector3(0, 0, 1), constant = 0) {
this._plane.set(normal, constant)
this.raycaster.setFromCamera(this.smooth, this.camera)
return this.raycaster.ray.intersectPlane(this._plane, this._worldPoint)
? this._worldPoint
: null
}
/** 매 프레임 호출 */
update(dt) {
this._prev.copy(this.smooth)
dampVec(this.smooth, this.ndc, this.smoothing, dt)
this.velocity.subVectors(this.smooth, this._prev).divideScalar(Math.max(dt, 1e-4))
this.speed = THREE.MathUtils.clamp(this.velocity.length() * 0.35, 0, 1)
// 레이캐스팅은 대상이 있을 때만
if (this._targets.length && this.isInside) {
this.raycaster.setFromCamera(this.ndc, this.camera)
const hits = this.raycaster.intersectObjects(this._targets, false)
const next = hits.length ? hits[0].object : null
if (next !== this._hovered) {
if (this._hovered) this._handlers.leave?.(this._hovered)
if (next) this._handlers.enter?.(next, hits[0])
this._hovered = next
document.body.style.cursor = next ? 'pointer' : ''
}
}
}
get hovered() { return this._hovered }
dispose() {
this.dom.removeEventListener('pointermove', this._onMove)
this.dom.removeEventListener('pointerdown', this._onDown)
this.dom.removeEventListener('pointerup', this._onUp)
this.dom.removeEventListener('pointerenter', this._onEnter)
this.dom.removeEventListener('pointerleave', this._onLeave)
if (this._onClick) this.dom.removeEventListener('click', this._onClick)
document.body.style.cursor = ''
}
}
```
사용 예:
```js
import Pointer from '../gl/Pointer.js'
import gsap from 'gsap'
const pointer = new Pointer(document.body, stage.camera, { smoothing: 0.0015 })
pointer.setTargets(cards, {
onEnter: (mesh) => gsap.to(mesh.scale, { x: 1.08, y: 1.08, z: 1.08, duration: 0.4, ease: 'power3.out' }),
onLeave: (mesh) => gsap.to(mesh.scale, { x: 1, y: 1, z: 1, duration: 0.5, ease: 'power3.out' }),
onClick: (mesh) => console.log('clicked', mesh.name),
})
stage.add((t, dt) => {
pointer.update(dt)
uniforms.uPointer.value.copy(pointer.smooth)
uniforms.uSpeed.value = pointer.speed
const world = pointer.intersectPlane(new THREE.Vector3(0, 0, 1), 0)
if (world) uniforms.uPointerWorld.value.copy(world)
})
```
**핵심 규칙**
- **`1 - Math.pow(factor, dt)`를 써라.** `lerp(a, b, 0.1)`은 프레임레이트에 따라 속도가 달라진다(120Hz에서 2배 빠름).
- 레이캐스팅은 `pointermove`가 아니라 **렌더 루프에서** 해라. 마우스 이벤트는 프레임보다 자주 발생한다.
- `intersectObjects(objects, false)``recursive``false`로 두면 자식 순회를 건너뛰어 훨씬 빠르다.
- 오브젝트가 100개를 넘거나 폴리곤이 많으면 `three-mesh-bvh`(`+18KB`)로 BVH 가속을 붙여라.
- 캔버스가 `pointer-events:none`이면 이벤트를 `document.body`나 상위 컨테이너에서 받아야 한다.
---
## P15 — 노이즈 디졸브 리빌/전환
**효과**: 노이즈 값과 임계값을 비교해 픽셀을 지운다. 경계에 발광선을 넣으면 "타들어가는" 룩이 된다. 리빌·페이지 전환·오브젝트 등장에 두루 쓴다.
**언제 쓰나**
- 페이지 진입 리빌
- 섹션 간 전환 (풀스크린 오버레이)
- 3D 오브젝트가 나타나고 사라짐
**비용**: 번들 +0KB · GPU **낮음** (노이즈 1회 + smoothstep 2회)
**조절 파라미터**
| uniform | 범위 | 의미 |
|---|---|---|
| `uProgress` | 0 ~ 1 | 진행도 |
| `uScale` | 1 ~ 20 | 노이즈 스케일. 클수록 잘게 부서짐 |
| `uEdgeWidth` | 0.01 ~ 0.2 | 발광 경계 두께 |
| `uEdgeColor` | — | 경계 색 (보통 브랜드 액센트) |
| `uDirection` | vec2 | 방향성. `(0,1)`이면 아래→위 |
### 3D 오브젝트용 (머티리얼 확장)
```glsl
// src/shaders/dissolve.frag — 독립 ShaderMaterial 버전
precision highp float;
#include './lib/noise.glsl'
uniform sampler2D uMap;
uniform float uProgress;
uniform float uScale;
uniform float uEdgeWidth;
uniform vec3 uEdgeColor;
uniform vec2 uDirection;
uniform float uTime;
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vec4 tex = texture2D(uMap, vUv);
// 3D 위치 기반 노이즈 → 오브젝트가 회전해도 패턴이 붙어 있다
float n = snoise(vPosition * uScale) * 0.5 + 0.5;
// 방향성 바이어스: uDirection 쪽부터 사라진다
float bias = dot(vUv - 0.5, normalize(uDirection + 1e-5)) + 0.5;
n = mix(n, bias, 0.45);
// 임계값. progress 0 → 전부 숨김, 1 → 전부 보임
float threshold = 1.0 - uProgress;
if (n < threshold) discard;
// 경계 발광
float edge = smoothstep(threshold + uEdgeWidth, threshold, n);
vec3 color = mix(tex.rgb, uEdgeColor, edge);
color += uEdgeColor * edge * 1.8; // 오버브라이트 → Bloom 이 잡아준다
gl_FragColor = vec4(color, tex.a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
```js
// src/shaders/dissolve.vert
// varying vec2 vUv; varying vec3 vPosition;
// void main() { vUv = uv; vPosition = position;
// gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
```
### 기존 `MeshStandardMaterial`에 얹기 (조명 유지)
```js
// src/gl/dissolveMaterial.js
import * as THREE from 'three'
/**
* 기존 PBR 머티리얼에 디졸브를 주입한다.
* @returns {{ material: THREE.Material, uniforms: object }}
*/
export function makeDissolve(material, {
scale = 6, edgeWidth = 0.06, edgeColor = '#a78bfa',
} = {}) {
const uniforms = {
uProgress: { value: 0 },
uScale: { value: scale },
uEdgeWidth: { value: edgeWidth },
uEdgeColor: { value: new THREE.Color(edgeColor) },
}
material.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, uniforms)
shader.vertexShader = shader.vertexShader
.replace('#include <common>', `
#include <common>
varying vec3 vDissolvePos;
`)
.replace('#include <begin_vertex>', `
#include <begin_vertex>
vDissolvePos = position;
`)
shader.fragmentShader = shader.fragmentShader
.replace('#include <common>', `
#include <common>
uniform float uProgress;
uniform float uScale;
uniform float uEdgeWidth;
uniform vec3 uEdgeColor;
varying vec3 vDissolvePos;
// 값싼 value noise (simplex 대신, PBR 셰이더가 이미 무겁다)
float hash31(vec3 p) {
p = fract(p * 0.1031);
p += dot(p, p.yzx + 33.33);
return fract((p.x + p.y) * p.z);
}
float vnoise(vec3 p) {
vec3 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(mix(hash31(i + vec3(0,0,0)), hash31(i + vec3(1,0,0)), f.x),
mix(hash31(i + vec3(0,1,0)), hash31(i + vec3(1,1,0)), f.x), f.y),
mix(mix(hash31(i + vec3(0,0,1)), hash31(i + vec3(1,0,1)), f.x),
mix(hash31(i + vec3(0,1,1)), hash31(i + vec3(1,1,1)), f.x), f.y), f.z);
}
`)
// 알파 테스트 직후에 discard 를 넣는다
.replace('#include <alphatest_fragment>', `
#include <alphatest_fragment>
float dNoise = vnoise(vDissolvePos * uScale);
float dThreshold = 1.0 - uProgress;
if (dNoise < dThreshold) discard;
`)
// 조명 계산이 끝난 뒤 경계를 더한다
.replace('#include <dithering_fragment>', `
#include <dithering_fragment>
float dNoise2 = vnoise(vDissolvePos * uScale);
float dEdge = smoothstep((1.0 - uProgress) + uEdgeWidth, 1.0 - uProgress, dNoise2);
gl_FragColor.rgb += uEdgeColor * dEdge * 2.2;
`)
}
material.customProgramCacheKey = () => 'dissolve-v1'
material.needsUpdate = true
return { material, uniforms }
}
```
사용:
```js
import gsap from 'gsap'
import { makeDissolve } from '../gl/dissolveMaterial.js'
const mat = new THREE.MeshStandardMaterial({ color: '#e4e4e7', roughness: 0.3, metalness: 0.4 })
const { uniforms } = makeDissolve(mat, { scale: 7, edgeColor: '#a78bfa' })
// 등장
gsap.to(uniforms.uProgress, { value: 1, duration: 1.8, ease: 'power2.inOut' })
// 퇴장
gsap.to(uniforms.uProgress, { value: 0, duration: 1.2, ease: 'power2.in' })
```
### 풀스크린 전환 오버레이 (페이지 전환용)
```glsl
// src/shaders/dissolveOverlay.frag
precision highp float;
uniform float uProgress;
uniform vec2 uResolution;
uniform vec3 uColor;
uniform float uScale;
uniform float uEdge;
varying vec2 vUv;
float hash21(vec2 p) {
p = fract(p * vec2(233.34, 851.73));
p += dot(p, p + 23.45);
return fract(p.x * p.y);
}
float vnoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1,0)), f.x),
mix(hash21(i + vec2(0,1)), hash21(i + vec2(1,1)), f.x), f.y);
}
float fbm(vec2 p) {
float s = 0.0, a = 0.5;
for (int i = 0; i < 4; i++) { s += vnoise(p) * a; p *= 2.03; a *= 0.5; }
return s;
}
void main() {
vec2 uv = gl_FragCoord.xy / uResolution;
float aspect = uResolution.x / uResolution.y;
vec2 p = vec2(uv.x * aspect, uv.y);
float n = fbm(p * uScale);
// 대각선 방향 바이어스
n = mix(n, (p.x / aspect + p.y) * 0.5, 0.4);
float threshold = 1.0 - uProgress * 1.25; // 1.25 로 여유를 줘야 완전히 사라진다
float alpha = smoothstep(threshold, threshold + uEdge, n);
// 경계 발광선
float line = smoothstep(threshold, threshold + uEdge * 0.35, n)
- smoothstep(threshold + uEdge * 0.35, threshold + uEdge, n);
vec3 col = uColor + line * 1.6;
gl_FragColor = vec4(col, 1.0 - alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
**튜닝 노트**
- `discard`는 early-Z를 깨서 불투명 씬에서는 비싸다. 오브젝트가 몇 개 안 되면 무시 가능.
- `uProgress`를 1.0까지만 보내면 마지막 픽셀이 남는다. **1.25배로 오버슈트**하거나 threshold 범위를 넓혀라.
- 경계 색을 1.0 이상(오버브라이트)으로 쓰면 Bloom과 결합했을 때 진짜 불처럼 보인다.
- `onBeforeCompile`에서 `<alphatest_fragment>` 훅은 조명 계산 **전**, `<dithering_fragment>`는 **후**다. discard는 앞에, 발광은 뒤에.
---
## 부록 — 패턴 조합 레시피
| 룩 | 조합 |
|---|---|
| **차분한 SaaS 히어로** | P2(도트 그리드) + P14(포인터) |
| **에이전시 포트폴리오** | P8(DOM 이미지) + P6(Lenis 스크롤) + P12(grain+vignette) |
| **제품 런칭** | P13(GLTF) + P6(스크롤 회전) + P12(bloom) + P15(디졸브 등장) |
| **뮤직/이벤트** | P4(GPGPU 파티클) + P12(bloom + CA) + P11(텍스트) |
| **브랜드 아이덴티티** | P10(유리 로고) + P1(그라디언트 배경) + P12(AGX 톤매핑) |
| **에디토리얼** | P9(이미지 전환) + P11(troika 텍스트) + P2(배경) |
각 조합의 **모바일 다운그레이드 규칙**은 [04-performance.md](./04-performance.md) §5를 따른다.