designpaca/packages/skill/references/three.md
Yun Chan 8267905c0c 테두리 광을 회전시키고 three.js 모바일 대응을 고친다
회전 테두리
  conic 의 각도를 @property 로 돌리면 매 프레임 배경을 다시 칠한다 —
  컴포지터에서 못 돌고 메인 스레드가 페인트한다(하드 게이트 8).
  정사각형 원뿔 레이어를 마스크 안에서 rotate 시켰다. transform 만 움직인다.
  aspect-ratio: 1 이라야 회전 중 밝기가 일정하고,
  width: 200% 라야 1178x52 헤더에서 모서리까지 닿는다. 주기 11초.

three.js 모바일
  세로 화면에서 게이트가 통째로 안 보이고 있었다.
  수직 시야는 화면 비율과 무관하지만 수평 시야는 aspect 에 비례한다.
  데스크톱(2.5:1)에서 정한 판 폭 15 가 세로 화면(0.45:1)의 수평 시야
  2.5 에 비해 여섯 배라, 첫 판이 화면을 완전히 덮었다.
  fit() 에서 camera.aspect 를 갱신할 때 판의 scale.x 도 같이 잡는다.

  저사양 판정도 고쳤다. hardwareConcurrency <= 4 만 보면 요즘 폰이 전부
  고사양으로 잡힌다(8코어가 흔하다). innerWidth < 760 을 함께 본다.
  DPR 1.5 -> 1.25, 캔버스 불투명도 0.7 -> 0.45(세로는 화면 전체를 덮는다).

스킬
  svg-filters.md  테두리 광 회전은 각도가 아니라 정사각형 레이어 rotate
  three.md        지오메트리를 화면 비율에 맞춰 다시 잡아라
                  저사양 판정에 화면 폭을 넣어라
2026-08-20 16:37:49 +09:00

39 KiB
Raw Permalink Blame History

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 이 그 구현이다.

캔버스 위에 글자를 올려야 한다면 하나만 올려라. 그리고 거기엔 바탕을 깔아라.

씬을 두 자리에 재사용하지 마라

의미를 가진 씬을 만들었다면 그 자리는 하나다. 배경이 허전하다고 같은 씬을 다른 섹션에 또 깔면 같은 그림이 두 번 나오고, 첫 번째 자리의 의미까지 희석된다.

한 페이지에 씬이 여럿이면 역할을 나눠라.

역할 구현 판단 기준
주인공 씬(메시·카메라·이동) 옆의 콘텐츠가 반응한다
재질 셰이더 플레인 하나 공간만 만든다. 아무것도 반응하지 않는다
무대 셰이더 플레인 하나 다른 요소가 그 위에 앉는다

파일 이름을 용도와 맞춰라. hero-scene.ts 를 파이프라인에서 쓰기 시작한 순간 다음 사람은 그것을 히어로 것인 줄 알고 고친다. gate-scene.ts 로 바꿔라.

지오메트리는 화면 비율에 맞춰 다시 잡아라

데스크톱에서 정한 크기를 세로 화면에 그대로 쓰면 씬이 통째로 안 보인다.

수직 시야는 화면 비율과 무관하지만 수평 시야는 aspect 에 비례한다.

수직 시야 = 2 · d · tan(fov/2)
수평 시야 = 수직 시야 × aspect

실측 사례: 판 폭 15 를 데스크톱(2.5:1)에서 정했다. 세로 화면(0.45:1)에서 수평 시야는 2.5 로 줄어드는데 판은 그대로 15 라 여섯 배가 됐다. 첫 판이 화면을 완전히 덮어 게이트가 보이지 않고 그냥 밝은 화면이 됐다.

const fitPanes = (aspect) => {
  const halfH = Math.tan((camera.fov * Math.PI) / 360);
  const viewW = 2 * camDist * halfH * aspect;
  const want = Math.max(MIN_W, viewW * 1.12);   // 살짝만 덮는다
  for (const m of panes) m.scale.x = Math.min(1, want / GEO_W);
};

fit() 안에서 camera.aspect 를 갱신할 때 같이 부른다.

저사양 판정에 화면 폭을 넣어라

const narrow = innerWidth < 760;
const lean = narrow || (navigator.hardwareConcurrency ?? 4) <= 4;
const dpr = Math.min(devicePixelRatio, narrow ? 1.25 : 1.5);

hardwareConcurrency 만 보면 요즘 폰이 전부 고사양으로 잡힌다 — 8코어가 흔하다. 좁은 화면은 대개 배터리로 돌아가고, 같은 씬이라도 열이 더 난다. 폰의 DPR 은 3 까지 가므로 좁은 화면에서는 한 단계 더 내린다.

세로 화면에서 캔버스가 화면 전체를 덮는다면 불투명도도 낮춰라. 가로에서 적당하던 밝기가 세로에서는 본문을 묻는다. 실측 교정: 0.7 → 0.45.

WebGL 위의 대비는 readPixels 로 못 잰다

preserveDrawingBuffer: false(기본값)면 프레임을 그린 뒤 버퍼가 비워진다. gl.readPixels전부 0 을 돌려주고, 그 0 을 검정으로 착각하면 대비가 통과한 것처럼 보인다.

스크린샷을 찍어 그 픽셀을 재라. 그리고 글자가 없는 영역만 골라라 — 글자를 포함해 재면 최고 밝기가 글자 자신이라 대비 1.00:1 이 나온다.

// 캔버스 배경만 있는 영역에서 가장 밝은 픽셀 = 최악의 경우
sharp(shot).extract({ left, top, width, height }).raw()

씬의 의미는 옆의 콘텐츠가 반응할 때 성립한다

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 작업은 무엇인가"를 물어라.

채택 조건 — 넷 다 만족해야 한다

  1. CSS/SVG로 같은 인상이 안 나온다
  2. 추가분이 +200KB(gzip) 이내다 (데모·포트폴리오 브리프면 +600KB, 단 올렸다고 명시)
  3. 폴백을 같이 만든다. 폴백 없이 채택하지 않는다
  4. 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에 하드코딩 셰이더 안 vec3linear 값이다. 색은 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, 낮을수록 고급) · OCTAVES(24) · p * 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. 성능 — 효과 순서대로

  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)