"스크롤할 때 이미지가 고정되어 원경처럼 보이는 게 패럴랙스의 꽃"이라는
지적이 맞았다. 느리게 같이 움직이는 것으로는 그 인상이 안 나온다 —
속도만 다를 뿐 방향이 같기 때문이다.
고정 배경
background-attachment: fixed 는 iOS Safari 가 GPU 메모리 때문에
throttle 해서 스크롤 중 배경이 튄다. 쓰지 않았다.
섹션에 clip-path: inset(0) 을 걸어 position: fixed 의 컨테이닝 블록으로
만들었다. 배경이 화면에 멈춘 채 그 섹션 안에서만 보인다.
실측: 스크롤 1000px 구간에서 viewportTop 이 계속 0.
대가 둘을 받아들였다
조상에 transform/filter/mask 를 못 쓴다 — 페이드 마스크를 포기했다
prefers-reduced-motion 에서는 absolute 로 되돌린다
전부 고정하지는 않았다. 종이는 손에 닿는 거리라 원경으로 두면 거짓말이다
재료·설치·프리플라이트 fixed 원경
사례 drift 두 결과물이 흐르는 자리
조판 drift 종이는 가깝다
게이트 씬을 파이프라인으로
히어로에서는 의미를 판독기가 자막으로 설명해야 했다. 그림이 스스로
말하지 못한다는 뜻이다. 옆에 여섯 단계가 적힌 자리로 옮기니 자막이
필요 없어졌다 — 판을 통과하면 그 단계가 켜진다.
캔버스는 sticky, 진행도는 섹션에서 읽는다.
travel 을 섹션 높이로 잡았더니 캔버스가 사라진 뒤에도 진행이 남아
6단계 중 3단계에서 멈췄다. (섹션 높이 - 뷰포트 높이)로 고쳤다.
캔버스 위 텍스트 대비 14.2:1.
스킬
motion.md 패럴랙스의 원형은 고정 · clip-path 기법과 그 대가
sticky 캔버스의 진행도 계산
three.md 씬의 의미는 옆의 콘텐츠가 반응할 때 성립한다.
자막이 필요하면 배치를 의심해라
36 KiB
three — 입체와 공간
4-3에서 읽는다. 레이아웃과 재질이 끝난 뒤에 온다. three는 세 번째 도구지 첫 번째 아이디어가 아니다.
이 문서를 읽는 법
전부 읽지 마라. 필요한 절만 열어라.
| 상황 | 읽을 곳 |
|---|---|
| three를 쓸지 아직 안 정했다 | §0만. 대부분 여기서 "안 쓴다"로 끝나고, 그게 가장 흔한 정답이다 |
| 쓰기로 했다 | §1 버전 핀 + §2 함정 + §3 코드 A(지연 로드) — 이 셋이 최소 필수 |
| 배경 이펙트가 필요하다 | + §4 코드 B (메시 그라디언트) |
| 모바일·저사양을 만난다 | + §5 코드 C (품질 티어) + §8 성능 |
| 이미지 그리드를 강화한다 | + §6 코드 D |
| 다른 패턴을 찾는다 | §7 표에서 고르고 research/three/02-patterns.md를 열어라 |
| 감사 직전 | §9 Lighthouse + §10 폴백 + §11 체크리스트 |
§0을 통과하지 못하면 나머지는 읽을 필요가 없다. "안 쓴다"는 실패가 아니라 이 스킬의 규칙이다.
0-A. 배치가 먼저다 — 이 씬은 주인공인가 재질인가
채택을 결정했다면 어디에 놓을지부터 정해라. 이 결정이 씬의 품질보다 결과를 크게 바꾼다. 그리고 배치는 하나가 아니다. 먼저 이 씬의 역할을 정해야 한다.
| 역할 | 배치 | 언제 |
|---|---|---|
| 주인공 | 액자 안. 텍스트는 액자 밖 | 3D 자체가 상품일 때 — 포트폴리오, 프로덕트 뷰어, 3D 스튜디오 |
| 재질 | 배경 전면. 텍스트가 그 위에 앉는다 | 공간감이 목적일 때 — 랜딩, 브랜드 사이트 |
둘을 섞지 마라. 재질로 쓸 씬을 액자에 가두면 "저 상자는 뭐냐"는 질문을 받는다. 액자는 안에 든 것이 볼 가치가 있다고 선언하는 장치라서, 그 안에 배경 무늬가 들어 있으면 선언과 내용이 어긋난다. 실측에서 그렇게 만들었다가 되돌렸다.
주인공일 때 — 액자 안
텍스트를 3D 위에 얹지 마라.
겹치면 두 가지가 동시에 일어난다. 3D 는 텍스트 가독성을 위해 흐리고 어둡게 눌려 얼룩이 되고, 텍스트는 대비를 확보하려고 글로우·스크림·그림자를 달게 된다. 둘 다 소리치다 둘 다 죽는다.
실측 근거 — lusion.co(3D 프로덕션 스튜디오, 2026-08 측정):
| 관측 | 값 |
|---|---|
| 캔버스 배치 | border-radius: 15px 액자 안. 페이지 배경과 명확히 분리 |
| 페이지 배경 | 흰색. 텍스트 색 2단계(검정 135회 / 흰 16회) |
| 폰트 패밀리 | 1개 |
| 강조색 | 캔버스 안에서만. DOM 에는 순청 4회뿐 |
| h1 크기 | 36px |
3D 를 가장 잘 쓰는 곳이 타이포를 가장 조용하게 쓴다. 힘이 글자가 아니라 물체에서 나오기 때문에 h1 이 36px 이어도 히어로가 압도적이다.
겹쳐야만 하는 경우(HAOQI.DESIGN 패턴)는 DOM 이 구조를 소유하고 WebGL 은 셰이더 uniform 만 만지게 한다. 기본 상태는 DOM 이 그리고, 상호작용 상태에서만 WebGL 이 나타난다. §6 이 그 구현이다.
캔버스 위에 글자를 올려야 한다면 하나만 올려라. 그리고 거기엔 바탕을 깔아라.
씬의 의미는 옆의 콘텐츠가 반응할 때 성립한다
3D 에 은유를 담았는데 그 의미를 자막으로 설명하고 있다면, 그림이 스스로 말하지 못한다는 뜻이다. 자막을 다듬을 게 아니라 배치를 의심해라.
실측 사례 — 여섯 단계 파이프라인을 유리판 여섯 장으로 표현한 씬:
| 히어로에 뒀을 때 | 파이프라인 섹션으로 옮긴 뒤 | |
|---|---|---|
| 옆에 있는 것 | 헤드라인과 CTA | 여섯 단계의 실제 이름과 설명 |
| 의미 전달 | 게이트 01 / 06 판독기 — 자막 |
판을 통과하면 그 단계가 켜진다 |
| 사용자 반응 | "왜 저 상자가 있는지 모르겠다" | — |
같은 씬인데 자리를 옮기니 자막이 필요 없어졌다. 콘텐츠가 반응하기 때문이다.
mountScene({
canvas,
progressFrom: section, // sticky 캔버스는 자기 rect 로 진행도를 못 잰다
onGate(index) { // 값이 바뀔 때만 DOM 을 쓴다
stops.forEach((el, i) => el.classList.toggle("is-active", i === index));
},
});
씬을 넣을 자리를 고를 때 "여기에 두면 무엇이 반응하는가"를 먼저 물어라. 아무것도 반응하지 않으면 그것은 재질이지 주인공이 아니다.
재질일 때 — 배경 전면
주인공이 아니라 공간을 만드는 표면이다. 규칙이 반대가 된다.
- 대비를 낮춘다. 주인공일 때의 값을 그대로 쓰면 글자 뒤에서 형광 막대처럼 튄다.
실측 교정: 테두리 광 계수
0.85 → 0.42, 알파0.72 → 0.34 - 경계를 푼다. 평면의 UV 테두리를 좁게 잡으면 사각형 프레임이 또렷하게 서서
추상 패턴이 아니라 '상자'로 읽힌다.
smoothstep(0.0, 0.05, ...)→0.17로 넓히고 제곱해 번지게 한다 - 느리게 움직인다. 스크롤 전 구간을 다 통과시키면 시선을 끌어 글자와 싸운다. 이동량을 60% 로 줄였다
- 글자 자리에 스크림을 깐다. 배경이 균일하게 밝으면 본문 대비가 무너진다. 전면을 덮지 말고 글자가 앉는 쪽만 눌러라 — 반대쪽을 열어둬야 배경이 살아 있다
- 아래를 마스크로 지운다. 섹션 경계에서 배경이 가로선으로 끊기면 두 장의 종이처럼 보인다.
mask-image: linear-gradient(to bottom, black 62%, transparent 96%) - 캔버스 크기는
ResizeObserver로 따라간다.window.resize만 들으면 폰트가 늦게 오거나 콘텐츠가 접힐 때 캔버스가 옛 크기로 남아 배경이 화면 일부만 덮는다. 그건 resize 이벤트가 아니다
0. 채택 게이트 — "쓰지 않는다"를 먼저 통과시켜라
| 원하는 것 | 먼저 시도 | three가 필요한 순간 |
|---|---|---|
| 유동적 그라디언트 배경 | radial-gradient 겹치기 + filter: blur() |
마우스/스크롤에 유기적으로 반응해야 |
| 유리·굴절 | backdrop-filter + SVG feDisplacementMap |
뒤 오브젝트가 3D로 왜곡돼야 |
| 그레인·노이즈 | SVG feTurbulence |
노이즈가 시간에 따라 흘러야 |
| 이미지 왜곡 | clip-path + transform |
픽셀 단위 변형·색수차가 필요 |
| 떠다니는 입자 | CSS 애니메이션 20개 | 1,000개 이상이거나 서로 반응해야 |
| 제품 회전 | 스프라이트 시퀀스(36장 WebP) | 사용자가 자유롭게 돌려야 |
왼쪽 칸으로 되면 왼쪽이 정답이다. 4-2에서 SVG 필터를 이미 썼다면 대부분 여기서 끝난다.
무거운 씬 임포트보다 셰이더 플레인 하나
| 방식 | 비용(gzip) | 언제 |
|---|---|---|
| 셰이더 플레인 1장 (§4) | ~95KB | 기본값. 배경·재질·분위기 |
| GLTF 모델 + 환경광 | ~180KB + 모델 | 제품이 주인공일 때만 |
| Spline / 씬 임포트 | 800KB ~ 2MB | 예산을 즉시 깬다. 사실상 금지 |
"3D를 넣자"가 아니라 "이 인상에 필요한 최소 GPU 작업은 무엇인가"를 물어라.
채택 조건 — 넷 다 만족해야 한다
- CSS/SVG로 같은 인상이 안 나온다
- 추가분이 +200KB(gzip) 이내다 (데모·포트폴리오 브리프면 +600KB, 단 올렸다고 명시)
- 폴백을 같이 만든다. 폴백 없이 채택하지 않는다
- 2단계 한 문장 컨셉을 강화한다. "있으면 멋있어서"는 이유가 아니다
1. 버전 핀 — 지금 안전한 조합
{ "three": "0.185.1", "@react-three/fiber": "^9.7.0", "@react-three/drei": "^10.7.8",
"@react-three/postprocessing": "^3.0.5", "postprocessing": "^6.39.4",
"overrides": { "three": "0.185.1" } }
| 경고 | 내용 |
|---|---|
| three 0.186 금지 | postprocessing의 peer가 three >=0.168.0 <0.186.0. 올리면 포스트프로세싱이 깨진다 → 0.185.1에 핀 |
| R3F v9 = React 19 전용 | peer react >=19 <19.3. 19.3+는 아직 지원 없음(v10 alpha) |
| three 중복 설치 금지 | 두 벌이면 instanceof 검사가 전부 깨진다. overrides로 단일화 |
| 바닐라 우선 | 페이지가 React가 아니면 R3F를 쓰지 마라. React+R3F만 +75KB다 |
| drei는 named import | 배럴 임포트는 +100KB다 |
2. 오늘 사람 잡는 함정
| 함정 | 증상 | 고치는 법 |
|---|---|---|
| sRGB 자동 지정 제거 [R3F v9] | 이미지가 어둡고 채도가 죽는다 | 컬러 텍스처에 tex.colorSpace = THREE.SRGBColorSpace. 노멀/러프니스맵엔 지정 금지 |
Clock deprecated [r183+] |
곧 제거됨 | renderer.setAnimationLoop((t) => …)의 t(ms)를 쓴다 |
RGBELoader 개명 [r180+] |
import 실패 | import { HDRLoader } from 'three/addons/loaders/HDRLoader.js' |
| ACES 톤매핑 기본값 | 플랫 그라디언트가 뿌옇게 죽는다 | 2D 셰이더면 toneMapping = THREE.NoToneMapping (R3F는 <Canvas flat>) |
| 셰이더 색이 틀림 | hex를 vec3에 하드코딩 |
셰이더 안 vec3는 linear 값이다. 색은 JS에서 THREE.Color로 넘겨라 |
| WebGL1 미지원 [r163+] | 구형 기기 백지 | webgl2 컨텍스트로 게이팅 (§3) |
drei Environment preset |
외부 CDN에서 HDRI를 받는다 | files="/hdri/studio_1k.hdr" 자체 호스팅 |
| 정면으로 선 평면의 프레넬 | 유리판이 "흐린 사각형"으로만 보이고 재질이 안 읽힌다 | 평면이 카메라를 정면으로 보면 dot(normal, view) ≈ 1 이라 프레넬이 구조적으로 0이다. 가장자리에서도 법선이 같아 변하지 않는다. 법선을 UV 로 미세하게 굽히고(normalize(n + vec3((vUv-0.5)*0.9, 0.0))), 두께는 UV 경계에서 따로 만들어라 |
gl_FragCoord 를 렌더 타겟 크기로 나눔 |
굴절 텍스처가 잘리거나 늘어난다 | gl_FragCoord 는 드로잉버퍼 좌표다. 렌더 타겟을 0.6배로 줄여 썼다면 그 크기로 나누면 UV 가 1 을 넘는다. renderer.getDrawingBufferSize() 를 uniform 으로 넘겨라 |
실측 사례: 위 두 함정을 designpaca 소개 페이지가 동시에 밟았다. 판 여섯 장을 세워놓고 "그냥 흐릿한 사각형"이라는 평가를 받았는데, 원인은 배치도 색도 아니고 유리로 읽히게 하는 신호가 계산되지 않고 있던 것이었다. 셰이더는 에러를 내지 않는다 — 조용히 아무것도 안 한다.
3. 코드 A — 지연 로드 부트스트랩 (이 문서에서 가장 중요)
언제: three를 쓰기로 한 모든 경우. 예외 없다. three를 초기 번들에서 빼는 유일한 방법이다.
// src/gl/boot.js — 계약: 모든 씬은 default export 로 mount(canvas, opts) => disposeFn 을 노출한다
export async function boot(canvas, loader) {
if (!canvas || !gate()) { canvas?.remove(); return null } // 폴백 배경이 그대로 남는다
await inViewport(canvas) // 진입 전엔 다운로드도 안 한다
const { default: mount } = await loader()
const css = getComputedStyle(document.documentElement)
const dispose = mount(canvas, { css })
const dur = css.getPropertyValue('--dur-normal').trim() || '350ms'
const ease = css.getPropertyValue('--ease-out').trim() || 'ease'
canvas.style.transition = `opacity ${dur} ${ease}`
requestAnimationFrame(() => { canvas.style.opacity = '1' })
addEventListener('pagehide', dispose, { once: true })
return dispose
}
function gate() { // WebGL2 + 사용자 선호 + 하드웨어를 한 번에 판정
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return false
if (navigator.connection?.saveData) return false
if ((navigator.deviceMemory ?? 8) < 4 || (navigator.hardwareConcurrency ?? 8) < 4) return false
try {
const gl = document.createElement('canvas').getContext('webgl2')
if (!gl) return false
gl.getExtension('WEBGL_lose_context')?.loseContext() // 프로브 컨텍스트 즉시 반납
return true
} catch { return false }
}
const inViewport = (el) => new Promise((res) => {
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); res() } },
{ rootMargin: '200px' })
io.observe(el)
})
// src/main.js — three 는 여기 어디에도 import 되지 않는다
import { boot } from './gl/boot.js'
boot(document.getElementById('gl'), () => import('./gl/scenes/hero.js'))
<div class="hero__bg" aria-hidden="true"><canvas id="gl" style="opacity:0"></canvas></div>
.hero__bg {
position: absolute; inset: 0; z-index: -1;
aspect-ratio: 16 / 9; /* CLS 차단 */
background: /* WebGL 실패 시 이것이 최종 결과물이다 */
radial-gradient(90% 70% at 20% 0%, var(--accent) 0%, transparent 60%),
linear-gradient(180deg, var(--surface-raised) 0%, var(--surface) 100%);
}
.hero__bg canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
@media (prefers-reduced-motion: reduce) { .hero__bg canvas { display: none; } }
폴백 배경은 셰이더의 정지 프레임과 같은 인상이어야 한다. 완성 후 renderer.domElement.toDataURL('image/webp', .85)로 한 프레임을 캡처해 대조해라.
공용 Stage — 모든 씬이 얹히는 최소 컨테이너
// src/gl/stage.js
import * as THREE from 'three'
export function createStage(canvas, { dprMax = 1.5, alpha = false } = {}) {
const renderer = new THREE.WebGLRenderer({ canvas, alpha, antialias: false, stencil: false,
powerPreference: 'high-performance' })
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.NoToneMapping // 플랫 2D 셰이더 기준
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); camera.position.z = 5
const size = { w: 0, h: 0, dpr: 1 }, updaters = new Set()
let running = false, last = 0, t0 = performance.now()
const draw = (t, dt) => { for (const f of updaters) f(t, dt); renderer.render(scene, stage.camera) }
const tick = (now) => { const dt = Math.min((now - last) / 1000, 1 / 20); last = now; draw((now - t0) / 1000, dt) }
const start = () => { if (!running) { running = true; last = performance.now(); renderer.setAnimationLoop(tick) } }
const stop = () => { running = false; renderer.setAnimationLoop(null) }
function resize() {
const r = canvas.getBoundingClientRect()
const w = Math.max(1, Math.round(r.width)), h = Math.max(1, Math.round(r.height))
const dpr = Math.min(devicePixelRatio || 1, dprMax)
if (w === size.w && h === size.h && dpr === size.dpr) return
Object.assign(size, { w, h, dpr })
renderer.setPixelRatio(dpr); renderer.setSize(w, h, false)
stage.camera.aspect = w / h; stage.camera.updateProjectionMatrix()
stage.onResize?.(w, h, dpr)
if (!running) draw(0, 0)
}
const ro = new ResizeObserver(resize); ro.observe(canvas)
const io = new IntersectionObserver(([e]) => e.isIntersecting ? start() : stop(), { rootMargin: '10%' })
io.observe(canvas)
const onVis = () => document.hidden ? stop() : start()
document.addEventListener('visibilitychange', onVis)
const onLost = (e) => { e.preventDefault(); stop() } // 없으면 복구 이벤트가 안 온다
canvas.addEventListener('webglcontextlost', onLost)
const stage = { renderer, scene, camera, size, start, stop, renderOnce: () => draw(0, 0),
add(f) { updaters.add(f); return () => updaters.delete(f) },
setDprMax(v) { dprMax = v; size.dpr = -1; resize() },
dispose() {
stop(); ro.disconnect(); io.disconnect()
document.removeEventListener('visibilitychange', onVis)
canvas.removeEventListener('webglcontextlost', onLost)
scene.traverse((o) => {
o.geometry?.dispose()
for (const m of [o.material].flat().filter(Boolean)) {
for (const u of Object.values(m.uniforms ?? {})) if (u.value?.isTexture) u.value.dispose()
m.dispose()
}
})
scene.clear(); renderer.dispose(); renderer.forceContextLoss(); updaters.clear()
} }
resize()
return stage
}
4. 코드 B — 메시 그라디언트 셰이더 플레인
언제: 랜딩 히어로 배경의 기본 무기. three를 쓰기로 했다면 90%는 이것으로 끝난다. 드로우콜 1개, 텍스처 0장. 색과 속도는 3단계 토큰에서 읽는다. 셰이더에 hex를 쓰지 마라 (하드 게이트 #1).
// src/gl/scenes/hero.js
import * as THREE from 'three'
import { createStage } from '../stage.js'
import { resolveQuality, watchdog } from '../quality.js'
import frag from '../shaders/hero.frag'
const VERT = /* glsl */`void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }` // 이미 클립 공간
export default function mount(canvas, { css }) {
const q = resolveQuality()
const stage = createStage(canvas, { dprMax: q.dpr })
const color = (n) => { // 하드코딩 폴백을 두지 마라
const v = css.getPropertyValue(n).trim()
if (!v) throw new Error(`designpaca: 토큰 ${n} 이 없다. 3단계로 돌아가라`)
return new THREE.Color(v) // THREE.Color 는 linear 로 저장된다
}
const durSlow = parseFloat(css.getPropertyValue('--dur-slow')) || 600 // ms
const speed = 1000 / (durSlow * 20) // 셰이더도 같은 시간 문법: 1주기 = --dur-slow × 20
// 풀스크린 트라이앵글. 플레인(2 tri)보다 싸고 대각선 이음매가 없다
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position',
new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3))
const u = {
uTime: { value: 0 }, uSpeed: { value: speed },
uResolution: { value: new THREE.Vector2(1, 1) },
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
uSurface: { value: color('--surface') },
uRaised: { value: color('--surface-raised') },
uAccent: { value: color('--accent') },
}
const mesh = new THREE.Mesh(geometry, new THREE.ShaderMaterial({
vertexShader: VERT, fragmentShader: frag, uniforms: u,
defines: { OCTAVES: q.octaves }, depthTest: false, depthWrite: false,
}))
mesh.frustumCulled = false
stage.scene.add(mesh)
stage.onResize = (w, h, dpr) => u.uResolution.value.set(w * dpr, h * dpr)
stage.onResize(stage.size.w, stage.size.h, stage.size.dpr)
const target = new THREE.Vector2(0.5, 0.5)
const onMove = (e) => target.set(e.clientX / innerWidth, 1 - e.clientY / innerHeight)
addEventListener('pointermove', onMove, { passive: true })
const guard = watchdog(q, stage)
stage.add((t, dt) => {
u.uTime.value = t
u.uPointer.value.lerp(target, 1 - Math.pow(0.002, dt)) // 프레임레이트 독립 보간
guard(dt)
})
stage.start()
return () => { removeEventListener('pointermove', onMove); stage.dispose() }
}
/* src/gl/shaders/hero.frag */
precision mediump float;
uniform float uTime, uSpeed;
uniform vec2 uResolution, uPointer;
uniform vec3 uSurface, uRaised, uAccent;
#ifndef OCTAVES
#define OCTAVES 3
#endif
float hash(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(hash(i), hash(i + vec2(1, 0)), f.x),
mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) {
float s = 0.0, a = 0.5;
for (int i = 0; i < OCTAVES; i++) { s += vnoise(p) * a; p *= 2.03; a *= 0.5; }
return s;
}
void main() {
/* 짧은 축 기준 정규화 — 안 하면 노이즈가 가로로 늘어진다 */
vec2 p = (gl_FragCoord.xy * 2.0 - uResolution) / min(uResolution.x, uResolution.y);
float t = uTime * uSpeed;
/* 도메인 워프: 노이즈로 좌표를 흔든 뒤 다시 노이즈 = "유기적"의 정체 */
float w = fbm(p * 1.1 + t);
float field = fbm(p * 1.7 + w * 0.9 + vec2(0.0, t * 1.3));
vec2 pc = (uPointer * 2.0 - 1.0) * vec2(uResolution.x / uResolution.y, 1.0);
field += exp(-dot(p - pc, p - pc) * 1.6) * 0.28; /* 포인터 근처를 부풀린다 */
vec3 col = mix(uSurface, uRaised, smoothstep(0.18, 0.62, field));
col = mix(col, uAccent, smoothstep(0.55, 0.95, field));
col *= 1.0 - dot(p, p) * 0.16; /* 비네트 */
col += (hash(gl_FragCoord.xy) - 0.5) / 255.0; /* 밴딩 제거. 사실상 필수 */
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
조절: uSpeed(0.030.15, 낮을수록 고급) · 4) · OCTAVES(2p * 1.1의 배율(작을수록 큰 덩어리) · 비네트 0.16.
#include <…>는 ShaderMaterial에서만 동작한다. RawShaderMaterial은 컴파일 에러다.
5. 코드 C — 품질 티어 + 런타임 강등
언제: three를 쓰는 모든 씬. 한 번 판정해 씬 전체 설정을 결정한다. 컴포넌트마다 각자 판단하면 조합이 폭발한다.
// src/gl/quality.js
const TIERS = {
low: { tier: 'low', dpr: 1, octaves: 2, particles: 4000, post: false, targetFps: 30 },
mid: { tier: 'mid', dpr: 1.5, octaves: 3, particles: 15000, post: 'minimal', targetFps: 60 },
high: { tier: 'high', dpr: 2, octaves: 4, particles: 40000, post: 'full', targetFps: 60 },
}
const ORDER = ['high', 'mid', 'low']
/** 동기 판정. detect-gpu(+12KB)는 GLTF 모델 씬에서만 추가로 쓴다 */
export function resolveQuality() {
const cached = sessionStorage.getItem('gl-tier')
if (cached && TIERS[cached]) return TIERS[cached]
const mobile = matchMedia('(max-width: 768px)').matches
const mem = navigator.deviceMemory ?? 8, cores = navigator.hardwareConcurrency ?? 8
const t = (mobile || mem < 6 || cores < 6) ? 'low'
: (mem >= 8 && cores >= 8) ? 'high' : 'mid'
sessionStorage.setItem('gl-tier', t)
return TIERS[t]
}
/** 4초 창에서 목표 fps의 75%를 30% 넘게 놓치면 한 단계 강등. low 에서도 실패하면 캔버스를 버린다 */
export function watchdog(quality, stage) {
let cur = quality, elapsed = 0, bad = 0, total = 0
return function update(dt) {
elapsed += dt; total++
if (1 / dt < cur.targetFps * 0.75) bad++
if (elapsed < 4) return
const failing = bad / total > 0.3
elapsed = 0; bad = 0; total = 0
if (!failing) return
const next = ORDER[ORDER.indexOf(cur.tier) + 1]
if (!next) { // 더 내릴 곳이 없다 → CSS 폴백만 남긴다
sessionStorage.setItem('gl-tier', 'low')
stage.renderer.domElement.remove(); stage.dispose(); return
}
cur = TIERS[next]
sessionStorage.setItem('gl-tier', next)
stage.setDprMax(cur.dpr) // 가장 효과가 큰 레버를 먼저 당긴다
}
}
6. 코드 D — DOM 동기화 이미지 hover 왜곡
언제: 이미지 그리드·포트폴리오. 폴백이 공짜인 유일한 패턴이다 — 실패하면 원래 <img>가 그대로 남는다.
장식을 얹는 게 아니라 이미 있는 콘텐츠를 강화하므로 "레이아웃 → 재질 → 입체" 순서와도 맞는다.
캔버스는 position: fixed; inset: 0; pointer-events: none; 이어야 한다.
// src/gl/scenes/gallery.js
import * as THREE from 'three'
import { createStage } from '../stage.js'
import { resolveQuality } from '../quality.js'
import frag from '../shaders/image.frag'
const VERT = /* glsl */`
uniform float uHover, uVelocity;
varying vec2 vUv;
void main() {
vUv = uv;
vec3 p = position;
p.y += sin(uv.x * 3.14159265) * uVelocity * 0.14; /* 스크롤 속도로 활처럼 휜다 */
p.z += sin(uv.y * 3.14159265) * uHover * 0.08;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`
export default function mount(canvas, { selector = 'img[data-gl]' } = {}) {
const q = resolveQuality()
const stage = createStage(canvas, { dprMax: q.dpr, alpha: true })
stage.camera = new THREE.PerspectiveCamera(45, 1, 100, 3000) // CSS 픽셀에 맞춘다
stage.camera.position.z = 800 // → 1 world unit = 1 px
const geometry = new THREE.PlaneGeometry(1, 1, 20, 20)
const loader = new THREE.TextureLoader()
const items = [], scroll = { cur: scrollY, vel: 0 }
for (const el of document.querySelectorAll(selector)) {
const u = { uTexture: { value: null }, uCover: { value: new THREE.Vector2(1, 1) },
uMouse: { value: new THREE.Vector2(.5, .5) },
uHover: { value: 0 }, uVelocity: { value: 0 }, uShift: { value: .006 } }
const mesh = new THREE.Mesh(geometry, new THREE.ShaderMaterial({
vertexShader: VERT, fragmentShader: frag, uniforms: u, transparent: true }))
mesh.visible = false
stage.scene.add(mesh)
const it = { el, mesh, u, hoverTarget: 0, box: null, ratio: 1 }
items.push(it)
loader.load(el.currentSrc || el.src, (tex) => {
tex.colorSpace = THREE.SRGBColorSpace // ← 빠뜨리면 이미지가 어두워진다
tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter
u.uTexture.value = tex
it.ratio = tex.image.width / tex.image.height
cover(it); mesh.visible = true
el.style.opacity = '0' // 텍스처 도착 후에만 DOM 이미지를 숨긴다
})
// 캔버스가 pointer-events:none 이므로 이벤트는 DOM 요소에서 받는다
el.addEventListener('pointerenter', () => { it.hoverTarget = 1 })
el.addEventListener('pointerleave', () => { it.hoverTarget = 0 })
el.addEventListener('pointermove', (e) => {
const r = el.getBoundingClientRect()
u.uMouse.value.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height)
})
}
const cover = (it) => { // object-fit: cover 를 JS 에서 계산 → 셰이더가 짧아진다
const pr = it.box ? it.box.w / it.box.h : 1
it.u.uCover.value.set(Math.min(pr / it.ratio, 1), Math.min(it.ratio / pr, 1))
}
const measure = () => { // 리플로우를 유발한다. 리사이즈 때만 부른다
for (const it of items) {
const r = it.el.getBoundingClientRect()
it.box = { top: r.top + scrollY, left: r.left + scrollX, w: r.width, h: r.height }
it.mesh.scale.set(r.width, r.height, 1); cover(it)
}
}
stage.onResize = (w, h) => {
stage.camera.fov = 2 * Math.atan(h / 2 / stage.camera.position.z) * (180 / Math.PI)
stage.camera.aspect = w / h; stage.camera.updateProjectionMatrix(); measure()
}
stage.onResize(stage.size.w, stage.size.h)
stage.add((t, dt) => {
// 스크롤 값은 rAF 에서 직접 읽는다. `scroll` 리스너는 하드 게이트 #10 위반이고,
// 어차피 매 프레임 필요한 값이다.
const k = 1 - Math.pow(.001, dt), hk = 1 - Math.pow(.0005, dt), prev = scroll.cur
scroll.cur += (scrollY - scroll.cur) * k
scroll.vel = THREE.MathUtils.clamp((scroll.cur - prev) / Math.max(dt, 1e-4) / 2500, -1, 1)
for (const it of items) {
if (!it.box) continue
it.mesh.position.x = it.box.left - stage.size.w / 2 + it.box.w / 2
it.mesh.position.y = -(it.box.top - scroll.cur) + stage.size.h / 2 - it.box.h / 2
it.u.uVelocity.value += (scroll.vel - it.u.uVelocity.value) * k
it.u.uHover.value += (it.hoverTarget - it.u.uHover.value) * hk
it.mesh.visible = !!it.u.uTexture.value &&
Math.abs(it.mesh.position.y) < stage.size.h / 2 + it.box.h
}
})
stage.start()
return () => {
items.forEach((it) => { it.el.style.opacity = '' })
geometry.dispose(); stage.dispose()
}
}
/* src/gl/shaders/image.frag */
precision mediump float;
uniform sampler2D uTexture;
uniform vec2 uCover, uMouse;
uniform float uHover, uVelocity, uShift;
varying vec2 vUv;
void main() {
vec2 uv = vUv * uCover + (1.0 - uCover) * 0.5; /* cover */
uv = (uv - 0.5) * mix(1.0, 0.94, uHover) + 0.5; /* hover 줌 */
vec2 dir = normalize(vUv - uMouse + 1e-5);
uv += dir * exp(-distance(vUv, uMouse) * 4.0) * uHover * 0.016; /* 마우스 방향 밀림 */
float s = uShift * (uHover * 0.6 + abs(uVelocity) * 1.4); /* 색수차 */
vec3 col = vec3(texture2D(uTexture, uv + vec2(s, s * 0.35)).r,
texture2D(uTexture, uv).g,
texture2D(uTexture, uv - vec2(s, s * 0.35)).b);
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
원본 이미지를 그대로 쓰면 VRAM이 터진다. 1920×1080 RGBA = 8.3MB/장. 표시 크기 × 2까지만 서버에서 리사이즈해라.
7. 패턴 카탈로그 — 코드는 research/three/02-patterns.md에
| 효과 | 언제 | 비용 | 문서 |
|---|---|---|---|
| SDF 도트 그리드 + 마우스 트레일 | 텍스트가 위에 올라가는 배경. 가독성을 안 해친다 | +0KB · 낮음 | P2 |
| 파티클 필드 (Points) | 먼지·별 레이어. 5만 개 이하 | +0KB · 중간 | P3 |
| GPGPU 플로우필드 파티클 | 10만 개 이상, 유체 같은 흐름 | +0KB · 높음 | P4 |
| InstancedMesh 그리드 웨이브 | 입체 격자 인터랙션. 드로우콜 1 | +0KB · 중간 | P5 |
| 스크롤 카메라 리그 (Lenis+ScrollTrigger) | 스크롤텔링. WebGL이 페이지를 지배할 때 | +45KB · 낮음 | P6 |
| drei ScrollControls | R3F에서 페이지 전체가 캔버스일 때 | +0KB · 낮음 | P7 |
| 이미지 전환 (displacement) | 슬라이더·캐러셀 | +0KB · 낮음 | P9 |
| 유리·굴절 (MeshTransmissionMaterial) | 중심 오브제. 가장 비싸다 | +0KB · 매우높음 | P10 |
| 3D 텍스트 (troika SDF) | WebGL 안의 대형 타이포 | +55KB · 낮음 | P11 |
| 포스트프로세싱 (bloom/CA/grain) | 발광·필름 룩 | +32KB · 중간~높음 | P12 |
| GLTF 제품 씬 (DRACO/KTX2) | 제품이 주인공 | +15KB + 모델 · 중간 | P13 |
| 노이즈 디졸브 리빌 | 등장·전환 | +0KB · 낮음 | P15 |
+45KB 이상 항목은 예산 계산에 반드시 넣어라. 스크롤 리그(GSAP+Lenis)는 한 줄도 안 썼는데 +200KB 예산의 22%를 먹고 시작한다. 여기에 troika(+55KB)와 포스트프로세싱(+32KB)을 얹으면 +132KB — 예산의 66%가 라이브러리로만 사라지고 실제 씬에 쓸 몫은 68KB뿐이다.
8. 성능 — 효과 순서대로
- DPR 클램프가 1위다.
min(devicePixelRatio, 2)— 3→2면 픽셀 55% 감소, 육안 차이 없음. 배경 셰이더는 1.25~1.5까지 내려도 된다 - 안 보일 때 안 그린다.
visibilitychange+IntersectionObserver. 정적 씬은 온디맨드(R3Fframeloop="demand"). 배터리 80% 절약 - VRAM이 모바일의 진짜 한계다. 4K 텍스처 = 67MB/장. WebP는 다운로드만 줄이고 VRAM은 그대로다. 줄이는 유일한 수단은 KTX2(ETC1S 1/8). 컨텍스트 로스의 최대 원인도 VRAM 고갈
- 드로우콜 예산: 데스크톱 150 / 모바일 60.
renderer.info.render.calls로 측정. 넘으면InstancedMesh MeshTransmissionMaterial은 매 프레임 씬을 재렌더한다.resolution을 기본(풀스크린)으로 두지 마라 — 512 이하, 모바일은samples={2} resolution={256}. 2개 이상이면transmissionSampler공유- 투명 파티클은 오버드로우가 병목이다. 개수보다
gl_PointSize를 먼저 줄여라 - 셰이더는
mediump기본. 모바일에서 ~2배 빠르다. 좌표·GPGPU만highp - 실시간 그림자는 끈다.
ContactShadows frames={1}또는 baked AO로 대체
병목 격리: 캔버스를 200×200으로 줄여 회복되면 프래그먼트 병목, 그대로면 드로우콜·CPU 병목이다.
9. Lighthouse — 캔버스를 LCP 요소로 만들지 마라
WebGL 랜딩페이지가 42점에서 98점으로 간 실측 사례의 핵심은 이 한 줄이다.
| 규칙 | 방법 |
|---|---|
| LCP는 DOM 텍스트가 잡는다 | 히어로 <h1>이 LCP가 되게 하고 캔버스는 z-index:-1 배경 레이어로 |
| CLS 차단 | 캔버스 컨테이너에 aspect-ratio 또는 min-height를 미리 준다 |
| three는 LCP 이후에 요청된다 | 코드 A의 동적 import + IntersectionObserver. Network 탭에서 순서를 확인해라 |
| 셰이더 컴파일 분산 | 머티리얼 10개를 한 프레임에 처음 그리면 100ms 롱태스크다. renderer.compileAsync(scene, camera) [r170+] |
| 디코더 preload 금지 | DRACO·basis WASM(각 200~250KB)은 실제로 필요할 때만 받게 한다 |
10. 폴백과 접근성
| 상황 | 대체 |
|---|---|
| WebGL2 미지원 · 컨텍스트 생성 실패 | 캔버스 제거 → CSS 그라디언트 폴백 (코드 A) |
저사양(메모리<4GB, 코어<4) · saveData |
아예 로드하지 않는다 |
prefers-reduced-motion: reduce |
캔버스 display:none + CSS 폴백. 굳이 띄워야 하면 renderOnce() 한 장만 |
| 런타임 프레임 저하 | 티어 강등 → 최종적으로 캔버스 제거 (코드 C) |
| 배터리 부족 · 3분 이상 실행 | DPR을 1로 내린다 (써멀 스로틀링 방지) |
| 컨텍스트 로스 | e.preventDefault()로 복구 신호 후 정지. three는 리소스를 자동 재생성하지 않는다 — 씬을 통째로 다시 만들어라 |
- 장식 캔버스는
aria-hidden="true"+pointer-events: none - WebGL 안의 텍스트는 검색·선택·스크린리더가 안 된다. 히어로 문구는 반드시 DOM에 둔다
- 의미 있는 3D 뷰는
role="img"+aria-label+ 정적 이미지 대안 - 캔버스 위 텍스트는 뒤에 반투명 레이어를 깔아 대비 4.5:1을 보장한다
- 캔버스를 여러 개 만들지 마라. 컨텍스트 상한은 8~16개다 (drei
<View>또는 씬 전환)
11. 채택 체크리스트
5단계 프리플라이트에서 그대로 돌린다. 하나라도 실패하면 채택 취소이거나 4-3 복귀다.
- §0 채택 조건 4개를 통과했다 (CSS/SVG로 안 되고, 예산 내이고, 폴백이 있고, 컨셉을 강화한다)
- WebGL 추가분을 실측했다(build 후 gzip). 예산 내이거나, 올린 이유를 명시했다
three가 초기 번들에 없다. Network 탭에서 LCP 이후 요청됨을 확인했다- 셰이더의 색·시간이 3단계 토큰(
--accent,--dur-*)에서 파생됐다. hex 하드코딩이 없다 - 컬러 텍스처에
colorSpace = SRGBColorSpace를 지정했다 - DPR을 클램프했다.
IntersectionObserver+visibilitychange로 렌더를 멈춘다 renderer.info.render.calls≤ 150(데스크톱) / 60(모바일)- 캔버스를
display:none으로 껐을 때 페이지가 완성돼 있다 (프리플라이트 0-A와 동일) prefers-reduced-motion에서 애니메이션이 멈추고 폴백이 보인다- 캔버스에
aria-hidden="true", 히어로 텍스트는 DOM에 있다 - 컨텍스트 로스를 강제(
WEBGL_lose_context.loseContext())했을 때 크래시 없이 폴백이 뜬다 - 라우트 이탈 시
dispose()가 호출된다. 3분 실행 후renderer.info.memory가 증가하지 않는다 - 실기기(중저가 안드로이드)에서 30fps 이상이다
- 6단계
design.md에 채택한 이펙트 · 실측 추가분(KB) · 그 폴백을 적었다
하드 게이트 중 셋이 여기서 자주 걸린다: #1(셰이더 hex 하드코딩) · #8(transform/opacity 외 애니메이션 — 캔버스 페이드는 opacity로) · #10(scroll 리스너 — rAF에서 scrollY를 읽어라).
근거: research/three/01~04 (조사일 2026-08-20)