Assets · Motion background
인디고 벨벳
천장 다운라이트

미리보기는 가벼운 540px 판이에요. 받는 영상은 아래 원본 해상도예요.
1080×1350
- 파일 (MP4)
- 4:5 · 1080×1350 · 5.3MB
- 9:16 · 1080×1920 · 6.0MB
- 길이·반복
- 10초 완전 반복 · 30fps
- 라이선스
- 코드 렌더(three.js — MIT), 폰트·이미지 없음
웹페이지에 실시간으로
영상 파일 대신 같은 장면을 브라우저에서 바로 그릴 수 있어요(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


