웹 디자인 파이프라인 스킬과 이를 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)
114 KiB
02 — 바로 쓸 수 있는 패턴 15개
전제: three 0.185.1,
Stage클래스는 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 | 유동 메시 그라디언트 (vertex displacement) | Vanilla | 낮음 |
| P2 | SDF 도트 그리드 배경 + 마우스 트레일 | Vanilla | 낮음 |
| P3 | 파티클 필드 (Points + 커스텀 셰이더) | Vanilla | 중간 |
| P4 | GPGPU 플로우필드 파티클 (ping-pong FBO) | Vanilla | 높음 |
| P5 | InstancedMesh 그리드 웨이브 | Vanilla | 중간 |
| P6 | 스크롤 카메라 리그 (Lenis + ScrollTrigger) | Vanilla | 낮음 |
| P7 | R3F ScrollControls 스크롤 씬 | R3F | 낮음 |
| P8 | DOM 동기화 이미지 플레인 + hover 왜곡 | Vanilla | 중간 |
| P9 | 이미지 전환 (displacement transition) | Vanilla | 낮음 |
| P10 | 유리/굴절 (Transmission + dispersion) | R3F / Vanilla | 매우높음 |
| P11 | 3D 텍스트 (troika) + 디스플레이스먼트 리빌 | Vanilla | 낮음 |
| P12 | 포스트프로세싱 스택 + 커스텀 이펙트 | 양쪽 | 중간~높음 |
| P13 | GLTF 제품 씬 (DRACO/KTX2 + 스크롤 회전) | R3F | 중간 |
| P14 | 마우스 인터랙션 툴킷 (raycast/lerp/trail) | Vanilla | 낮음 |
| 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 변환됨) |
코드
// 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()
}
}
// 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);
}
// 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 | 잔상 길이. 클수록 오래 남음 |
코드
// 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
}
}
// 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()
}
}
// 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 클래스가 불필요하다.
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 쿼드를 써야 한다.
코드
// 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()
}
}
// 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);
}
// 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 | 마우스 밀어내기 |
코드
// 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())
}
}
// 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()
}
}
// 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);
}
// 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);
}
// 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 | 마우스 영향 반경 감쇠 |
코드
// 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%' |
핀 구간 길이 |
코드
// 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
}
// 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?)
코드
// 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 | 스크롤 속도(정규화) |
코드
// 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()
}
}
// 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);
}
// 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 | — | 노이즈/스트라이프/링 등. 룩을 결정하는 가장 큰 변수 |
코드
// 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()
},
}
}
// src/shaders/transition.vert
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// 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 코드
// 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 없이, 이전 프레임의 화면을 굴절시키는 저렴한 방식.
// 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 | 글자 왜곡 세기 |
코드
// 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>)
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.6mipmapBlur 항상 true |
ChromaticAberration |
offset |
[0.0005, 0.0008] ~ [0.003, 0.004] |
Noise |
opacity, premultiply |
0.015 ~ 0.06 |
Vignette |
offset, darkness |
offset 0.15 |
DepthOfField |
focusDistance, focalLength, bokehScale |
bokehScale 1~4. 모바일에서 끄기 |
ToneMapping |
mode |
ACES_FILMIC 또는 AGX |
R3F 코드
// 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 코드
// 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)를 호출한다.
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를 상속하면 다른 이펙트와 하나의 셰이더로 병합된다. 별도 패스보다 훨씬 싸다.
/* 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);
}
// 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로 감싼다.
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)
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 코드
// 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)
효과: 세 가지 기본기를 한 파일에 모은 유틸. 어느 씬에나 얹을 수 있다.
- 포인터 스무딩: 프레임레이트 독립 lerp
- 레이캐스팅: 오브젝트 픽킹 + hover 상태 관리
- 관성 추적: 마우스 속도/방향 → 셰이더 uniform
언제 쓰나: 거의 모든 씬. 특히 hover 반응이 필요한 3D 오브젝트
비용: 번들 +0KB · CPU: 레이캐스팅은 오브젝트 수에 비례(수백 개면 three-mesh-bvh 고려)
코드
// 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 = ''
}
}
사용 예:
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 오브젝트용 (머티리얼 확장)
// 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>
}
// 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에 얹기 (조명 유지)
// 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 }
}
사용:
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' })
풀스크린 전환 오버레이 (페이지 전환용)
// 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 §5를 따른다.