웹페이지에 실시간으로

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

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-obsidian-flow.large.webp
- 같은 장면의 영상(9:16 MP4, 반복): https://pub-a4aa81cc9139413c99493d10e0d4398d.r2.dev/backgrounds/three-obsidian-flow/abb9a2f114fda67aa09de0b3367e361b18ff20776e30ffe6f7acefc41352299f.mp4

## 넣는 방법
1. `npm i three@0.186.1` (pnpm·yarn 이면 그 도구로). 버전을 올리지 않는다. 번들러 없는 정적 HTML 이면 설치 대신 아래 importmap(같은 버전의 CDN 고정)을 <head> 에 넣는다.
2. 아래 코드를 장면 모듈 `obsidian-flow.scene.js` 로 바이트 그대로 저장한다(기존 lib·utils 폴더 관례를 따른다). 장면 값(색·속도·seed·점 수 같은 숫자)은 바꾸지 않는다. 3단계의 컴포넌트와 파일 이름을 다르게 둔다 — 같은 폴더에 `ObsidianFlowBackground.js`·`ObsidianFlowBackground.tsx` 를 같이 두면 TypeScript 가 `./ObsidianFlowBackground.js` 를 컴포넌트 자신으로 읽는다.
   - TypeScript 프로젝트면 .js 는 그대로 두고, 옆에 같은 이름의 선언 파일 `obsidian-flow.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 면 `ObsidianFlowBackground.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-obsidian-flow.large.webp)를 CSS 배경(background-size: cover)으로 깔아 둔다 — 처음 그려지기 전에도, 모듈이 캔버스를 숨겼을 때(WebGL 불가·컨텍스트 잃음)도 그 포스터가 보이므로 따로 감지할 필요가 없다. 포스터를 <img> 로 따로 보여 줄 때만 `onUnavailable` 에서 상태를 바꾼다. `canvas.getContext(...)` 로 미리 떠보지 않는다(기본 속성 컨텍스트가 먼저 잡혀 three 의 설정이 무시된다). `webglcontextlost`·`webglcontextrestored` 도 따로 처리하지 않는다(모듈이 처리한다).
5. `prefers-reduced-motion: reduce` 면 코드가 스스로 정지 화면 한 장만 그리고 루프를 멈춘다(`mountWeb` 안에 들어 있다) — 끄지 않는다. 포스터만 보여 주고 싶으면 그 경우에는 `mountWeb` 호출을 건너뛴다.

```js
// three-obsidian-flow — 흑요석 조약돌 · 숨 쉬는 검은 유리 (three.js, vanilla, 단일 파일).
// 잘 닦인 검은 화산 유리(흑요석) 조약돌 하나가 화면 오른쪽 위에 놓여 오른쪽 가장자리에서만 잘리고, 아주 천천히 숨쉬듯 형태를 바꾼다.
// 재질은 유전체 유리(금속 아님): 정면은 어두운 방을 4% 만 비추고(몸통 Y 4~12 — 순수 검정 아님), 비스듬한 가장자리로 갈수록 프레넬로 반사가 선다.
// 빛은 환경맵 하나: 코드로 만든 어두운 전시실을 PMREMGenerator.fromScene 으로 굽는다(RoomEnvironment·외부 HDR 없음) —
// 머리 위 앞쪽 따뜻한 큰 소프트박스(키, 가장자리가 보이는 또렷한 사각 반사) + 왼쪽 앞 넓고 어두운 필 + 카메라 뒤 가는 가로 띠(형태를 따라 휘는 선) +
// 왼쪽 뒤 코발트 세로 띠(림 ≤15%) + 아래의 어둑한 따뜻한 바닥 반사판 + 인디고 벽(어두운 방). 직접광 없음.
// 두 속도: ① 드리프트 — 변위는 저주파 4D 심플렉스를 (y, w) 평면의 원 위에서 샘플해 루프마다 정확히 한 바퀴(숨쉬기),
//             원 위의 속도는 루프 안에서 한 번 느려졌다 빨라진다(정속 아님)
//          ② 사건 — 세로로 긴 따뜻한 띠 조명(해석식, 반사 벡터의 방위각)이 루프당 한 번 표면을 왼쪽→오른쪽으로 쓸고 지나간다(1.5초, ease-in-out).
// 배경: 잉크~인디고 벽(톤매핑 제외, 팔레트 그대로) + 조약돌 뒤 아주 약한 따뜻한 후광. 4:5 하단 글자 구역·9:16 중앙 띠는 조용하다.
// 구도: 4:5 는 중심 (0.72, 0.31)·반지름 화면 폭 0.33, 9:16 은 가로로 긴 강돌(1.3배)로 중심 (0.80, 0.155)·반지름 0.25(위 가장자리 ≥40px, 아래 끝 ≤0.29). 오른쪽 한 변만 자른다.
// 모든 움직임은 u = t/loopSeconds 의 정수 주파수 → frame(loopSeconds) === frame(0).
//
// 파일 하나가 렌더(HyperFrames hf-seek)와 웹(mountWeb) 양쪽의 같은 소스다 — scenes/_template.js 계약.
//   createRenderer(canvas, {width, height, dpr}) → WebGLRenderer, SRGB 출력, DPR 1 (이 장면은 createScene 에서 Neutral 톤매핑을 켠다 — 배경은 toneMapped:false)
//   createScene(renderer, params)               → {scene, camera, renderAt(t), setSize(w, h), dispose(), params} — renderAt 는 t 의 순수 함수
//   mountWeb(canvas, params, {onUnavailable})   → 웹 규칙 v2
// 4D 심플렉스 노이즈 GLSL: Ashima Arts / Ian McEwan, webgl-noise (MIT License, Copyright (C) 2011 Ashima Arts) — https://github.com/ashima/webgl-noise
import * as THREE from 'three';
import {mergeVertices} from 'three/addons/utils/BufferGeometryUtils.js';

export const meta = {
  id: 'three-obsidian-flow',
  background: '#04080E', // ink — 모서리는 여기로 가라앉는다
};

// 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: 3,
  // 조약돌 형태 — 저주파 변위(매끈한 유리 표면)
  detail: 80, // IcosahedronGeometry detail — 20·81² = 131,220 삼각형(정점 병합 ≈65.6k)
  amp: 0.08, // 변위 진폭(반지름 비율) — 볼록한 조약돌을 벗어나지 않게
  freq: 0.8, // 1옥타브 주파수
  oct2: 0.06, // 2옥타브 가중치(주파수 ×2.03)
  flat: 0.86, // 카메라 쪽 두께(z) 비율 — 조약돌은 조금 납작하다
  morph: 0.55, // 루프에 한 바퀴 도는 4D 원의 반지름 — 숨쉬기 크기
  pace: 0.3, // 드리프트 속도 변조(0 = 정속). 원 위 각도 θ' = θ − pace·sin(θ − 1.2)
  // 재질 — 유전체 검은 유리(흑요석) + 클리어코트
  glassColor: '#020304',
  roughness: 0.07,
  clearcoat: 1,
  clearcoatRoughness: 0.025,
  ior: 1.5,
  envIntensity: 1,
  // 환경맵(어두운 전시실, 선형 휘도)
  keyColor: '#FFF1E0',
  key: 9, // 머리 위 앞쪽 따뜻한 큰 소프트박스 — 가장자리가 보이는 또렷한 사각 반사
  keyEl: 42, // 고도(°)
  keyAz: -18, // 방위(°, 카메라 쪽 0, 오른쪽 +)
  line: 1.6, // 카메라 뒤 눈높이 조금 위의 가는 가로 띠 — 형태를 따라 휘는 선 하나
  fill: 0.22, // 왼쪽 앞의 넓고 어두운 판 — 몸통 왼쪽에 넓은 그라데이션
  wall: 3.4, // 인디고 벽(방, 팔레트 #293546 선형 × 배수) — 몸통이 순수 검정이 되지 않게(Y 4~12)
  ceiling: 0.3, // 천장 쪽(#A69C98 선형 × 배수)은 조금 더 밝은 따뜻한 회색
  rim: 1.1, // 왼쪽 뒤 코발트 세로 띠(림 ≤15%)
  floor: 0.5, // 아래의 어둑한 따뜻한 바닥 반사판(#817065 × 배수) — 아래 몸통에 따뜻한 그라데이션
  // 사건 — 세로 띠 조명이 한 번 쓸고 지나간다
  sweep: 4.5,
  sweepAt: 0.571, // 루프 위상(0~1) 중심 — 10초 루프에서 5.7초
  sweepWidth: 0.075, // 반폭(위상) — 0.075 × 10초 × 2 = 1.5초
  // 배경
  glow: 0.035, // 조약돌 뒤 따뜻한 후광(선형, 아주 약하게) — 윤곽 둘레에만(오라가 되지 않게)
  // 구도 — 중심(화면 비율, 왼쪽·위 기준)과 반지름(화면 폭 비율), 긴 축 각도(rad). 4:5 ↔ 9:16 보간
  cx45: 0.72, cy45: 0.31, r45: 0.33, axis45: 0.33, stretch45: 1.14, // stretch = 긴 축 방향으로 늘인 비율
  cx916: 0.8, cy916: 0.155, r916: 0.25, axis916: 0.06, stretch916: 1.3,
  envTilt916: 0.12, // 9:16 에서 환경을 조금 앞으로 기울여(rad) 위 하이라이트를 보이는 쪽으로
  parallax: 0.05, // 카메라 옆 이동 폭(월드 단위, 1~3% 시차)
  exposure: 1.0,
  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;
}

// 사건 창 안에서 -1→+1 로 진행하는 값(창 밖은 0 — 그때 세기도 0 이라 보이지 않는다)
const sweepPos = (u, c, w) => {
  const d = ((((u - c + 0.5) % 1) + 1) % 1) - 0.5; // -0.5..0.5, 0 = 중심
  if (Math.abs(d) >= w) return 0;
  const x = (d / w + 1) / 2; // 0..1
  return x * x * (3 - 2 * x) * 2 - 1;
};

// GLSL — 4D 심플렉스 노이즈(Ashima Arts / Ian McEwan, MIT) + 저주파 변위 + 법선 재계산(접선 방향 이웃 두 점의 변위로 외적)
const NOISE_GLSL = /* glsl */ `
uniform vec2 uLoop1;
uniform vec2 uLoop2;
uniform float uAmp;
uniform float uFreq;
uniform float uOct2;
vec4 mod289v4(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
float mod289f(float x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute4(vec4 x) { return mod289v4(((x * 34.0) + 10.0) * x); }
float permutef(float x) { return mod289f(((x * 34.0) + 10.0) * x); }
vec4 taylorInvSqrt4(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float taylorInvSqrtf(float r) { return 1.79284291400159 - 0.85373472095314 * r; }
vec4 grad4(float j, vec4 ip) {
  const vec4 ones = vec4(1.0, 1.0, 1.0, -1.0);
  vec4 p, s;
  p.xyz = floor(fract(vec3(j) * ip.xyz) * 7.0) * ip.z - 1.0;
  p.w = 1.5 - dot(abs(p.xyz), ones.xyz);
  s = vec4(lessThan(p, vec4(0.0)));
  p.xyz = p.xyz + (s.xyz * 2.0 - 1.0) * s.www;
  return p;
}
float snoise4(vec4 v) {
  const vec4 C = vec4(0.138196601125011, 0.276393202250021, 0.414589803375032, -0.447213595499958);
  const float F4 = 0.309016994374947451;
  vec4 i = floor(v + dot(v, vec4(F4)));
  vec4 x0 = v - i + dot(i, C.xxxx);
  vec4 i0;
  vec3 isX = step(x0.yzw, x0.xxx);
  vec3 isYZ = step(x0.zww, x0.yyz);
  i0.x = isX.x + isX.y + isX.z;
  i0.yzw = 1.0 - isX;
  i0.y += isYZ.x + isYZ.y;
  i0.zw += 1.0 - isYZ.xy;
  i0.z += isYZ.z;
  i0.w += 1.0 - isYZ.z;
  vec4 i3 = clamp(i0, 0.0, 1.0);
  vec4 i2 = clamp(i0 - 1.0, 0.0, 1.0);
  vec4 i1 = clamp(i0 - 2.0, 0.0, 1.0);
  vec4 x1 = x0 - i1 + C.xxxx;
  vec4 x2 = x0 - i2 + C.yyyy;
  vec4 x3 = x0 - i3 + C.zzzz;
  vec4 x4 = x0 + C.wwww;
  i = mod289v4(i);
  float j0 = permutef(permutef(permutef(permutef(i.w) + i.z) + i.y) + i.x);
  vec4 j1 = permute4(permute4(permute4(permute4(
      i.w + vec4(i1.w, i2.w, i3.w, 1.0))
    + i.z + vec4(i1.z, i2.z, i3.z, 1.0))
    + i.y + vec4(i1.y, i2.y, i3.y, 1.0))
    + i.x + vec4(i1.x, i2.x, i3.x, 1.0));
  vec4 ip = vec4(1.0 / 294.0, 1.0 / 49.0, 1.0 / 7.0, 0.0);
  vec4 p0 = grad4(j0, ip);
  vec4 p1 = grad4(j1.x, ip);
  vec4 p2 = grad4(j1.y, ip);
  vec4 p3 = grad4(j1.z, ip);
  vec4 p4 = grad4(j1.w, ip);
  vec4 norm = taylorInvSqrt4(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
  p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
  p4 *= taylorInvSqrtf(dot(p4, p4));
  vec3 m0 = max(0.6 - vec3(dot(x0, x0), dot(x1, x1), dot(x2, x2)), 0.0);
  vec2 m1 = max(0.6 - vec2(dot(x3, x3), dot(x4, x4)), 0.0);
  m0 = m0 * m0;
  m1 = m1 * m1;
  return 49.0 * (dot(m0 * m0, vec3(dot(p0, x0), dot(p1, x1), dot(p2, x2))) + dot(m1 * m1, vec2(dot(p3, x3), dot(p4, x4))));
}
// 변위장 — 1옥타브는 (y, w) 평면의 원, 2옥타브는 (x, w) 평면의 원(두 배 속도)을 루프마다 정확히 돈다 → 위상 0 과 1 이 같은 모양
float blobField(vec3 p) {
  vec3 q = p * uFreq;
  float n = snoise4(vec4(q.x, q.y + uLoop1.x, q.z, uLoop1.y));
  n += uOct2 * snoise4(vec4(q.x * 2.03 + uLoop2.x + 4.1, q.y * 2.03 + 1.7, q.z * 2.03 + 8.3, uLoop2.y + 11.0));
  return n / (1.0 + uOct2);
}
vec3 blobDisp(vec3 p) { return p * (1.0 + uAmp * blobField(p)); }
`;
const NORMAL_GLSL = /* glsl */ `
vec3 nrm0 = normalize(position);
vec3 tng0 = normalize(cross(nrm0, abs(nrm0.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0)));
vec3 btg0 = cross(nrm0, tng0);
float epsD = 0.01;
vec3 dispA = blobDisp(nrm0);
vec3 dispB = blobDisp(normalize(nrm0 + tng0 * epsD));
vec3 dispC = blobDisp(normalize(nrm0 + btg0 * epsD));
vec3 objectNormal = normalize(cross(dispB - dispA, dispC - dispA));
#ifdef USE_TANGENT
  vec3 objectTangent = vec3( tangent.xyz );
#endif
`;
const POSITION_GLSL = /* glsl */ `
vec3 transformed = dispA;
#ifdef USE_ALPHAHASH
  vPosition = vec3( position );
#endif
`;
// 사건 — 세로로 긴 띠 조명을 반사 벡터의 방위각(카메라 쪽 +z 가 0, 오른쪽 +x 가 +)으로 해석해 IBL 에 더한다(PMREM 을 다시 굽지 않는다)
const SWEEP_PARS = /* glsl */ `
uniform float uSweep;
uniform float uSweepAz;
uniform float uSweepW;
uniform vec3 uSweepColor;
float sweepBand(vec3 viewR, float widen) {
  vec3 r = transformDirectionByInverseViewMatrix(viewR, viewMatrix);
  float d = atan(r.x, r.z) - uSweepAz;
  d = atan(sin(d), cos(d));
  float el = r.y;
  float w = uSweepW * widen;
  return exp(-d * d / (2.0 * w * w)) * smoothstep(-0.5, 0.0, el) * (1.0 - smoothstep(0.6, 0.95, el));
}
`;
const SWEEP_APPLY = /* glsl */ `
#include <lights_fragment_maps>
if (uSweep > 0.0) {
  // 바탕 금속(거침): 넓고 약한 빛 / 클리어코트(매끈): 가늘고 또렷한 선 — 빛 한 줄기가 표면을 쓸고 지나간다
  vec3 rvB = normalize(mix(reflect(-geometryViewDir, geometryNormal), geometryNormal, pow4(material.roughness)));
  radiance += uSweepColor * (0.45 * uSweep * sweepBand(rvB, 1.8));
  #ifdef USE_CLEARCOAT
    vec3 rvC = reflect(-geometryViewDir, geometryClearcoatNormal);
    clearcoatRadiance += uSweepColor * (uSweep * sweepBand(rvC, 1.0));
  #endif
}
`;

// 소프트박스 재질 — 가장자리가 부드럽게 꺼지는 발광 사각형(선형 HDR 값 그대로, 환경 씬 전용)
// hot > 0 이면 가운데가 밝은 가우스 판(반사가 밝은 심 + 넓은 그라데이션으로 읽힌다)
function softboxMaterial(color, intensity, soft, hot = 0) {
  return new THREE.ShaderMaterial({
    uniforms: {uColor: {value: new THREE.Color(color).multiplyScalar(intensity)}, uSoft: {value: new THREE.Vector2(soft[0], soft[1])}, uHot: {value: hot}},
    vertexShader: 'varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
    fragmentShader: 'varying vec2 vUv; uniform vec3 uColor; uniform vec2 uSoft; uniform float uHot; void main() { vec2 d = abs(vUv - 0.5) * 2.0; float f = (1.0 - smoothstep(1.0 - uSoft.x, 1.0, d.x)) * (1.0 - smoothstep(1.0 - uSoft.y, 1.0, d.y)); f *= exp(-uHot * dot(d, d)); gl_FragColor = vec4(uColor * f, 1.0); }',
    side: THREE.DoubleSide, depthWrite: false, depthTest: false, toneMapped: false,
    transparent: true, blending: THREE.AdditiveBlending, // 돔 위에 빛을 더한다(가장자리가 돔보다 어두운 테를 만들지 않게)
  });
}


// 환경맵 — 코드로 만든 어두운 전시실을 PMREM 으로 굽는다. 조약돌은 원점 근처라 무한히 먼 환경으로 근사된다.
// 어느 방향도 순수 검정이 아니다(인디고 벽 · 따뜻한 회색 천장 · 어둑한 바닥) — 정면 몸통이 방을 4% 만 비춰도 Y 4~12 로 읽힌다.
function buildEnvironment(renderer, o) {
  const env = new THREE.Scene();
  env.background = new THREE.Color(0x000000);
  const disposables = [];
  const lin = (hex, k) => new THREE.Color(hex).multiplyScalar(k); // 팔레트 색(선형) × 배수
  const domeMat = new THREE.ShaderMaterial({
    uniforms: {uTop: {value: lin(PALETTE.spot, o.ceiling)}, uWall: {value: lin(PALETTE.indigoLit, o.wall)}, uLow: {value: lin(PALETTE.indigoWall, o.wall * 0.45)}},
    vertexShader: 'varying vec3 vD; void main() { vD = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
    // 벽은 고르지 않다 — 왼쪽 앞이 밝고 오른쪽 뒤가 어둡고, 넓은 세로 패널 두 개가 아주 조금 밝다(윤곽 반사가 고른 테두리로 보이지 않게)
    fragmentShader: 'varying vec3 vD; uniform vec3 uTop; uniform vec3 uWall; uniform vec3 uLow; void main() { vec3 d = normalize(vD); float y = d.y; float az = atan(d.x, d.z); float side = 0.3 + 0.7 * (0.5 + 0.5 * cos(az + 0.9)); float panel = 1.0 + 0.6 * exp(-pow((az + 2.3) / 0.22, 2.0)) + 0.4 * exp(-pow((az - 2.0) / 0.3, 2.0)); vec3 c = mix(uLow, uWall * side * panel, smoothstep(-0.5, 0.1, y)); c = mix(c, uTop, smoothstep(0.3, 0.95, y)); gl_FragColor = vec4(c, 1.0); }',
    side: THREE.BackSide, depthWrite: false, depthTest: false, toneMapped: false,
  });
  const domeGeo = new THREE.SphereGeometry(30, 48, 24);
  const dome = new THREE.Mesh(domeGeo, domeMat);
  dome.renderOrder = 0;
  env.add(dome);
  disposables.push(domeMat, domeGeo);
  const geo = new THREE.PlaneGeometry(1, 1);
  disposables.push(geo);
  // 방향(방위각: 카메라 쪽 +z 가 0°, 오른쪽 +x 가 +, 고도)과 거리로 놓고 원점을 바라보게 한다
  const add = (mat, azDeg, elDeg, dist, sx, sy) => {
    disposables.push(mat);
    const m = new THREE.Mesh(geo, mat);
    const az = THREE.MathUtils.degToRad(azDeg), el = THREE.MathUtils.degToRad(elDeg);
    m.scale.set(sx, sy, 1);
    m.position.set(dist * Math.cos(el) * Math.sin(az), dist * Math.sin(el), dist * Math.cos(el) * Math.cos(az));
    m.lookAt(0, 0, 0);
    m.renderOrder = 1;
    env.add(m);
  };
  // 필: 왼쪽 앞의 넓고 어두운 판 — 몸통 왼쪽 절반에 넓은 그라데이션 반사(방이 비친다 → 검은 유리로 읽힌다)
  add(softboxMaterial(o.keyColor, o.fill, [1.0, 1.0], 1.0), -42, 4, 8, 14, 8);
  // 키: 머리 위 앞쪽의 따뜻한 큰 소프트박스 — 가장자리가 보이는 또렷한 사각 반사(가운데가 조금 더 밝다)
  add(softboxMaterial(o.keyColor, o.key, [0.12, 0.16], 0.35), o.keyAz, o.keyEl, 8, 7.5, 3.2);
  // 선: 카메라 뒤 눈높이 조금 위의 가는 가로 띠 — 몸통에 형태를 따라 휘는 가는 선 하나
  add(softboxMaterial(o.keyColor, o.line, [0.5, 0.6], 0.3), -6, 12, 8, 18, 0.55);
  // 림: 왼쪽 뒤의 코발트 세로 띠(왼쪽 가장자리에만) — 전기 코발트에 키 색을 조금 섞어 네온이 되지 않게
  add(softboxMaterial(new THREE.Color(PALETTE.electric).lerp(new THREE.Color(o.keyColor), 0.22), o.rim, [0.6, 0.5], 0.4), -125, 8, 8, 2.6, 10);
  // 바닥 반사판: 아래의 어둑한 따뜻한 판 — 아래 몸통에 따뜻한 그라데이션(바닥에서 올라오는 빛)
  add(softboxMaterial(PALETTE.bounce, o.floor, [0.9, 0.9], 0.6), 0, -72, 8, 22, 16);
  const pmrem = new THREE.PMREMGenerator(renderer);
  const rt = pmrem.fromScene(env, 0, 0.1, 100, {size: 256});
  pmrem.dispose();
  for (const d of disposables) d.dispose();
  return rt;
}

export function createScene(renderer, params = {}) {
  const o = {...DEFAULTS, ...params};
  // 유리 하이라이트만 Neutral 톤매핑으로 부드럽게 접는다(어두운 쪽은 선형 그대로 — ACES 처럼 몸통의 어두운 반사를 뭉개지 않는다).
  // 배경 셰이더는 toneMapped:false 라 팔레트 그대로
  renderer.toneMapping = THREE.NeutralToneMapping;
  renderer.toneMappingExposure = o.exposure;
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(meta.background);
  const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 60);
  const CAM_Z = 5.2, BG_Z = -4, VFOV = 30;
  const BG_K = (CAM_Z - BG_Z) / CAM_Z; // z=0 의 크기 → 배경 평면 위 크기

  // --- 배경: 잉크~인디고 벽 + 조약돌 뒤 위쪽의 아주 약한 따뜻한 후광(월드 평면이라 카메라가 움직이면 조약돌과 시차가 난다) ---
  const bgUni = {
    uWall: {value: new THREE.Color(PALETTE.indigoWall)},
    uInk: {value: new THREE.Color(PALETTE.ink)},
    uGlow: {value: new THREE.Color('#FFE2C6').multiplyScalar(o.glow * 0.62)},
    uFrame: {value: new THREE.Vector2(1, 1)}, // 배경 평면 위 화면 반폭·반높이
    uBlobC: {value: new THREE.Vector2()},
    uBlobR: {value: 1},
    uZone: {value: -0.42}, // 인디고가 가장 밝은 곳(화면 y, -1..1) — 글자 구역 쪽
  };
  const bgMat = new THREE.ShaderMaterial({
    uniforms: bgUni,
    vertexShader: 'varying vec3 vW; void main() { vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }',
    fragmentShader: /* glsl */ `
      #include <common>
      #include <dithering_pars_fragment>
      varying vec3 vW;
      uniform vec3 uWall; uniform vec3 uInk; uniform vec3 uGlow;
      uniform vec2 uFrame; uniform vec2 uBlobC; uniform float uBlobR; uniform float uZone;
      void main() {
        vec2 s = vW.xy / uFrame; // 화면 좌표(-1..1)
        // 벽: 글자 구역 쪽은 인디고, 위·네 모서리로 갈수록 잉크
        float v = smoothstep(0.35, 1.45, length((s - vec2(0.0, uZone)) * vec2(0.85, 0.72)));
        vec3 col = mix(uWall, uInk, v);
        // 후광: 조약돌 바로 뒤(조금 위), 윤곽 둘레에서만 보이는 따뜻한 빛 — 위에서 내려온 빛이 벽에 남긴 자국
        vec2 g = (vW.xy - (uBlobC + vec2(0.0, 0.18) * uBlobR)) / (uBlobR * 0.62);
        col += uGlow * exp(-0.5 * dot(g, g)) * 4.0;
        gl_FragColor = vec4(col, 1.0);
        #include <colorspace_fragment>
        #include <dithering_fragment>
      }`,
    toneMapped: false, depthWrite: false, dithering: true,
  });
  const bgGeo = new THREE.PlaneGeometry(60, 60);
  const bg = new THREE.Mesh(bgGeo, bgMat);
  bg.position.set(0, 0, BG_Z);
  bg.renderOrder = -1;
  scene.add(bg);

  // --- 조약돌: 아이코사헤드론(정점 병합) + 정점 셰이더 변위·법선, 유전체 검은 유리 + 클리어코트, 코드 전시실 환경맵 ---
  const envRT = buildEnvironment(renderer, o);
  const uni = {
    uLoop1: {value: new THREE.Vector2()}, uLoop2: {value: new THREE.Vector2()},
    uAmp: {value: o.amp}, uFreq: {value: o.freq}, uOct2: {value: o.oct2},
    uSweep: {value: 0}, uSweepAz: {value: 0}, uSweepW: {value: 0.11}, uSweepColor: {value: new THREE.Color(o.keyColor)},
  };
  const blobMat = new THREE.MeshPhysicalMaterial({
    color: o.glassColor, metalness: 0, roughness: o.roughness, ior: o.ior, specularIntensity: 1,
    clearcoat: o.clearcoat, clearcoatRoughness: o.clearcoatRoughness,
    envMap: envRT.texture, envMapIntensity: o.envIntensity, dithering: true,
  });
  blobMat.onBeforeCompile = (shader) => {
    Object.assign(shader.uniforms, uni);
    shader.vertexShader = NOISE_GLSL + shader.vertexShader
      .replace('#include <beginnormal_vertex>', NORMAL_GLSL)
      .replace('#include <begin_vertex>', POSITION_GLSL);
    shader.fragmentShader = shader.fragmentShader
      .replace('#include <lights_physical_pars_fragment>', '#include <lights_physical_pars_fragment>\n' + SWEEP_PARS)
      .replace('#include <lights_fragment_maps>', SWEEP_APPLY);
  };
  blobMat.customProgramCacheKey = () => 'obsidian-pebble-v12';
  let blobGeo = new THREE.IcosahedronGeometry(1, o.detail);
  blobGeo.deleteAttribute('normal');
  blobGeo.deleteAttribute('uv');
  blobGeo = mergeVertices(blobGeo, 1e-5);
  blobGeo.computeVertexNormals();
  const blob = new THREE.Mesh(blobGeo, blobMat);
  blob.frustumCulled = false; // 변위로 경계가 커진다
  // 늘이기는 부모(긴 축)에서, 노이즈 회전은 자식에서 — 늘인 방향은 고정되고 표면 무늬만 돈다
  const holder = new THREE.Group();
  holder.add(blob);
  scene.add(holder);

  // 4:5 → 0, 9:16 → 1. 세로 화면은 가로 시야(4:5 기준)를 고정하고 세로 시야를 넓힌다. 가로 화면(웹)은 세로 시야 30° 고정
  const view = {portrait: 0, halfW: 1, halfH: 1, R: 1, x: 0, y: 0, axis: 0.35, stretch: 1.12};
  function setSize(w, h) {
    const aspect = w / h;
    camera.aspect = aspect;
    const p = THREE.MathUtils.clamp((0.8 - aspect) / (0.8 - 0.5625), 0, 1);
    view.portrait = p;
    const tanV = Math.tan(THREE.MathUtils.degToRad(VFOV / 2));
    const tanH = tanV * Math.max(aspect, 0.8);
    camera.fov = THREE.MathUtils.radToDeg(2 * Math.atan(tanH / aspect));
    camera.updateProjectionMatrix();
    view.halfW = CAM_Z * tanH;
    view.halfH = (CAM_Z * tanH) / aspect;
    // 구도: 반지름은 화면 폭(가로 화면이면 높이×0.8) 기준, 중심은 화면 비율(왼쪽·위 기준)
    const base = Math.min(2 * view.halfW, 2 * view.halfH * 0.8);
    const L = THREE.MathUtils.lerp;
    view.R = L(o.r45, o.r916, p) * base;
    view.x = (2 * L(o.cx45, o.cx916, p) - 1) * view.halfW;
    view.y = (1 - 2 * L(o.cy45, o.cy916, p)) * view.halfH;
    view.axis = L(o.axis45, o.axis916, p);
    view.stretch = L(o.stretch45, o.stretch916, p); // 9:16 은 가로로 긴 강돌 — 위 띠 안에서 크게 보이게
    blobMat.envMapRotation.set(o.envTilt916 * p, 0, 0);
    bgUni.uFrame.value.set(view.halfW * BG_K, view.halfH * BG_K);
    bgUni.uZone.value = L(-0.42, 0.0, p); // 9:16 은 중앙 띠 쪽이 가장 밝은 인디고
  }

  function renderAt(t) {
    const u = phase(t, o.loopSeconds);
    const th = TAU * u;
    // 속도 1 — 드리프트(숨쉬기): 4D 원 한 바퀴(1옥타브) · 두 바퀴(2옥타브). 원 위 속도는 루프 안에서 한 번 느려졌다 빨라진다(θ' 는 θ 의 주기 함수)
    const thm = th - o.pace * Math.sin(th - 1.2);
    uni.uLoop1.value.set(o.morph * Math.cos(thm), o.morph * Math.sin(thm));
    uni.uLoop2.value.set(0.5 * o.morph * Math.cos(2 * thm + 1.3), 0.5 * o.morph * Math.sin(2 * thm + 1.3));
    const R = view.R;
    const br = R * (1 + 0.012 * Math.sin(th + 0.4));
    holder.position.set(view.x + 0.02 * R * Math.sin(th + 0.7), view.y + 0.02 * R * Math.sin(th), 0);
    holder.rotation.set(0, 0, view.axis + 0.05 * Math.sin(th + 0.9));
    holder.scale.set(br * view.stretch, br / Math.sqrt(view.stretch), br * o.flat);
    blob.rotation.set(0.05 * Math.sin(th + 2.0), 0.16 * Math.sin(thm), 0.04 * Math.sin(th + 2.6));
    camera.position.set(o.parallax * Math.sin(th), 0.02 * Math.sin(th + 1.3), CAM_Z);
    camera.lookAt(0, 0, 0);
    bgUni.uBlobC.value.set(holder.position.x * BG_K, holder.position.y * BG_K);
    bgUni.uBlobR.value = R * BG_K;
    // 속도 2 — 사건: 세로 띠 조명이 왼쪽(-115°)에서 오른쪽(+100°)으로 한 번 쓸고 지나간다(위상 sweepAt 중심, ease-in-out)
    const ev = pulse(u, o.sweepAt, o.sweepWidth);
    const sp = sweepPos(u, o.sweepAt, o.sweepWidth);
    uni.uSweep.value = o.sweep * ev;
    uni.uSweepAz.value = THREE.MathUtils.degToRad(-7.5 + 107.5 * sp);
    renderer.render(scene, camera);
  }

  function dispose() {
    blobGeo.dispose(); blobMat.dispose(); envRT.dispose(); bgGeo.dispose(); bgMat.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-obsidian-flow

비슷한 배경

  • 점의 구름

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

  • 인디고 벨벳

    3D · 천장 다운라이트 · 10초 반복 · 무료

  • 코발트 실크

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

  • 리브 유리

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