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