지적받은 것: 모바일 마진·패딩, 폰트 웨이트, 버튼 스타일 불일치, SSOT,
스크롤바, 모바일 메뉴, 그리고 3D 를 왜 상자에 넣었는지.
3D 의 역할을 잘못 잡았다
lusion.co 의 "3D 는 액자 안"은 3D 자체가 상품일 때의 규칙이다.
우리는 절차를 파는데 액자에 넣을 상품이 없어 "저 상자는 뭐냐"가 됐고,
의미를 게이트 판독기가 자막으로 설명하고 있었다.
이 씬의 역할은 재질이다. 배경으로 되돌리고 값을 전부 낮췄다 —
테두리 광 0.85->0.42, 알파 0.72->0.34, UV 경계 0.05->0.17, 이동 60%
ResizeObserver 로 캔버스를 따라가게 했다. window.resize 만 듣는 동안
히어로 높이 변화를 놓쳐 배경이 화면의 3분의 2만 덮었다
SSOT
웨이트 6종(400/500/560/600/620/660) -> 3종. 셋은 토큰에 없던 값이었다
.btn + btn-solid/btn-quiet. 두 버튼이 높이 36 vs 44, 패딩 16 vs 8,
테두리 1px vs 0, 웨이트 400 vs 500 이었다
--header-h. 간격을 clamp 로 바꾸자 관계가 끊겨 h1 이 헤더 뒤로 들어갔다
--space-5~8 과 --gutter 를 clamp 로. 고정이면 모바일 여백이 화면의 26%
scrollbar-color 지정
모바일
좌우 여백 24 -> 32px, 섹션 상하 112 -> 56px
헤더에 섹션 앵커 4개. 900px 미만은 details 기반 메뉴(JS 0바이트)
패럴랙스는 섹션마다 다른 말을 한다
히어로 글자 상승 / 배경 하강 — 반대 방향이라야 시차가 보인다
파이프라인 단계명 고정, 산출물만 지연 — 인과
재료 타일이 서로 다른 깊이 — 표면은 한 겹이 아니다
프리플라이트 뒤 항목일수록 늦게 — 로그는 흐른다
수치 없음. 의도적 정지 — 계기판의 바늘은 떨지 않는다
스킬
three.md §0-A 를 역할별로 갈랐다. "액자 안"만 적어둔 것이 잘못된
일반화였고 내가 거기 그대로 걸렸다. 재질일 때의 값 교정을 실측으로 남김
tokens.md 웨이트·컨트롤·헤더 높이·반응형 간격 — 토큰이 아예 없던 네 축
motion.md 섹션별 패럴랙스 배정표. 움직이지 않는 것도 결정이다
390px 실렌더: 가로 스크롤 0, 대비 실패 0, 웨이트 3종, radius 2종. 테스트 22개 통과
661 lines
34 KiB
Markdown
661 lines
34 KiB
Markdown
# 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 이 그 구현이다.
|
||
|
||
캔버스 위에 글자를 올려야 한다면 **하나만** 올려라. 그리고 거기엔 바탕을 깔아라.
|
||
|
||
### 재질일 때 — 배경 전면
|
||
|
||
주인공이 아니라 공간을 만드는 표면이다. 규칙이 반대가 된다.
|
||
|
||
- **대비를 낮춘다.** 주인공일 때의 값을 그대로 쓰면 글자 뒤에서 형광 막대처럼 튄다.
|
||
실측 교정: 테두리 광 계수 `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 작업은 무엇인가"를 물어라.**
|
||
|
||
### 채택 조건 — 넷 다 만족해야 한다
|
||
|
||
1. CSS/SVG로 같은 인상이 안 나온다
|
||
2. 추가분이 **+200KB(gzip) 이내**다 (데모·포트폴리오 브리프면 +600KB, 단 올렸다고 명시)
|
||
3. **폴백을 같이 만든다.** 폴백 없이 채택하지 않는다
|
||
4. 2단계 한 문장 컨셉을 **강화**한다. "있으면 멋있어서"는 이유가 아니다
|
||
|
||
---
|
||
|
||
## 1. 버전 핀 — 지금 안전한 조합
|
||
|
||
```json
|
||
{ "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를 초기 번들에서 빼는 유일한 방법이다.
|
||
|
||
```js
|
||
// 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)
|
||
})
|
||
```
|
||
|
||
```js
|
||
// src/main.js — three 는 여기 어디에도 import 되지 않는다
|
||
import { boot } from './gl/boot.js'
|
||
boot(document.getElementById('gl'), () => import('./gl/scenes/hero.js'))
|
||
```
|
||
|
||
```html
|
||
<div class="hero__bg" aria-hidden="true"><canvas id="gl" style="opacity:0"></canvas></div>
|
||
```
|
||
|
||
```css
|
||
.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 — 모든 씬이 얹히는 최소 컨테이너
|
||
|
||
```js
|
||
// 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).
|
||
|
||
```js
|
||
// 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() }
|
||
}
|
||
```
|
||
|
||
```glsl
|
||
/* 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.03~0.15, 낮을수록 고급) · `OCTAVES`(2~4) · `p * 1.1`의 배율(작을수록 큰 덩어리) · 비네트 0.16.
|
||
`#include <…>`는 `ShaderMaterial`에서만 동작한다. `RawShaderMaterial`은 컴파일 에러다.
|
||
|
||
---
|
||
|
||
## 5. 코드 C — 품질 티어 + 런타임 강등
|
||
|
||
**언제**: three를 쓰는 모든 씬. 한 번 판정해 씬 전체 설정을 결정한다. 컴포넌트마다 각자 판단하면 조합이 폭발한다.
|
||
|
||
```js
|
||
// 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;` 이어야 한다.
|
||
|
||
```js
|
||
// 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()
|
||
}
|
||
}
|
||
```
|
||
|
||
```glsl
|
||
/* 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. 성능 — 효과 순서대로
|
||
|
||
1. **DPR 클램프가 1위다.** `min(devicePixelRatio, 2)` — 3→2면 픽셀 **55% 감소**, 육안 차이 없음. 배경 셰이더는 1.25~1.5까지 내려도 된다
|
||
2. **안 보일 때 안 그린다.** `visibilitychange` + `IntersectionObserver`. 정적 씬은 온디맨드(R3F `frameloop="demand"`). 배터리 80% 절약
|
||
3. **VRAM이 모바일의 진짜 한계다.** 4K 텍스처 = 67MB/장. **WebP는 다운로드만 줄이고 VRAM은 그대로**다. 줄이는 유일한 수단은 KTX2(ETC1S 1/8). 컨텍스트 로스의 최대 원인도 VRAM 고갈
|
||
4. **드로우콜 예산**: 데스크톱 150 / 모바일 60. `renderer.info.render.calls`로 측정. 넘으면 `InstancedMesh`
|
||
5. **`MeshTransmissionMaterial`은 매 프레임 씬을 재렌더한다.** `resolution`을 기본(풀스크린)으로 두지 마라 — 512 이하, 모바일은 `samples={2} resolution={256}`. 2개 이상이면 `transmissionSampler` 공유
|
||
6. **투명 파티클은 오버드로우가 병목**이다. 개수보다 `gl_PointSize`를 먼저 줄여라
|
||
7. **셰이더는 `mediump` 기본.** 모바일에서 ~2배 빠르다. 좌표·GPGPU만 `highp`
|
||
8. **실시간 그림자는 끈다.** `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)
|