웹페이지에 실시간으로

영상 파일 대신 같은 장면을 브라우저에서 바로 그릴 수 있어요(three.js · WebGL — 대부분의 브라우저에서 그려지고, 못 그리면 포스터로 바뀌어요). 아래 프롬프트를 Claude Code·Codex 에 붙여 넣으면 VelvetDrapeBackground 모듈로 넣어 첫 화면 배경에 깔아 줍니다. 동작 줄이기에서는 정지 화면 한 장만 그려요.

npm i three@0.186.1
프롬프트 전체 보기
이 프로젝트에 Duchamp 모션 배경 "인디고 벨벳"(천장 다운라이트)을 실시간 3D 배경(three.js)으로 넣어 줘.

## 배경 정보
- 엔진: three.js(threejs.org) — MIT, 상업적 사용 가능. 패키지 `three@0.186.1` (버전 고정)
- WebGL 로 그린다(WebGPU 아님 — 대부분의 브라우저에서 바로 그려진다). 글자·이미지·폰트·외부 로딩 없음 — 위에 내 글자를 얹어 쓰는 배경
- 정지 포스터: https://duchamp.app/readymade/backgrounds/three-velvet-drape.large.webp
- 같은 장면의 영상(9:16 MP4, 반복): https://pub-a4aa81cc9139413c99493d10e0d4398d.r2.dev/backgrounds/three-velvet-drape/e0bacc63b272dd92f2f9190806b733c10205ccc11b4962f0b4a8c0448036721d.mp4

## 넣는 방법
1. `npm i three@0.186.1` (pnpm·yarn 이면 그 도구로). 버전을 올리지 않는다. 번들러 없는 정적 HTML 이면 설치 대신 아래 importmap(같은 버전의 CDN 고정)을 <head> 에 넣는다.
2. 아래 코드를 장면 모듈 `velvet-drape.scene.js` 로 바이트 그대로 저장한다(기존 lib·utils 폴더 관례를 따른다). 장면 값(색·속도·seed·점 수 같은 숫자)은 바꾸지 않는다. 3단계의 컴포넌트와 파일 이름을 다르게 둔다 — 같은 폴더에 `VelvetDrapeBackground.js`·`VelvetDrapeBackground.tsx` 를 같이 두면 TypeScript 가 `./VelvetDrapeBackground.js` 를 컴포넌트 자신으로 읽는다.
   - TypeScript 프로젝트면 .js 는 그대로 두고, 옆에 같은 이름의 선언 파일 `velvet-drape.scene.d.ts` 를 만든다(`// @ts-nocheck` 로는 안 된다 — allowJs 가 꺼져 있으면 TS 가 .js 를 읽지 않아 가져오는 쪽에서 TS7016 이 난다):
     ```ts
     export function mountWeb(canvas: HTMLCanvasElement, params?: Record<string, unknown>, options?: { onUnavailable?: (reason: string, error?: unknown) => void }): { destroy(): void };
     ```
   - 모듈의 약속: `mountWeb(canvas, params, { onUnavailable })` 는 던지지 않는다. WebGL2 를 못 만들거나 컨텍스트를 잃으면 캔버스를 스스로 숨기고(visibility: hidden) `onUnavailable(reason)` 을 부른다(reason: "webgl-unavailable" · "context-lost"). 컨텍스트가 돌아오면 캔버스를 다시 보이고 이어 그린다. 돌려준 핸들의 `destroy()` 가 루프·관찰자·GPU 자원을 정리한다.
3. 배경으로 깐다: 감싸는 섹션을 `position: relative; overflow: hidden` 으로 두고 `<canvas>` 를 `position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: 0` 으로, 내용은 그 위(z-index 1)에 둔다. 따로 말하지 않았으면 첫 화면(히어로) 섹션에 깐다.
   - React·Next.js 면 `VelvetDrapeBackground.tsx` 클라이언트 컴포넌트("use client")를 만들어 `useEffect` 안에서 `const handle = mountWeb(canvasRef.current, {"loopSeconds":10}, { onUnavailable })` 를 부르고(onUnavailable 은 선택 — 4단계), 정리(cleanup)에서 `handle.destroy()` 를 부른다. 서버 렌더에서는 캔버스와 포스터만 나간다(WebGL 은 브라우저에서만).
   - 그 밖(Vue·Svelte·Astro·정적 HTML)도 같은 규칙 — 붙일 때 `mountWeb`, 떼어낼 때 `destroy()`.
4. 폴백: 캔버스 뒤(같은 섹션)에 포스터(https://duchamp.app/readymade/backgrounds/three-velvet-drape.large.webp)를 CSS 배경(background-size: cover)으로 깔아 둔다 — 처음 그려지기 전에도, 모듈이 캔버스를 숨겼을 때(WebGL 불가·컨텍스트 잃음)도 그 포스터가 보이므로 따로 감지할 필요가 없다. 포스터를 <img> 로 따로 보여 줄 때만 `onUnavailable` 에서 상태를 바꾼다. `canvas.getContext(...)` 로 미리 떠보지 않는다(기본 속성 컨텍스트가 먼저 잡혀 three 의 설정이 무시된다). `webglcontextlost`·`webglcontextrestored` 도 따로 처리하지 않는다(모듈이 처리한다).
5. `prefers-reduced-motion: reduce` 면 코드가 스스로 정지 화면 한 장만 그리고 루프를 멈춘다(`mountWeb` 안에 들어 있다) — 끄지 않는다. 포스터만 보여 주고 싶으면 그 경우에는 `mountWeb` 호출을 건너뛴다.

```js
// three-velvet-drape — 인디고 벨벳 · 천장 다운라이트. 홈 전시실 mood-2 방(인디고 벨벳 벽 + 천장 스팟 + 어두운 바닥)을 실조명으로 다시 지은 장면.
// 벽을 덮은 무거운 벨벳 한 장: 넓고 고르지 않은 주름 4~5개(둥근 능선 · 칼처럼 접힌 깊은 골)가 바닥까지 내려오고, 바닥선과 접지 그림자가 방을 만든다.
// 주름의 법선·골짜기 그늘은 프래그먼트에서 같은 높이 함수로 다시 구해 메시 해상도와 상관없이 1080 에서 또렷하다(골은 1px 아래로 좁아지지 않게 AA).
// 빛: 키 하나 — 위 중앙의 따뜻한 다운라이트(#FFE4C8, 넓은 반그림자, 코드 고보로 아래를 빨리 끈 웅덩이),
//     오른쪽 가장자리 주름 능선과 바닥에만 닿는 차가운 코발트 림(세로 띠 면광원, 키의 15% 이하), 아주 약한 반구 필.
// 재질: MeshPhysicalMaterial sheen 1(#4A5A9A) — 결 광택은 능선 옆 비탈에서만, 빛 밖에서는 인디고 평면으로 가라앉는다. 고운 결·눌린 얼룩은 천에 붙은 정지 무늬.
// 중경: 빛줄기 안에서만 보이는 먼지 28개(느린 정수 주파수 표류, 카메라 시차 1~2%). 룩: 골짜기 그늘 · 아래로 가라앉는 인디고 안개(글자 구역) · 밝은 곳은 따뜻한 회색으로 말림.
// 두 속도: 주름이 루프 동안 느리게 흔들리고 숨쉰다(드리프트) + 루프당 한 번(≈5.0~6.4초) 바람 한 줄기가 왼쪽→오른쪽으로 천을 밀고 지나가며 빛이 20% 숨쉰다(사건).
// 모든 움직임은 u = t/loopSeconds 의 정수 주파수 → frame(loopSeconds) === frame(0). 이미지·HDR·글자·fetch 없음(고보·얼룩·먼지는 코드).
//
// 파일 하나가 렌더(HyperFrames hf-seek)와 웹(mountWeb) 양쪽의 같은 소스다 — scenes/_template.js 계약.
//   createRenderer(canvas, {width, height, dpr}) → WebGLRenderer, SRGB 출력, 톤매핑 없음(롤오프는 재질 안에서), DPR 1
//   createScene(renderer, params)               → {scene, camera, renderAt(t), setSize(w, h), dispose(), params} — renderAt 는 t 의 순수 함수
//   mountWeb(canvas, params, {onUnavailable})   → 웹 규칙 v2
// Rules (tone brief 2026-10-10 "전시실 조명"): one warm key from above + cool rim ≤15% · slow drift + one event per loop ·
//   text zone quiet (4:5 bottom 40% × centre 60%, 9:16 centre band) · integer frequencies of u · no Math.random / text / images / fetch.
import * as THREE from 'three';
import {RectAreaLightUniformsLib} from 'three/addons/lights/RectAreaLightUniformsLib.js';

export const meta = {
  id: 'three-velvet-drape',
  background: '#04080E', // ink — frame edges fall to this
};

// Palette from the brief (sRGB). 브리프 8색 — 한 배경에 색 가족 2개까지, 코발트는 액센트만.
export const PALETTE = {
  ink: '#04080E', // 바탕
  indigoWall: '#0D1827', // 인디고 벽
  indigoLit: '#293546', // 빛 받은 인디고
  spot: '#A69C98', // 스팟 하이라이트(따뜻한 회색)
  bounce: '#817065', // 바닥 반사(따뜻한 바운스)
  cobalt: '#2B30AE', // 코발트 액센트(인쇄·림)
  electric: '#2A3CFF', // 전기 코발트(림·가장자리 글로우만, 면적 ≤5%)
  plaster: '#E4E6E8', // 석고/종이(밝은 변형)
  cream: '#EDE8DE', // 미색(밝은 변형)
};

// All params are plain JSON (they travel in the sidecar `three.params`). 숫자·문자열만 — 함수 금지.
export const DEFAULTS = {
  loopSeconds: 10, // render.mjs overwrites this with preset.seconds — keep in sync for the web
  seed: 7,
  // 벨벳 — 벽을 덮은 한 장(바닥 ~ 화면 위)
  drapeWidth: 4.6,
  drapeTop: 2.6,
  segX: 230,
  segY: 200,
  tilt: 0.03, // rad — 위가 카메라 쪽으로
  foldScale: 1.0, // 주름 공간 주파수 배수(클수록 좁다). 주된 주름 0.62 = 4:5 화면 폭의 24%
  foldDepth: 1.0, // 주름 깊이 배수
  gather: 0.16, // 위로 갈수록 주름이 촘촘(위에서 모인 천)
  crease: 0.13, // 주된 주름 골의 둥글기(작을수록 칼주름) — 두꺼운 천이라 골도 조금 둥글게
  warp: 0.11, // 주름 폭이 고르지 않게(가로 도메인 워프)
  depthVar: 0.32, // 주된 주름마다 깊이가 다르게(느린 노이즈)
  crinkle: 0.003, // 잔구김
  velvetColor: '#0D1827',
  sheenColor: '#465A92',
  sheenRoughness: 0.42,
  roughness: 0.9,
  specular: 0.25, // 벨벳은 정반사가 약하다
  mottle: 0.12, // 눌린 벨벳 얼룩(천에 붙은 정지 노이즈 — 움직이는 그레인 아님)
  pile: 0.14, // 고운 결(sheen 에만, 1080 에서 3~6px)
  aoMin: 0.18, // 골짜기 그늘 — 골 바닥 밝기 배수
  cavWidth: 0.12, // 골짜기 그늘 폭(sin² 단위)
  // 빛 — 키 하나(위 중앙, 따뜻), 림(오른쪽 가장자리, ≤15%), 아주 약한 반구 필
  keyColor: '#FFE4C8',
  keyIntensity: 780, // candela
  keyX: -0.25, // 웅덩이 중심 기준 스팟 위치: 옆 · 위 · 앞
  keyRise: 2.4,
  keyRise916: 1.7,
  keyGain916: 0.72,
  keyFront: 1.15, // 수직에서 ≈25° — 천장 다운라이트가 벨벳을 스치듯 비춰 결(sheen)이 산다
  keyFront916: 0.95,
  keyDrop: 0.25,
  keyAngle: 0.42, // 콘은 넉넉히 — 웅덩이 모양은 고보가 정한다(콘 가장자리가 웅덩이를 자르지 않게)
  keyPenumbra: 0.85,
  cookieWide: 0.38, // 고보(스팟 투영 공간, 콘 반지름 = 1) — 가로 시그마
  cookieWide916: 0.42,
  cookieUp: 0.21, // 위쪽 세로 시그마(4:5)
  cookieUp916: 0.18, // 9:16 — 웅덩이 전체가 화면 안에 들어오게(등기구가 가까워 웅덩이 자체가 작다)
  cookieDown: 0.13, // 아래쪽 세로 시그마(4:5) — 아래 가장자리는 벽 높이 마스크가 끈다
  cookieDown916: 0.12, // 9:16 — 중앙 띠가 더 위에서 시작한다(반그림자는 넓게 두어 골마다 빗살이 서지 않게)
  cookieLift: 0.05,
  maskTop45: 0.36, // 웅덩이 아래 가장자리(화면 위에서 비율): 여기까지는 다 받고 —
  maskBot45: 0.56, // — 여기서 꺼진다(4:5 글자 구역 0.6 위)
  maskTop916: 0.17,
  maskBot916: 0.3, // 9:16 중앙 띠 0.3 에서 꺼진다
  maskCurve45: 0.35, // 아래 가장자리를 둥글게(가운데가 가장 낮은 U 자)
  maskCurve916: 0.8,
  cookieSharp: 2.0, // 2 = 가우스, 클수록 가운데가 평평하고 가장자리가 또렷
  cookieHalo: 0.12, // 넓은 번짐(실제 등기구의 필드 빛) — 코어 대비 세기
  haloSide: 1.3,
  haloUp: 1.0, // 번짐은 위로는 코어만큼만 — 다운라이트 웅덩이의 위 가장자리가 둥글고 또렷하게
  haloDown: 1.05,
  rimColor: '#2A3CFF',
  rimNits: 26, // 세로 띠 면광원 — 키 웅덩이의 ≤15%
  fillSky: '#8A8C94',
  fillGround: '#04080E',
  fill: 13,
  // 바닥 — 벨벳이 닿는 어두운 광택 바닥(4:5 화면 아래 ≈8%)
  floorY: -1.36,
  floorColor: '#141A24',
  floorRough: 0.42,
  floorSpill: 0.0035, // 웅덩이 아래 바닥의 따뜻한 자국(선형) — 바닥이 바닥으로 읽히게, 글자 구역 σ 를 거의 안 올리는 크기
  // 먼지 — 빛줄기 안에서만 보이는 중경(≤40개)
  dust: 28,
  dustSize: 5.5, // 1080 높이 기준 지름(px) · 카메라 거리 1 기준
  dustGain: 0.9,
  // 룩 — 비네트 · 아래로 가라앉는 안개 · 하이라이트 롤오프
  vignette: 0.42,
  fogColor: '#0F1E31',
  fogAmount: 0.4,
  sinkStart45: 0.5, // 화면 위에서 비율 — 여기서 가라앉기 시작
  sinkFull45: 0.8,
  sinkStart916: 0.2,
  sinkFull916: 0.36,
  rollStart: 0.0, // 선형 휘도 — 여기서부터 키 색으로 말리기 시작
  rollEnd: 0.32,
  rollGamma: 0.6,
  rollAmount: 0.9, // 하이라이트를 따뜻한 회색(#B4A79E 계열)으로
  rollTint: '#FFE9D2',
  knee: 0.32, // 어깨 — 이 휘도 위는 부드럽게 눌린다
  top: 0.62,
  // 구도(4:5 → 9:16 은 view.portrait 로 보간)
  fov: 30,
  camDist: 6.0,
  camDist916: 7.2,
  poolY45: 0.3, // 빛웅덩이 중심(화면 위에서 비율)
  poolY916: 0.19,
  lookY916: 0.3, // 9:16 은 시선을 올려 바닥 띠를 화면 아래 ≈8% 로
  parallax: 0.07, // 카메라 옆 이동(먼지·바닥과 1~2% 시차)
  sway: 1.0, // 주름 흔들림 배수
  // 사건 — 루프당 한 번: 바람 한 줄기가 천을 왼쪽→오른쪽으로 밀고 지나가고 빛이 숨쉰다
  breathe: 0.2, // 키 세기 +20%
  spread: 0.08, // 웅덩이 반경 +8%
  gustPush: 0.055, // 바람이 천을 옆으로 미는 양(월드)
  gustBulge: 0.06, // 천이 앞으로 부푸는 양
  gustTravel: 0.95, // 바람이 지나가는 거리(화면 반폭 배수, 왼쪽 → 오른쪽)
  gustWidth: 0.5, // 바람 띠 반폭(월드)
  eventAt: 0.571, // 위상 중심(10초 루프에서 5.71초)
  eventWidth: 0.075, // 반폭(위상) — 0.075 × 10초 × 2 = 1.5초
  stillTime: 0, // frame shown when prefers-reduced-motion
  maxDpr: 1.5, // web: DPR cap (rule v2) — 올리지 말 것
};

// Seeded PRNG — never Math.random (render must be deterministic). 시드 난수.
export function mulberry32(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// Loop helpers. u = loop phase 0..1. 루프 위상 도우미.
export const TAU = Math.PI * 2;
export const phase = (t, loopSeconds) => (((t / loopSeconds) % 1) + 1) % 1;
// Smooth pulse 0→1→0 centred at c with half-width w (phase units), ease-in-out, wraps at the loop seam.
// 루프당 '사건' 1회용 — 중심 c, 반폭 w(위상 단위), 이음새를 넘어도 매끈하다.
export const pulse = (u, c, w) => {
  const d = Math.abs(((((u - c + 0.5) % 1) + 1) % 1) - 0.5);
  const x = 1 - Math.min(d / w, 1);
  return x * x * (3 - 2 * x);
};

export function createRenderer(canvas, {width, height, dpr = 1, preserveDrawingBuffer = false} = {}) {
  const renderer = new THREE.WebGLRenderer({canvas, antialias: true, alpha: false, stencil: false, powerPreference: 'high-performance', preserveDrawingBuffer});
  renderer.setPixelRatio(dpr);
  renderer.setSize(width, height, false); // false: never touch canvas CSS size
  renderer.outputColorSpace = THREE.SRGBColorSpace; // linear work → sRGB out, hex colours land as written
  renderer.toneMapping = THREE.NoToneMapping; // keep the palette; expose via light intensity, not tone curves
  return renderer;
}

// GLSL (정점·프래그먼트 공용) — 벨벳 높이 함수. 세로 주름 4층(넓은 굽이 · 주된 주름 · 잔주름 · 구김)을 정수 주파수로 흔들리고 숨쉬게 하고,
// 가로 도메인 워프로 주름 폭을 흩뜨린다. 주된 주름·잔주름은 둥근 능선 + 칼처럼 접힌 골(√(sin²(θ/2)+ε²)) — 골의 ε 는 화면 1px 아래로 내려가지 않게(aa).
// 사건의 바람 한 줄기(uGust)는 지나가는 자리의 천을 옆으로 밀고 앞으로 부풀린다. 프래그먼트는 같은 함수로 법선·골짜기 그늘(cav)을 다시 구한다.
const DRAPE_GLSL = /* glsl */ `
#define NFOLD 4
uniform float uPhase;
uniform vec4 uFoldA[NFOLD]; // k, slant, phase, amp
uniform vec4 uFoldB[NFOLD]; // sway amp, sway phase, breathe amp, breathe phase
uniform vec4 uFoldC[NFOLD]; // crease eps (0 = sine), sway2 amp, sway2 phase, cavity weight
uniform vec4 uWarp; // amp, k, slant, phase
uniform vec4 uShape; // depth mul, gather, cavity width, half height
uniform vec4 uVar; // crinkle amp, crinkle fx, crinkle fy, main-fold depth variation
uniform vec4 uGust; // centre x, side push, half-width, bulge
// 2D simplex noise — Ashima Arts / Ian McEwan (MIT)
vec3 vdPermute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
float vdSnoise(vec2 v) {
  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
  vec2 i = floor(v + dot(v, C.yy));
  vec2 x0 = v - i + dot(i, C.xx);
  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
  vec4 x12 = x0.xyxy + C.xxzz;
  x12.xy -= i1;
  i = mod(i, 289.0);
  vec3 p = vdPermute(vdPermute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
  vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
  m = m * m;
  m = m * m;
  vec3 x = 2.0 * fract(p * C.www) - 1.0;
  vec3 h = abs(x) - 0.5;
  vec3 ox = floor(x + 0.5);
  vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
  vec3 g;
  g.x = a0.x * x0.x + h.x * x0.y;
  g.yz = a0.yz * x12.xz + h.yz * x12.yw;
  return 130.0 * dot(m, g);
}
float drapeZ(vec2 p, int nf, float aa, out float cav) {
  float w = 6.28318530718 * uPhase;
  float yn = clamp(p.y / uShape.w, -1.0, 1.0);
  float gx = (p.x - uGust.x) / uGust.z;
  float g = exp(-gx * gx);
  float xw = p.x - uGust.y * g;
  xw += uWarp.x * sin(uWarp.y * xw + uWarp.z * p.y + uWarp.w + 0.6 * sin(w + 0.4));
  xw *= 1.0 + uShape.y * yn;
  float z = 0.0;
  cav = 0.0;
  for (int i = 0; i < NFOLD; i++) {
    if (i >= nf) break;
    vec4 a = uFoldA[i];
    vec4 b = uFoldB[i];
    vec4 c = uFoldC[i];
    float th = a.x * xw + a.y * p.y + a.z + b.x * sin(w + b.y) + c.y * sin(2.0 * w + c.z);
    float amp = a.w * (1.0 + b.z * sin(w + b.w));
    if (i == 1) amp *= 1.0 + uVar.w * vdSnoise(vec2(xw * 0.9, p.y * 0.2) + 31.7); // 주름마다 깊이가 다르다
    if (c.x > 0.0) {
      float e = max(c.x, aa * a.x);
      float s = sin(0.5 * th);
      z += amp * (sqrt(s * s + e * e) - 0.64);
      cav += c.w * exp(-s * s / uShape.z);
    } else {
      z += amp * sin(th);
    }
  }
  if (nf >= NFOLD) z += uVar.x * vdSnoise(vec2(xw * uVar.y, p.y * uVar.z) + 7.3); // 잔구김 — 규칙성을 깬다
  z += uGust.w * g * smoothstep(-1.0, 0.3, yn); // 바람이 지나가는 자리가 앞으로 부푼다(바닥 쪽은 덜)
  return z * (1.0 - 0.2 * yn) * uShape.x;
}
`;
const V_BEGIN = /* glsl */ `
float vdCavV;
vec3 transformed = vec3(position.xy, position.z + drapeZ(position.xy, NFOLD, 0.0, vdCavV));
vCloth = position.xy;
#ifdef USE_ALPHAHASH
  vPosition = vec3( position );
#endif
`;
// 화면 공간 비네트·안개·롤오프 공용
const LOOK_GLSL = /* glsl */ `
uniform vec2 uRes;
uniform float uVignette;
uniform vec2 uSink; // bottom y, top y (world)
uniform vec3 uFogColor;
uniform float uFogAmount;
float vdVignette() {
  vec2 q = (gl_FragCoord.xy / uRes) * 2.0 - 1.0;
  return 1.0 - uVignette * smoothstep(0.55, 1.5, length(vec2(q.x, 0.75 * q.y)));
}
`;
// 프래그먼트 — 눌린 벨벳 얼룩·고운 결, 법선·골짜기 그늘 다시 구하기, 안개·롤오프
const F_PARS = /* glsl */ `
varying vec2 vCloth;
varying vec3 vDrapeWorld;
uniform mat3 uModelRot;
uniform float uAoMin;
uniform float uMottle;
uniform float uPile;
uniform float uFloorY;
uniform vec4 uSpotMask; // bottom y, full y, curve, centre x — 웅덩이 아래 가장자리를 벽 높이로 끈다(주름 깊이와 무관 → 골마다 빗살이 서지 않는다). 가운데가 가장 낮은 U 자
uniform vec4 uRoll; // start, end, amount, gamma
uniform vec3 uRollTint;
uniform vec2 uShoulder; // knee, top
float vdMottle(vec2 p) { return 0.62 * vdSnoise(p * vec2(4.2, 2.2)) + 0.38 * vdSnoise(p * vec2(9.5, 5.0) + vec2(17.3, 5.1)); }
float vdPile(vec2 p) { return 0.7 * vdSnoise(p * vec2(64.0, 26.0) + vec2(3.7, 9.1)) + 0.3 * vdSnoise(p * vec2(140.0, 52.0) + vec2(1.3, 4.2)); }
`;
const F_COLOR = /* glsl */ `
#include <color_fragment>
float vdMt = vdMottle(vCloth);
diffuseColor.rgb *= 1.0 + uMottle * vdMt;
`;
const F_NORMAL = /* glsl */ `
float faceDirection = gl_FrontFacing ? 1.0 : - 1.0;
float vdAA = fwidth(vCloth.x) * 1.2;
const float NE = 0.0015;
float vdC0;
vec3 vdN = normalize(vec3(
  -(drapeZ(vCloth + vec2(NE, 0.0), NFOLD, vdAA, vdC0) - drapeZ(vCloth - vec2(NE, 0.0), NFOLD, vdAA, vdC0)) / (2.0 * NE),
  -(drapeZ(vCloth + vec2(0.0, NE), NFOLD, vdAA, vdC0) - drapeZ(vCloth - vec2(0.0, NE), NFOLD, vdAA, vdC0)) / (2.0 * NE),
  1.0));
float vdCav;
float vdZ0 = drapeZ(vCloth, 3, vdAA, vdCav); // 골짜기 그늘 — 주된 주름 + 잔주름
vec3 normal = normalize((viewMatrix * vec4(uModelRot * vdN, 0.0)).xyz);
vec3 nonPerturbedNormal = normal;
`;
const F_SHEEN = /* glsl */ `
#include <lights_physical_fragment>
#ifdef USE_SHEEN
  float vdPileAA = 1.0 - smoothstep(0.25, 0.5, fwidth(vCloth.x) * 64.0); // 결이 2px 아래로 작아지면 지운다(반짝임 방지)
  material.sheenColor *= max(0.0, 1.0 + 1.4 * uMottle * vdMt + uPile * vdPileAA * vdPile(vCloth));
#endif
`;
const F_OUT = /* glsl */ `
outgoingLight *= mix(1.0, uAoMin, clamp(vdCav, 0.0, 1.0));
outgoingLight *= 1.0 - 0.5 * exp(-max(vDrapeWorld.y - uFloorY, 0.0) / 0.1); // 바닥에 닿는 단의 접지 그늘
float vdSinkT = 1.0 - smoothstep(uSink.x, uSink.y, vDrapeWorld.y);
outgoingLight = mix(outgoingLight, uFogColor, uFogAmount * vdSinkT);
outgoingLight *= vdVignette();
float vdL = dot(outgoingLight, vec3(0.2126, 0.7152, 0.0722));
outgoingLight = mix(outgoingLight, vdL * uRollTint, uRoll.z * pow(smoothstep(uRoll.x, uRoll.y, vdL), uRoll.w));
float vdLk = vdL > uShoulder.x ? uShoulder.x + (uShoulder.y - uShoulder.x) * (1.0 - exp(-(vdL - uShoulder.x) / (uShoulder.y - uShoulder.x))) : vdL;
outgoingLight *= vdLk / max(vdL, 1e-5);
#include <opaque_fragment>
`;
// 바닥 — 벨벳 단 앞의 접지 그늘(z), 안개, 비네트
const FLOOR_OUT = /* glsl */ `
outgoingLight *= 1.0 - 0.55 * exp(-max(vFloorW.z, 0.0) / 0.22);
float fsx = (vFloorW.x - uSpill.x) / uSpill.y;
outgoingLight += uSpillColor * (uSpill.z * exp(-fsx * fsx) * exp(-max(vFloorW.z, 0.0) / uSpill.w)); // 웅덩이 아래로 흘러내린 빛이 바닥에 남긴 따뜻한 자국
outgoingLight = mix(outgoingLight, uFogColor, uFogAmount);
outgoingLight *= vdVignette();
#include <opaque_fragment>
`;
// 먼지 — 빛줄기(스팟 콘) 좌표로 놓고 정수 주파수로 떠돈다. 밝기 = 콘 안쪽 정도 × 역제곱 — 빛 밖에서는 보이지 않는다.
const DUST_VERT = /* glsl */ `
attribute vec4 aBeam; // t along the beam (0 light .. 1 target), r (cone radius units), angle, size mul
attribute vec4 aDrift; // phase a, phase b, phase c, amp
uniform float uPhase;
uniform vec3 uKeyPos;
uniform vec3 uKeyTgt;
uniform float uTan;
uniform float uPx; // px per world unit at distance 1
uniform float uDiam;
uniform float uGain;
varying float vI;
void main() {
  float w = 6.28318530718 * uPhase;
  vec3 ax = uKeyTgt - uKeyPos;
  float L = length(ax);
  ax /= L;
  vec3 b1 = normalize(cross(ax, vec3(0.0, 0.0, 1.0)));
  vec3 b2 = cross(ax, b1);
  float d = aBeam.x * L;
  vec3 p = uKeyPos + ax * d + (b1 * cos(aBeam.z) + b2 * sin(aBeam.z)) * (aBeam.y * d * uTan);
  p += aDrift.w * vec3(sin(w + aDrift.x), 0.7 * sin(w + aDrift.y) + 0.3 * sin(2.0 * w + aDrift.z), 0.5 * sin(w + aDrift.z));
  vec3 lv = p - uKeyPos;
  float ld = length(lv);
  float ca = dot(lv / ld, ax);
  float rr = sqrt(max(1.0 - ca * ca, 0.0)) / max(ca, 1e-3) / uTan;
  vI = uGain * (1.0 - smoothstep(0.3, 0.85, rr)) / (ld * ld);
  vec4 mv = modelViewMatrix * vec4(p, 1.0);
  gl_Position = projectionMatrix * mv;
  gl_PointSize = max(1.0, uDiam * aBeam.w * uPx / -mv.z);
}
`;
const DUST_FRAG = /* glsl */ `
uniform vec3 uColor;
varying float vI;
void main() {
  vec2 q = gl_PointCoord * 2.0 - 1.0;
  float r2 = dot(q, q);
  float a = exp(-3.0 * r2) * (1.0 - smoothstep(0.65, 1.0, r2));
  gl_FragColor = vec4(uColor * (vI * a), 1.0);
  #include <colorspace_fragment>
}
`;

// 키 스팟에만 벽 높이 마스크를 곱한다 — three 의 lights_fragment_begin 청크를 그대로 쓰고 스팟 쿠키 줄 뒤에 한 줄만 더한다
const SPOT_LINE = 'directLight.color = inSpotLightMap ? directLight.color * spotColor.rgb : directLight.color;';
const SPOT_BEGIN = THREE.ShaderChunk.lights_fragment_begin.includes(SPOT_LINE)
  ? THREE.ShaderChunk.lights_fragment_begin.replace(SPOT_LINE, `${SPOT_LINE}
directLight.color *= smoothstep(uSpotMask.x, uSpotMask.y, vDrapeWorld.y - uSpotMask.z * (vDrapeWorld.x - uSpotMask.w) * (vDrapeWorld.x - uSpotMask.w));`)
  : '#include <lights_fragment_begin>';

// 스팟 앞 고보(쿠키) — 빛을 부드러운 타원으로 다듬는다. 위에서 비스듬히 내려오는 빛은 벽에서 아래로 길게 늘어나므로
// 아래쪽 세로 시그마를 줄여(빨리 꺼지게) 글자 구역 위에서 끝낸다. 코드로 채우는 HalfFloat 데이터 텍스처(이미지 아님).
function fillCookie(tex, o, up, down, wide) {
  const size = tex.image.width, data = tex.image.data;
  const one = THREE.DataUtils.toHalfFloat(1);
  for (let j = 0; j < size; j++) {
    const t = ((j + 0.5) / size) * 2 - 1; // -1 아래 … +1 위 (스팟 투영 공간)
    const dt = t - o.cookieLift;
    const sy = dt < 0 ? down : up;
    for (let i = 0; i < size; i++) {
      const s = ((i + 0.5) / size) * 2 - 1;
      const r2 = (s / wide) ** 2 + (dt / sy) ** 2;
      const h2 = (s / (wide * o.haloSide)) ** 2 + (dt / (sy * (dt < 0 ? o.haloDown : o.haloUp))) ** 2;
      const v = THREE.DataUtils.toHalfFloat(Math.exp(-0.5 * r2 ** (o.cookieSharp / 2)) + o.cookieHalo * Math.exp(-0.5 * h2));
      const k = (j * size + i) * 4;
      data[k] = v; data[k + 1] = v; data[k + 2] = v; data[k + 3] = one;
    }
  }
  tex.needsUpdate = true;
}
function makeCookie(size = 128) {
  const tex = new THREE.DataTexture(new Uint16Array(size * size * 4), size, size, THREE.RGBAFormat, THREE.HalfFloatType);
  tex.colorSpace = THREE.NoColorSpace; // 선형 곱셈 값
  tex.minFilter = THREE.LinearFilter;
  tex.magFilter = THREE.LinearFilter;
  tex.generateMipmaps = false;
  return tex;
}
// 사건 창 안에서 -1→+1 로 진행하는 값(창 밖은 0 — 그때 세기도 0 이라 보이지 않는다)
const sweepPos = (u, c, w) => {
  const d = ((((u - c + 0.5) % 1) + 1) % 1) - 0.5;
  if (Math.abs(d) >= w) return 0;
  const x = (d / w + 1) / 2;
  return x * x * (3 - 2 * x) * 2 - 1;
};

export function createScene(renderer, params = {}) {
  const o = {...DEFAULTS, ...params};
  const rand = mulberry32(o.seed);
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(meta.background);
  const camera = new THREE.PerspectiveCamera(o.fov, 1, 0.1, 60);

  // --- 벨벳: 넓은 굽이 1.8 · 주된 주름 0.56(4:5 화면 폭의 22% — 무거운 천의 둥근 관) · 잔주름 0.24 · 구김 0.11. 거의 수직(1~3°, 같은 쪽) ---
  const LAMBDA = [1.8, 0.56, 0.24, 0.11];
  const AMP = [0.08, 0.2, 0.02, 0.004];
  const LEAN = [2, 1.5, 1.5, 3];
  const SWAY = [0.34, 0.46, 0.55, 0.6]; // rad(주름 위상) — 루프당 1회
  const SWAY2 = [0.1, 0.14, 0.18, 0.2]; // rad — 루프당 2회
  const BREATH = [0.08, 0.12, 0.14, 0.16];
  const CREASE = [0, o.crease, 0, 0]; // 칼주름 골은 주된 주름만(잔주름은 사인 — 엇갈린 잔선 없음)
  const CAVW = [0, 1, 0, 0];
  const foldA = [], foldB = [], foldC = [];
  for (let i = 0; i < 4; i++) {
    const k = (TAU / LAMBDA[i]) * o.foldScale;
    const slant = k * Math.tan(THREE.MathUtils.degToRad(LEAN[i])) * (rand() < 0.5 ? 1 : 0.6) * (0.5 + 0.5 * rand()); // 같은 쪽으로만 — 주름끼리 엇갈려 X 무늬가 생기지 않게
    foldA.push(new THREE.Vector4(k, slant, rand() * TAU, AMP[i] * (0.85 + 0.3 * rand())));
    foldB.push(new THREE.Vector4(o.sway * SWAY[i] * (0.8 + 0.4 * rand()), rand() * TAU, BREATH[i], rand() * TAU));
    foldC.push(new THREE.Vector4(CREASE[i], o.sway * SWAY2[i] * (0.8 + 0.4 * rand()), rand() * TAU, CAVW[i]));
  }
  const tint = new THREE.Color(o.rollTint); // 선형 — 휘도 1 로 정규화해 롤오프가 밝기를 바꾸지 않게
  const tintLum = 0.2126 * tint.r + 0.7152 * tint.g + 0.0722 * tint.b;
  const look = {
    uRes: {value: new THREE.Vector2(1, 1)},
    uVignette: {value: o.vignette},
    uSink: {value: new THREE.Vector2(-1, 0)},
    uFogColor: {value: new THREE.Color(o.fogColor)},
    uFogAmount: {value: o.fogAmount},
  };
  const uni = {
    ...look,
    uPhase: {value: 0},
    uFoldA: {value: foldA},
    uFoldB: {value: foldB},
    uFoldC: {value: foldC},
    uWarp: {value: new THREE.Vector4(o.warp, 3.1 * o.foldScale, 0.3, rand() * TAU)},
    uShape: {value: new THREE.Vector4(o.foldDepth, o.gather, o.cavWidth, 2.0)},
    uVar: {value: new THREE.Vector4(o.crinkle, 13, 1.6, o.depthVar)},
    uGust: {value: new THREE.Vector4(-9, 0, o.gustWidth, 0)},
    uModelRot: {value: new THREE.Matrix3()},
    uAoMin: {value: o.aoMin},
    uMottle: {value: o.mottle},
    uPile: {value: o.pile},
    uFloorY: {value: o.floorY},
    uSpotMask: {value: new THREE.Vector4(-9, -8, 0, 0)},
    uRoll: {value: new THREE.Vector4(o.rollStart, o.rollEnd, o.rollAmount, o.rollGamma)},
    uRollTint: {value: new THREE.Vector3(tint.r / tintLum, tint.g / tintLum, tint.b / tintLum)},
    uShoulder: {value: new THREE.Vector2(o.knee, o.top)},
  };
  const mat = new THREE.MeshPhysicalMaterial({
    color: o.velvetColor, roughness: o.roughness, metalness: 0, specularIntensity: o.specular,
    sheen: 1, sheenColor: o.sheenColor, sheenRoughness: o.sheenRoughness, dithering: true,
  });
  mat.onBeforeCompile = (shader) => {
    Object.assign(shader.uniforms, uni);
    shader.vertexShader = shader.vertexShader
      .replace('#include <common>', `#include <common>\n${DRAPE_GLSL}\nvarying vec2 vCloth;\nvarying vec3 vDrapeWorld;`)
      .replace('#include <begin_vertex>', V_BEGIN)
      .replace('#include <project_vertex>', '#include <project_vertex>\nvDrapeWorld = (modelMatrix * vec4(transformed, 1.0)).xyz;');
    shader.fragmentShader = shader.fragmentShader
      .replace('#include <common>', `#include <common>\n${DRAPE_GLSL}\n${LOOK_GLSL}\n${F_PARS}`)
      .replace('#include <color_fragment>', F_COLOR)
      .replace('#include <normal_fragment_begin>', F_NORMAL)
      .replace('#include <lights_fragment_begin>', SPOT_BEGIN)
      .replace('#include <lights_physical_fragment>', F_SHEEN)
      .replace('#include <opaque_fragment>', F_OUT);
  };
  mat.customProgramCacheKey = () => 'velvet-drape-v8';
  const drapeH = o.drapeTop - o.floorY + 0.1;
  const geo = new THREE.PlaneGeometry(o.drapeWidth, drapeH, o.segX, o.segY);
  geo.translate(0, o.floorY - 0.1 + drapeH / 2, 0); // 로컬 = 월드(기울이기 전)
  const drape = new THREE.Mesh(geo, mat);
  drape.rotation.x = o.tilt;
  drape.frustumCulled = false; // 변위로 경계가 바뀐다
  drape.updateMatrixWorld(true);
  uni.uModelRot.value.setFromMatrix4(drape.matrixWorld);
  scene.add(drape);

  // --- 바닥: 벨벳 단이 닿는 어두운 광택 바닥 ---
  const floorUni = {...look, uSpill: {value: new THREE.Vector4(0, 0.8, o.floorSpill, 0.5)}, uSpillColor: {value: new THREE.Color(o.keyColor)}};
  const floorMat = new THREE.MeshStandardMaterial({color: o.floorColor, roughness: o.floorRough, metalness: 0, dithering: true});
  floorMat.onBeforeCompile = (shader) => {
    Object.assign(shader.uniforms, floorUni);
    shader.vertexShader = shader.vertexShader
      .replace('#include <common>', '#include <common>\nvarying vec3 vFloorW;')
      .replace('#include <project_vertex>', '#include <project_vertex>\nvFloorW = (modelMatrix * vec4(transformed, 1.0)).xyz;');
    shader.fragmentShader = shader.fragmentShader
      .replace('#include <common>', `#include <common>\nvarying vec3 vFloorW;\nuniform vec4 uSpill;\nuniform vec3 uSpillColor;\n${LOOK_GLSL}`)
      .replace('#include <opaque_fragment>', FLOOR_OUT);
  };
  floorMat.customProgramCacheKey = () => 'velvet-floor-v1';
  const floorGeo = new THREE.PlaneGeometry(14, 8);
  const floor = new THREE.Mesh(floorGeo, floorMat);
  floor.rotation.x = -Math.PI / 2;
  floor.position.set(0, o.floorY, 3.7);
  scene.add(floor);

  // --- 빛: 위 중앙의 따뜻한 다운라이트 하나(고보로 부드러운 웅덩이), 오른쪽 가장자리 코발트 림(세로 띠 면광원), 아주 약한 반구 필 ---
  RectAreaLightUniformsLib.init();
  const cookie = makeCookie();
  const key = new THREE.SpotLight(o.keyColor, o.keyIntensity, 0, o.keyAngle, o.keyPenumbra, 2);
  key.map = cookie;
  const keyTarget = new THREE.Object3D();
  key.target = keyTarget;
  const rim = new THREE.RectAreaLight(o.rimColor, o.rimNits, 0.04, 1.2);
  const fill = new THREE.HemisphereLight(o.fillSky, o.fillGround, o.fill);
  fill.position.set(0, 1, 0.6); // 하늘 쪽을 약간 앞으로 — 능선이 조금 더 받는다
  scene.add(key, keyTarget, rim, fill);

  // --- 먼지: 빛줄기 안의 28개(중경) ---
  const N = Math.max(0, Math.min(40, Math.round(o.dust)));
  const beam = new Float32Array(N * 4), drift = new Float32Array(N * 4);
  for (let i = 0; i < N; i++) {
    beam.set([0.5 + 0.47 * rand(), Math.sqrt(rand()) * 0.8, rand() * TAU, 0.6 + 0.8 * rand()], i * 4);
    drift.set([rand() * TAU, rand() * TAU, rand() * TAU, 0.03 + 0.05 * rand()], i * 4);
  }
  const dustGeo = new THREE.BufferGeometry();
  dustGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
  dustGeo.setAttribute('aBeam', new THREE.BufferAttribute(beam, 4));
  dustGeo.setAttribute('aDrift', new THREE.BufferAttribute(drift, 4));
  const dustUni = {
    uPhase: {value: 0}, uKeyPos: {value: new THREE.Vector3()}, uKeyTgt: {value: new THREE.Vector3()}, uTan: {value: Math.tan(o.keyAngle)},
    uPx: {value: 1000}, uDiam: {value: 0.013}, uGain: {value: o.dustGain}, uColor: {value: new THREE.Color(o.keyColor)},
  };
  const dustMat = new THREE.ShaderMaterial({
    uniforms: dustUni, vertexShader: DUST_VERT, fragmentShader: DUST_FRAG,
    transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, toneMapped: false,
  });
  const dust = new THREE.Points(dustGeo, dustMat);
  dust.frustumCulled = false;
  dust.renderOrder = 2;
  if (N > 0) scene.add(dust);

  // 0 at 4:5, 1 at 9:16 — 세로가 길수록 카메라를 물려 화면 폭(주름 폭 비율)을 맞추고, 웅덩이를 위로·짧게
  const view = {portrait: 0, aspect: 0.8, D: o.camDist, halfH: 1, halfW: 1, poolY: 0.5, lookY: 0, keyRise: o.keyRise, keyFront: o.keyFront, keyGain: 1};
  const res = new THREE.Vector2();
  let cookieKey = '';
  function layout() {
    const p = view.portrait, L = THREE.MathUtils.lerp;
    view.D = L(o.camDist, o.camDist916, p);
    view.halfH = view.D * Math.tan(THREE.MathUtils.degToRad(o.fov / 2));
    view.halfW = view.halfH * view.aspect;
    view.lookY = L(0, o.lookY916, p);
    view.keyRise = L(o.keyRise, o.keyRise916, p); // 9:16 은 등기구를 벽에 더 가깝게 — 웅덩이가 작아져 위 가장자리까지 화면 안에 든다
    view.keyFront = L(o.keyFront, o.keyFront916, p);
    view.poolY = view.lookY + view.halfH * (1 - 2 * L(o.poolY45, o.poolY916, p));
    const f0 = L(o.sinkStart45, o.sinkStart916, p), f1 = L(o.sinkFull45, o.sinkFull916, p);
    look.uSink.value.set(view.lookY + view.halfH * (1 - 2 * f1), view.lookY + view.halfH * (1 - 2 * f0)); // (아래 y, 위 y)
    uni.uSpotMask.value.set(view.lookY + view.halfH * (1 - 2 * L(o.maskBot45, o.maskBot916, p)), view.lookY + view.halfH * (1 - 2 * L(o.maskTop45, o.maskTop916, p)), L(o.maskCurve45, o.maskCurve916, p), 0);
    view.keyGain = L(1, o.keyGain916, p); // 9:16 은 등기구가 가까워 같은 세기면 더 밝다
    const up = Number(L(o.cookieUp, o.cookieUp916, p).toFixed(4)), down = Number(L(o.cookieDown, o.cookieDown916, p).toFixed(4));
    const wide = Number(L(o.cookieWide, o.cookieWide916, p).toFixed(4));
    if (`${up}/${down}/${wide}` !== cookieKey) { fillCookie(cookie, o, up, down, wide); cookieKey = `${up}/${down}/${wide}`; }
  }
  function setSize(w, h) {
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
    view.aspect = w / h;
    view.portrait = THREE.MathUtils.clamp((0.8 - w / h) / (0.8 - 0.5625), 0, 1);
    renderer.getDrawingBufferSize(res);
    look.uRes.value.copy(res);
    dustUni.uPx.value = res.y / (2 * Math.tan(THREE.MathUtils.degToRad(o.fov / 2)));
    layout();
  }

  // Frame at t seconds — pure function of t. 정수 주파수만.
  function renderAt(t) {
    const u = phase(t, o.loopSeconds);
    uni.uPhase.value = u; // 주름 흔들림·숨쉬기(셰이더)
    dustUni.uPhase.value = u;
    // 속도 2 — 사건: 바람 한 줄기가 천을 왼쪽→오른쪽으로 밀고 지나가고(위상 eventAt 중심, ease-in-out) 빛이 숨쉰다
    const ev = pulse(u, o.eventAt, o.eventWidth);
    const gp = sweepPos(u, o.eventAt, o.eventWidth);
    uni.uGust.value.set(gp * view.halfW * o.gustTravel, o.gustPush * ev, o.gustWidth, o.gustBulge * ev);
    // 속도 1 — 드리프트: 웅덩이가 아주 조금 흔들리고, 카메라가 1~2% 시차로 숨쉰다
    key.position.set(o.keyX + 0.04 * Math.sin(TAU * u + 2.2), view.poolY + view.keyRise, view.keyFront);
    keyTarget.position.set(0.05 * Math.sin(TAU * u + 0.7), view.poolY - o.keyDrop, 0);
    key.intensity = o.keyIntensity * view.keyGain * (1 + o.breathe * ev);
    key.angle = o.keyAngle * (1 + o.spread * ev);
    dustUni.uKeyPos.value.copy(key.position);
    dustUni.uKeyTgt.value.copy(keyTarget.position);
    dustUni.uTan.value = Math.tan(key.angle);
    dustUni.uGain.value = o.dustGain * (o.keyIntensity / 820) * view.keyGain * (1 + o.breathe * ev);
    rim.position.set(view.halfW + 0.05, view.poolY - 0.35, 0.15);
    rim.lookAt(view.halfW - 0.3, view.poolY - 0.35, -0.05);
    camera.position.set(o.parallax * Math.sin(TAU * u), view.lookY + 0.015 * Math.sin(TAU * u + 1.3), view.D);
    camera.lookAt(0, view.lookY, 0);
    renderer.render(scene, camera);
  }

  function dispose() {
    geo.dispose(); mat.dispose(); cookie.dispose(); floorGeo.dispose(); floorMat.dispose(); dustGeo.dispose(); dustMat.dispose();
  }

  const sz = renderer.getSize(new THREE.Vector2());
  setSize(sz.x, sz.y);
  return {scene, camera, renderAt, setSize, dispose, params: o};
}

// Web rules v2 (keep as is when copying — 복사할 때 그대로 둔다):
//   DPR ≤ 1.5 · 30 fps cap (rAF, time gated) · IntersectionObserver pause off-screen · visibilitychange pause
//   prefers-reduced-motion → one still frame · webglcontextlost → hide canvas + onUnavailable(reason) so the host shows the poster
//   ResizeObserver (fallback: window resize) · phase continues across pauses (elapsed time, not wall clock)
export function mountWeb(canvas, params = {}, {onUnavailable} = {}) {
  const o = {...DEFAULTS, ...params};
  const FRAME_MS = 1000 / 30;
  const dprNow = () => Math.min(window.devicePixelRatio || 1, 1.5, o.maxDpr ?? 1.5);
  let renderer, s;
  try {
    renderer = createRenderer(canvas, {width: canvas.clientWidth || 640, height: canvas.clientHeight || 800, dpr: dprNow()});
    s = createScene(renderer, o);
  } catch (e) {
    canvas.style.visibility = 'hidden';
    onUnavailable?.('webgl-unavailable', e);
    return {renderer: null, scene: null, destroy() {}};
  }
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
  let raf = 0, last = 0, lastDraw = 0, elapsed = 0, onScreen = true, lost = false;
  const draw = () => s.renderAt(reduce.matches ? o.stillTime : elapsed % o.loopSeconds);
  const frame = (now) => {
    raf = requestAnimationFrame(frame);
    if (now - lastDraw < FRAME_MS - 0.5) return; // 30 fps cap
    lastDraw = now - ((now - lastDraw) % FRAME_MS);
    if (last) elapsed += (now - last) / 1000;
    last = now;
    s.renderAt(elapsed % o.loopSeconds);
  };
  const stop = () => { if (raf) cancelAnimationFrame(raf); raf = 0; last = 0; };
  const sync = () => {
    if (lost) return;
    const run = onScreen && !reduce.matches && document.visibilityState !== 'hidden';
    if (!run) { stop(); if (reduce.matches) draw(); }
    else if (!raf) raf = requestAnimationFrame(frame);
  };
  const resize = () => {
    const cw = canvas.clientWidth, ch = canvas.clientHeight;
    if (!cw || !ch || lost) return;
    renderer.setPixelRatio(dprNow());
    renderer.setSize(cw, ch, false);
    s.setSize(cw, ch);
    if (!raf) draw();
  };
  const onLost = (e) => { e.preventDefault(); lost = true; stop(); canvas.style.visibility = 'hidden'; onUnavailable?.('context-lost'); };
  const onRestored = () => { lost = false; canvas.style.visibility = ''; resize(); sync(); };
  const io = typeof IntersectionObserver !== 'undefined' ? new IntersectionObserver((es) => { onScreen = es.some((x) => x.isIntersecting); sync(); }) : null;
  const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
  io?.observe(canvas);
  if (ro) ro.observe(canvas); else window.addEventListener('resize', resize);
  reduce.addEventListener('change', sync);
  document.addEventListener('visibilitychange', sync);
  canvas.addEventListener('webglcontextlost', onLost);
  canvas.addEventListener('webglcontextrestored', onRestored);
  resize();
  sync();
  return {
    renderer, scene: s,
    destroy() {
      stop();
      io?.disconnect();
      if (ro) ro.disconnect(); else window.removeEventListener('resize', resize);
      reduce.removeEventListener('change', sync);
      document.removeEventListener('visibilitychange', sync);
      canvas.removeEventListener('webglcontextlost', onLost);
      canvas.removeEventListener('webglcontextrestored', onRestored);
      s.dispose(); renderer.dispose();
    },
  };
}
```

importmap(정적 HTML 전용 — npm 으로 설치했으면 필요 없다):
```html
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"}}</script>
```

## 지켜 줄 것
- 코드에 들어 있는 성능·접근성 장치는 지우거나 끄지 않는다: 기기 픽셀 비율 1.5 상한 · 초당 30프레임 상한 · 화면 밖(IntersectionObserver)·탭 숨김(visibilitychange)이면 정지 · 동작 줄이기면 정지 화면 한 장 · 크기 변화 추적(ResizeObserver).
- 배경 위 글자는 대비를 확인한다(어두운 배경엔 밝은 글자). 필요하면 배경 쪽에 반투명 막을 한 겹 더하되 장면 값은 건드리지 않는다.
- 서버 렌더에서 깨지지 않게 WebGL 코드는 브라우저에서만 돈다(Next.js 는 "use client" + useEffect).
- three.js 는 외부로 아무것도 보내지 않는다 — 따로 끌 설정이 없다.
- 끝나면 바뀐 파일 목록과, 실제로 움직이는 배경이 보이는지 확인한 방법(개발 서버 화면 등)을 알려 줘.

출처: Duchamp 모션 배경 https://duchamp.app/readymade/backgrounds/three-velvet-drape

비슷한 배경

  • 흑요석 조약돌

    3D · 숨 쉬는 검은 유리 · 10초 반복 · 무료

  • 점의 구름

    3D · 천장 빛 아래 숨 쉬는 구 · 10초 반복 · 무료

  • 코발트 실크

    셰이더 · 빛을 머금은 깊은 파랑 · 7초 반복 · 무료

  • 리브 유리

    셰이더 · 골을 타고 흐르는 빛 · 7초 반복 · 무료