# 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 #include } ``` **튜닝 노트** - 세그먼트를 늘리는 대신 `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 #include } ``` **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), }) // ... 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 #include } ``` **튜닝 노트** - `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 #include } ``` **함정 정리** - 렌더타깃 텍스처는 `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 구조 전제: *
*
...
*
...
*
...
*
* */ 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도 ``로 같이 스크롤된다. **언제 쓰나** - 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(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 ( ) } /** 스크롤에 따라 나타나는 HTML 섹션 */ function Sections() { return (

Scroll

중간 지점에서 발광한다

그리고 빠져나간다

) } export default function ScrollScene() { return ( ) } ``` **주의** - `ScrollControls`는 **자체 스크롤 컨테이너**를 만든다. 페이지의 다른 DOM과 스크롤이 공유되지 않으므로, 일반 페이지에 부분적으로 넣으면 안 된다. "페이지 전체가 캔버스"일 때만 쓴다. - 페이지 일부만 3D로 하고 싶다면 → **P6(Lenis + ScrollTrigger)** 또는 drei ``를 써라. - SEO: `` 내부 콘텐츠는 클라이언트 렌더링이다. 중요한 텍스트는 별도 SSR DOM에 두고 캔버스는 장식으로 유지해라. --- ## P8 — DOM 동기화 이미지 플레인 + hover 왜곡 + RGB shift **효과**: 페이지의 ``를 숨기고 같은 위치·크기에 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 구조 전제: *
...
* 캔버스: 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 #include } ``` **함정 정리** - 이미지 원본을 그대로 쓰면 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 #include } ``` **변형 아이디어** - `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(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 ( ) } /** 유리 뒤에서 왜곡될 텍스트 */ function BackText() { return ( REFRACT ) } 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 ( ) } ``` ### 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 #include } `, }) // 렌더 루프 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 ', /* glsl */` #include 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 ', /* glsl */` #include 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 ', /* glsl */` #include uniform float uProgress; varying vec2 vLocalPos; ` ) .replace( '#include ', /* glsl */` #include 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 ``) ```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(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 ( DESIGNPACA ) } ``` **주의** - **`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 ( ) } return ( {quality === 'high' && ( )} {quality === 'high' && } ) } ``` > `EffectComposer`를 쓰면 `Canvas`의 `antialias`가 무의미해진다(오프스크린 버퍼에 그리므로). > 대신 `multisampling={4}` 또는 ``를 써라. 모바일에서는 둘 다 끄는 게 정답. ### 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) // ``` **함정 정리** - **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 }) { const { scene } = useGLTF('/models/product.glb', '/decoders/draco/') const group = useRef(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 (
) } function Loader() { const { progress } = useProgress() return (
{progress.toFixed(0)}%
) } 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 ( setDpr(Math.min(2, window.devicePixelRatio))} onDecline={() => setDpr(1)} /> }> {/* files 로 자체 HDRI 를 쓰면 preset 다운로드(외부 CDN)를 피할 수 있다 */} ) } ``` **함정 정리** - ``은 **외부 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 #include } ``` ```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 ', ` #include varying vec3 vDissolvePos; `) .replace('#include ', ` #include vDissolvePos = position; `) shader.fragmentShader = shader.fragmentShader .replace('#include ', ` #include 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 ', ` #include float dNoise = vnoise(vDissolvePos * uScale); float dThreshold = 1.0 - uProgress; if (dNoise < dThreshold) discard; `) // 조명 계산이 끝난 뒤 경계를 더한다 .replace('#include ', ` #include 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 #include } ``` **튜닝 노트** - `discard`는 early-Z를 깨서 불투명 씬에서는 비싸다. 오브젝트가 몇 개 안 되면 무시 가능. - `uProgress`를 1.0까지만 보내면 마지막 픽셀이 남는다. **1.25배로 오버슈트**하거나 threshold 범위를 넓혀라. - 경계 색을 1.0 이상(오버브라이트)으로 쓰면 Bloom과 결합했을 때 진짜 불처럼 보인다. - `onBeforeCompile`에서 `` 훅은 조명 계산 **전**, ``는 **후**다. 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를 따른다.