designpaca/research/three/02-patterns.md
Yun Chan 8808c672dc designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포.

스킬 (packages/skill)
- SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로
  본문은 절차와 인덱스만, 지식은 references/ 로 분리
- 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기
- 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외
- 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙,
  SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침

설치 CLI (packages/cli, packages/core)
- npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md
- 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다
- 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성
- AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입
- Windsurf 는 12,000자 상한 초과 시 설치를 차단

배포 (build/ci, .forgejo/workflows)
- 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러
  → draft 릴리스 → Cloudflare Pages. 재실행 멱등

근거 (research/)
- 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다

테스트 22개 통과 (core 16 · cli 6)
2026-08-20 10:48:00 +09:00

114 KiB
Raw Blame History

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 이내로 유지해라. 넘으면 지터가 생긴다.
  • PointsfrustumCulled = 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.60.95, int 0.41.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.150.45, darkness 0.51.2
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를 쓰면 Canvasantialias가 무의미해진다(오프스크린 버퍼에 그리므로). 대신 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 훅을 추가

권장: Stagethis.renderFn = () => this.renderer.render(this.scene, this.camera) 필드를 추가하고 _tick에서 this.renderFn()을 호출하도록 바꾼 뒤, 컴포저를 쓸 때 stage.renderFn = () => fx.render(stage.delta)로 교체해라.

커스텀 이펙트 만들기

postprocessingEffect를 상속하면 다른 이펙트와 하나의 셰이더로 병합된다. 별도 패스보다 훨씬 싸다.

/* 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에 몰아라.
  • DepthOfFielddepthBuffer: 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가 걸린다.
  • ContactShadowsframes={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 고려)

코드

// 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)recursivefalse로 두면 자식 순회를 건너뛰어 훨씬 빠르다.
  • 오브젝트가 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를 따른다.