Assets · Motion background
점의 구름
천장 빛 아래 숨 쉬는 구

미리보기는 가벼운 540px 판이에요. 받는 영상은 아래 원본 해상도예요.
1080×1350
- 파일 (MP4)
- 4:5 · 1080×1350 · 5.2MB
- 9:16 · 1080×1920 · 6.1MB
- 길이·반복
- 10초 완전 반복 · 30fps
- 라이선스
- 코드 렌더(three.js — MIT), 폰트·이미지 없음
웹페이지에 실시간으로
영상 파일 대신 같은 장면을 브라우저에서 바로 그릴 수 있어요(three.js · WebGL — 대부분의 브라우저에서 그려지고, 못 그리면 포스터로 바뀌어요). 아래 프롬프트를 Claude Code·Codex 에 붙여 넣으면 PointSphereBackground 모듈로 넣어 첫 화면 배경에 깔아 줍니다. 동작 줄이기에서는 정지 화면 한 장만 그려요.
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-point-sphere.large.webp
- 같은 장면의 영상(9:16 MP4, 반복): https://pub-a4aa81cc9139413c99493d10e0d4398d.r2.dev/backgrounds/three-point-sphere/cdd865acc327ced9d7438874014e25a2833bb4556da500701a98c3d977c6c8bf.mp4
## 넣는 방법
1. `npm i three@0.186.1` (pnpm·yarn 이면 그 도구로). 버전을 올리지 않는다. 번들러 없는 정적 HTML 이면 설치 대신 아래 importmap(같은 버전의 CDN 고정)을 <head> 에 넣는다.
2. 아래 코드를 장면 모듈 `point-sphere.scene.js` 로 바이트 그대로 저장한다(기존 lib·utils 폴더 관례를 따른다). 장면 값(색·속도·seed·점 수 같은 숫자)은 바꾸지 않는다. 3단계의 컴포넌트와 파일 이름을 다르게 둔다 — 같은 폴더에 `PointSphereBackground.js`·`PointSphereBackground.tsx` 를 같이 두면 TypeScript 가 `./PointSphereBackground.js` 를 컴포넌트 자신으로 읽는다.
- TypeScript 프로젝트면 .js 는 그대로 두고, 옆에 같은 이름의 선언 파일 `point-sphere.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 면 `PointSphereBackground.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-point-sphere.large.webp)를 CSS 배경(background-size: cover)으로 깔아 둔다 — 처음 그려지기 전에도, 모듈이 캔버스를 숨겼을 때(WebGL 불가·컨텍스트 잃음)도 그 포스터가 보이므로 따로 감지할 필요가 없다. 포스터를 <img> 로 따로 보여 줄 때만 `onUnavailable` 에서 상태를 바꾼다. `canvas.getContext(...)` 로 미리 떠보지 않는다(기본 속성 컨텍스트가 먼저 잡혀 three 의 설정이 무시된다). `webglcontextlost`·`webglcontextrestored` 도 따로 처리하지 않는다(모듈이 처리한다).
5. `prefers-reduced-motion: reduce` 면 코드가 스스로 정지 화면 한 장만 그리고 루프를 멈춘다(`mountWeb` 안에 들어 있다) — 끄지 않는다. 포스터만 보여 주고 싶으면 그 경우에는 `mountWeb` 호출을 건너뛴다.
```js
// three-point-sphere — 점의 구름 · 천장 빛 아래 숨 쉬는 구 (three.js, vanilla, 단일 파일). 레시피 3(점 구름 구)을 '전시실 조명' 규칙으로.
// 어두운 인디고 벽 앞에 떠 있는 점 약 1만6천 개의 구름(반지름 0.85~1.15 두께의 껍질 + 바깥으로 풀려 나가는 점 4%).
// 키는 하나 — 천장 스팟(수직에서 ≈22°, 따뜻한 #FFF1E0): 위 반구가 밝고 명암 경계는 거의 수평, 껍질 안쪽 점일수록 빛이 덜 닿는다(부피 그늘).
// 차가운 코발트 림(#2A3CFF, 키의 10% 이하)은 아래 테두리만. 벽에는 스팟이 구 위·뒤로 번진 빛웅덩이, 빛 속엔 떠도는 먼지 몇 개(중경).
// 점은 구 화면 반지름 270px 기준 지름 3~4.6px(540 출력에서 1.5px 이상), 원형 알파 감쇠. 초점은 앞면 — 뒤 반구·앞 먼지는 커지고 흐려진다(피사계 심도).
// 두 속도: ① 드리프트 — 루프당 60° 자전(k=12 겹 배치를 루프당 2칸 돌린다: 칸마다 점의 반지름·접선 오프셋 위상이 달라 겹 무늬가 보이지 않는다),
// 반지름 ±3.5% 숨쉬기(루프당 1회) + 4D 심플렉스 노이즈 부풂(시간 축은 원 위 샘플) + 바깥 점이 흩어졌다 돌아온다 + 카메라 미세 선회(시차)
// ② 사건 — 루프당 한 번(≈1.3초, ease-in-out) 빛이 숨을 들이쉰다: 스팟이 밝아지고, 그 빛의 너울이 위에서 아래로 구를 지나가며 점이 부풀었다 가라앉는다.
// 모든 움직임은 u = t/loopSeconds 의 정수 주파수 → frame(loopSeconds) === frame(0). 텍스처·이미지·fetch 없음, 시드 난수(mulberry32)만.
//
// 파일 하나가 렌더(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
import * as THREE from 'three';
export const meta = {
id: 'three-point-sphere',
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', // 미색(밝은 변형)
};
// 파라미터는 전부 JSON(숫자·문자열) — 사이드카 three.params 로 간다
export const DEFAULTS = {
loopSeconds: 10, // render.mjs 가 preset.seconds 로 덮어쓴다
seed: 7,
// 점 배치 — 경도 섹터(2π/k) 하나에 시드 난수로 점을 뿌리고 k 번 돌려 붙인다. 칸(복사본)마다 반지름·접선 오프셋 위상이 달라 겹 무늬가 없다
count: 16000, // 목표 점 수(실제 수는 k 의 배수)
jitter: 0.38, // 피보나치 격자 지터(평균 간격 비율) — 고른 간격(블루 노이즈)을 두되 격자 무늬는 깬다
symmetry: 12, // k
spinSectors: 2, // 루프당 자전 칸 수(정수) → 360°·2/12 = 60°
thickness: 0.15, // 껍질 두께(±, 반지름 비율) — 0.85~1.15
swing: 1.6, // 껍질 안 점의 위상 진폭 배율(점마다 0~1 의 제곱 분포를 곱한다 — 대부분은 표면 가까이)
tangent: 0.035, // 접선 오프셋(반지름 비율) — 칸마다 위상이 달라 같은 무늬가 반복되지 않는다
dust: 0.04, // 바깥으로 풀려 나가는 점 비율
dustReach: 0.22, // 최대 높이(반지름 비율)
dustCycles: 5, // 칸 위상 x 에 대한 정수 주파수(루프당 5/6 주기 — 칸마다 위상이 달라 늘 일부만 나가 있다)
tiltX: -0.38, // 자전축 기울기(rad) — 위 극을 뒤로 눕혀 자전이 얼굴을 가로지른다
tiltZ: 0.16,
// 숨쉬기(드리프트)
breathe: 0.035, // 전체 반지름 ±3.5%(루프당 1회)
wobble: 0.03, // 4D 노이즈 부풂 진폭(±)
noiseFreq: 0.9,
noiseOrbit: 0.6, // 노이즈 시간 축 원의 반지름 — 루프당 한 바퀴
// 빛 — 키 하나(천장 스팟, 수직에서 ≈22°, 살짝 왼쪽 앞), 림(아래 뒤, 차가움 ≤10%)
keyX: -0.12, keyY: 0.9, keyZ: 0.42,
keyColor: '#FFF1E0',
wrap: 0.26, // 감싸는 확산 — 경계를 부드럽게
falloff: 1.25, // 밝기 곡선 지수 — 클수록 빛 중심↔그늘 낙차가 크다
ambient: 0.085, // 그늘 쪽 점의 최소 밝기(차가운 필 — 그늘 반구가 인디고 점으로 남아 구의 윤곽이 읽힌다)
innerDim: 0.42, // 껍질 가장 안쪽 점의 빛 배율(부피 그늘)
shadowAlpha: 0.38, // 그늘 점의 불투명도 — 재질(점)은 빛 안에서만 또렷하다
rimX: 0.25, rimY: -0.75, rimZ: -0.6,
rimColor: '#2A3CFF',
rimIntensity: 0.1, // 키 대비 ≤0.1
// 점 — 지름(px)은 구 화면 반지름 270px 기준, 해상도·비율에 비례
sizeMin: 3.0,
sizeMax: 4.6,
coc: 2.4, // 초점 밖 흐림(반지름 1 만큼 벗어나면 지름 +coc px)
backDim: 0.9, // 뒤 반구 밝기 — 앞 점 틈으로 반대로 도는 뒤 점이 보인다(시차)
// 사건 — 빛의 들숨
eventAt: 0.55, // 루프 위상 중심
eventWidth: 0.065, // 반폭(위상) — 0.065 × 10초 × 2 = 1.3초
eventKey: 0.22, // 스팟 밝기 증가
eventBand: 0.35, // 너울 띠 두께(키 방향 내적 단위)
eventLift: 0.035, // 너울이 지나갈 때 반지름 부풂
eventGain: 0.24, // 너울 안 밝기 증가
// 구도 — 4:5 → 9:16 로 갈수록 구를 더 위·작게(글자 구역 밖)
size45: 0.2, // 구 반지름 / 화면 높이
size916: 0.114,
centerY45: 0.33, // 구 중심(위에서부터 화면 높이 비율)
centerY916: 0.158,
camDist: 7.8, // 구 반지름 단위 — 원근(앞뒤 점 크기 비 ≈1.3)
orbit: 0.06, // 카메라 미세 선회(rad) — 먼지·벽과의 시차
// 바탕
lift: 1.0, // 인디고 바탕 들어올림(글자 구역 Y 15~60)
pool: 1.0, // 벽의 빛웅덩이(천장 스팟이 벽을 쓸어내린 부채꼴, 구 뒤)
lampX: 0.1, lampY: 1.9, // 벽 위 스팟의 기준점(구 반지름 단위, 구 중심 기준)
shadow: 0.45, // 구가 벽 빛웅덩이에 드리운 그림자(구 아래쪽으로 비어져 나온다)
halo: 0.6, // 빛 쪽 가장자리의 산란광
motes: 12, // 빛 속 먼지(전경·중경의 크고 흐린 점, ≤40)
stillTime: 0, // 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);
};
// 사건 창 안에서 -1→+1 로 ease-in-out 진행하는 값(창 밖은 ±1 에 머문다) — 지나가는 너울의 위치
const sweep = (u, c, w) => {
const d = ((((u - c + 0.5) % 1) + 1) % 1) - 0.5; // -0.5..0.5, 0 = 중심
const x = Math.min(Math.max((d / w + 1) / 2, 0), 1);
return x * x * (3 - 2 * x) * 2 - 1;
};
// GLSL 4D 심플렉스 노이즈 — Ashima Arts / Ian McEwan (MIT). 시간 축 두 개를 원 위에서 샘플해 루프가 정확히 닫힌다.
const SNOISE4 = /* glsl */ `
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
float mod289(float x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 10.0) * x); }
float permute(float x) { return mod289(((x * 34.0) + 10.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float taylorInvSqrt(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 snoise(vec4 v) {
const vec4 C = vec4(0.138196601125011, 0.276393202250021, 0.414589803375032, -0.447213595499958);
vec4 i = floor(v + dot(v, vec4(0.309016994374947451)));
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 = mod289(i);
float j0 = permute(permute(permute(permute(i.w) + i.z) + i.y) + i.x);
vec4 j1 = permute(permute(permute(permute(
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 = taylorInvSqrt(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 *= taylorInvSqrt(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))));
}
`;
// 점 셰이더 — 위치·밝기·크기를 전부 정점 셰이더에서(웹에서도 프레임당 CPU 일 없음)
const POINT_VERT = /* glsl */ `
attribute vec3 aRand; // 점(섹터 안 번호)마다: 크기·밝기 지터, 먼지 위상, 먼지 선택
attribute vec4 aSeed; // 반지름 위상 진폭·위상, 접선 진폭·위상
attribute float aCopy; // 칸 번호 s
uniform float uPass; // 0 = 뒤 반구(먼저, 흐리게), 1 = 앞 반구
uniform float uX, uK; // 칸 위상 x = (s + uX) / k — uX = 루프당 자전 칸 수 × u
uniform vec2 uTime; // (cos 2πu, sin 2πu) × noiseOrbit — 노이즈 시간 축의 원
uniform float uScale, uWobble, uFreq, uThick, uSwing, uTan, uDust, uDustReach, uDustCycles;
uniform vec3 uKey, uRimDir;
uniform float uKeyGain, uWrap, uFalloff, uInnerDim, uRim, uShadowA, uBackDim, uAmb;
uniform float uEvS, uEvEnv, uEvBand, uEvLift, uEvGain;
uniform float uPx, uCenterDepth, uSizeMin, uSizeMax, uCoc, uFocus;
uniform vec3 uC0, uC1, uC2, uC3, uC4, uRimColor;
varying vec3 vColor;
varying float vAlpha, vSize;
#define TAU 6.283185307179586
${SNOISE4}
float field(vec3 n) {
return 0.75 * snoise(vec4(n * uFreq, uTime)) + 0.25 * snoise(vec4(n * uFreq * 2.3 + vec3(3.1, 7.7, 1.3), uTime * 1.7));
}
// 밝기 사다리: 그늘 인디고 → 빛 받은 인디고 → 따뜻한 회색(스팟) → 미색 → 키
vec3 ramp(float b) {
vec3 c = mix(uC0, uC1, smoothstep(0.0, 0.22, b));
c = mix(c, uC2, smoothstep(0.18, 0.55, b));
c = mix(c, uC3, smoothstep(0.5, 0.85, b));
return mix(c, uC4, smoothstep(0.82, 1.0, b));
}
void main() {
float x = (aCopy + uX) / uK; // 칸 위상 — 루프 끝에 칸 s 가 칸 s+2 자리·위상이 된다(정확 루프)
vec3 nl = normalize(position);
vec3 t1 = normalize(cross(nl, abs(nl.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0)));
vec3 t2 = cross(nl, t1);
float ta = TAU * (x + aSeed.w);
vec3 dl = normalize(nl + (t1 * cos(ta) + t2 * sin(ta)) * (uTan * aSeed.z));
vec3 n = normalize(mat3(modelMatrix) * dl); // 월드 방향 — 노이즈·빛·너울은 월드에 고정, 점만 그 사이를 돈다
vColor = vec3(0.0); vAlpha = 0.0; vSize = 1.0;
float front = step(0.0, dot(n, normalize(cameraPosition)));
if (abs(front - uPass) > 0.5) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; }
// 껍질 두께 — 점마다 진폭(대부분 작다)과 위상, 칸 위상 x 로 천천히 오르내린다
float sh = clamp(uSwing * aSeed.x * sin(TAU * x + aSeed.y), -1.0, 1.0);
float shellR = 1.0 + uThick * sh;
// 바깥으로 풀려 나가는 점 — 칸 위상에 대한 정수 주파수(칸마다 위상이 달라 늘 일부만 나가 있다)
float isDust = step(1.0 - uDust, aRand.z);
float out_ = isDust * uDustReach * (0.5 - 0.5 * cos(TAU * (uDustCycles * x + aRand.y)));
// 사건 — 위(키 쪽)에서 아래로 지나가는 빛의 너울
float kd = dot(n, uKey);
float ev = exp(-pow((kd - uEvS) / uEvBand, 2.0)) * uEvEnv;
float r = uScale * (1.0 + uWobble * field(n)) * (shellR + out_) * (1.0 + uEvLift * ev);
vec3 p = n * r;
// 키 — 감싸는 확산 + 껍질 안쪽일수록 덜 닿는 빛(부피 그늘). 먼지는 껍질 밖이라 그늘이 없다
float lam = clamp((kd + uWrap) / (1.0 + uWrap), 0.0, 1.0);
float depthIn = clamp((1.0 - sh) * 0.5, 0.0, 1.0) * (1.0 - isDust);
float b = pow(lam, uFalloff) * mix(1.0, uInnerDim, depthIn * depthIn) * mix(0.9, 1.06, aRand.x) * uKeyGain;
b += ev * uEvGain * (0.4 + 0.6 * lam);
b = clamp(max(b, uAmb * mix(0.7, 1.2, aRand.x)), 0.0, 1.0);
// 림 — 아래 뒤의 차가운 빛(키의 uRim 배), 테두리(시선과 스치는 각)에서만
vec3 V = normalize(cameraPosition - p);
float ndv = abs(dot(n, V));
float rimL = clamp(dot(n, uRimDir), 0.0, 1.0) * pow(1.0 - ndv, 1.6);
vec3 col = ramp(b) + uRimColor * (uRim * rimL);
float a = mix(uShadowA, 1.0, smoothstep(0.08, 0.45, b)) * (1.0 - 0.35 * isDust);
a = max(a, clamp(rimL * uRim * 7.0, 0.0, 0.65));
vec4 mv = viewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mv;
// 지름 — 빛 쪽 점이 크다, 거리 감쇠, 초점(앞면) 밖은 커지고 알파는 면적만큼 준다
float size = mix(uSizeMin, uSizeMax, 0.45 * aRand.x + 0.55 * smoothstep(0.1, 0.85, b)) * (1.0 + 0.25 * ev);
size *= uPx * (uCenterDepth / -mv.z);
float blur = uCoc * uPx * abs(-mv.z - uFocus);
float s2 = size + blur;
a *= (size * size) / (s2 * s2);
if (uPass < 0.5) a *= uBackDim;
vColor = col; vAlpha = a; vSize = s2;
gl_PointSize = s2 + 2.0;
}
`;
const POINT_FRAG = /* glsl */ `
varying vec3 vColor;
varying float vAlpha, vSize;
void main() {
float sprite = vSize + 2.0;
float d = length(gl_PointCoord - 0.5) * sprite; // 점 중심에서의 거리(px)
float r = 0.5 * vSize;
float a = vAlpha * (1.0 - smoothstep(r - 0.75 - 0.18 * vSize, r + 0.75, d)); // 원형 알파 — 가장자리 1.5px + 큰 점일수록 부드럽게
if (a < 0.002) discard;
gl_FragColor = vec4(vColor, a);
#include <colorspace_fragment>
}
`;
// 바탕 — 화면 공간 큰 삼각형 하나: 잉크 + 넓은 인디고 들어올림(글자 구역) + 벽의 빛웅덩이(스팟이 구 위·뒤로 번짐) + 빛 쪽 가장자리 산란광 + 빛 속 먼지.
// 정지 디더(시간 불변)로 밴딩 방지
const BG_VERT = /* glsl */ `
void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }
`;
const BG_FRAG = /* glsl */ `
uniform vec2 uRes, uCenter; // 버퍼 px(아래가 0)
uniform float uRadius, uLift, uPool, uHalo, uGain, uPortrait, uShadow;
uniform vec2 uKeyS, uLamp; // 화면에서 본 키 방향(구 중심 기준, 위가 +) · 벽 스팟 기준점(구 반지름 단위)
uniform vec3 uInk, uIndigo, uPoolColor, uPoolTail, uHaloColor;
float hash12(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
void main() {
vec2 px = gl_FragCoord.xy;
vec2 uv = px / uRes;
float asp = uRes.x / uRes.y;
// 넓은 인디고 — 글자 구역(가운데 아래)이 들리고 모서리는 잉크로 가라앉는다
vec2 f = (uv - vec2(0.5, mix(0.3, 0.5, uPortrait))) * vec2(asp * 1.15, 1.0) * vec2(1.0, mix(1.25, 0.75, uPortrait));
vec3 col = mix(uInk, uIndigo, clamp(exp(-dot(f, f) * 3.2) * uLift, 0.0, 1.0));
// 벽의 빛웅덩이 — 천장 스팟이 벽을 위에서 쓸어내린 부채꼴(윗선은 포물선, 아래로 넓어지며 흐려진다). 구 반지름 단위
vec2 q = (px - uCenter) / uRadius;
float dy = uLamp.y - q.y, dx = q.x - uLamp.x;
float top = smoothstep(0.0, 0.55, dy - 0.32 * dx * dx);
float spread = 0.5 + 0.42 * max(dy, 0.0);
float pool = top * exp(-pow(dx / spread, 2.0)) * exp(-max(dy, 0.0) * 0.42);
// 구가 벽에 드리운 그림자 — 스팟이 위·앞에서 오므로 구 바로 뒤·조금 아래(구 아래 테두리 밑으로만 비어져 나온다)
vec2 sq = (q - vec2(0.03, -0.32)) / vec2(1.02, 0.98);
pool *= 1.0 - uShadow * exp(-pow(dot(sq, sq), 2.0) * 1.4);
col += mix(uPoolTail, uPoolColor, smoothstep(0.15, 0.75, pool)) * uPool * pool * uGain; // 웅덩이 중심은 따뜻한 회색, 가장자리는 빛 받은 인디고로 풀린다
// 빛 쪽 가장자리의 산란광 — 구 둘레를 따라 얇게, 키가 오는 쪽에서만
float rr = length(q);
float side = smoothstep(-0.1, 0.9, dot(q / max(rr, 1e-4), uKeyS));
float halo = exp(-max(rr - 1.0, 0.0) * 6.0) * smoothstep(0.85, 1.02, rr) * side;
col += uHaloColor * uHalo * halo * uGain;
gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment>
gl_FragColor.rgb += (hash12(px) + hash12(px + 17.31) - 1.0) * 1.6 / 255.0; // 정지 디더(삼각 분포 ±1.6/255, 시간 불변) — 어두운 그라데이션이 인코딩 뒤에도 동심원으로 끊기지 않게
}
`;
// 빛 속 먼지 — 몇십 개, 크고 흐린 점(중경). 구 위 빛 원뿔 안에서만 밝다
const MOTE_VERT = /* glsl */ `
attribute vec4 aM; // 기준 위치(구 반지름 단위 x, y, z) + 위상
uniform float uU, uPx, uCenterDepth, uCoc, uFocus, uGain;
uniform vec3 uKey;
varying float vA, vS;
#define TAU 6.283185307179586
void main() {
vec3 p = aM.xyz;
float ph = TAU * (uU + aM.w);
p += vec3(0.16 * sin(ph), 0.12 * sin(2.0 * ph + 1.3) + 0.1 * cos(ph), 0.08 * cos(ph + 0.7));
// 빛 원뿔 안 — 구 위쪽(키 방향) 쪽일수록 밝다
float inBeam = smoothstep(0.62, 0.92, dot(normalize(p), uKey)) * smoothstep(1.05, 1.25, length(p));
vec4 mv = viewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mv;
float size = 11.0 * uPx * (uCenterDepth / -mv.z);
float blur = uCoc * uPx * abs(-mv.z - uFocus);
vS = size + blur;
vA = 0.16 * inBeam * uGain * (size * size) / (vS * vS);
gl_PointSize = vS + 2.0;
}
`;
const MOTE_FRAG = /* glsl */ `
uniform vec3 uColor;
varying float vA, vS;
void main() {
float d = length(gl_PointCoord - 0.5) * (vS + 2.0);
float a = vA * (1.0 - smoothstep(0.12 * vS, 0.5 * vS + 0.75, d));
if (a < 0.002) discard;
gl_FragColor = vec4(uColor, a);
#include <colorspace_fragment>
}
`;
// 점 배치 — 경도 [0, 2π/k) 섹터에 시드 난수로 균일하게 뿌리고 k 번 돌려 붙인다.
// 버퍼 순서는 (섹터 안 번호 j) 우선 — 칸이 바뀌어도 겹치는 점들의 그리기 순서가 같아 이음새가 정확히 닫힌다.
function buildCloud(o) {
const rand = mulberry32(o.seed);
const k = Math.max(1, Math.round(o.symmetry));
const sector = TAU / k;
const N = Math.max(1000, Math.round(o.count));
const GA = Math.PI * (3 - Math.sqrt(5));
const spacing = Math.sqrt((4 * Math.PI) / N);
const base = [];
const p = new THREE.Vector3(), t1 = new THREE.Vector3(), t2 = new THREE.Vector3();
const UP = new THREE.Vector3(0, 1, 0), RIGHT = new THREE.Vector3(1, 0, 0);
for (let i = 0; i < N; i++) {
const phi = (i * GA) % TAU;
if (phi >= sector) continue; // 섹터 하나만 — k 번 돌려 붙인다
const y = 1 - (2 * (i + 0.5)) / N;
const rr = Math.sqrt(Math.max(0, 1 - y * y));
p.set(rr * Math.cos(phi), y, rr * Math.sin(phi));
t1.crossVectors(p, Math.abs(p.y) < 0.99 ? UP : RIGHT).normalize();
t2.crossVectors(p, t1);
const a = rand() * TAU, m = o.jitter * spacing * Math.sqrt(rand());
p.addScaledVector(t1, Math.cos(a) * m).addScaledVector(t2, Math.sin(a) * m).normalize();
const g = rand();
base.push([p.x, p.y, p.z, rand(), rand(), rand(), g * g, rand() * TAU, 0.3 + 0.7 * rand(), rand()]);
}
const M = base.length;
const total = M * k;
const pos = new Float32Array(total * 3), rnd = new Float32Array(total * 3), seed4 = new Float32Array(total * 4), copy = new Float32Array(total);
let w = 0;
for (let j = 0; j < M; j++) {
const [x, y, z, r1, r2, r3, g, ph, gt, pt] = base[j];
for (let s = 0; s < k; s++) {
const c = Math.cos(s * sector), sn = Math.sin(s * sector); // three 의 rotation.y 와 같은 방향
pos[w * 3] = x * c + z * sn; pos[w * 3 + 1] = y; pos[w * 3 + 2] = -x * sn + z * c;
rnd[w * 3] = r1; rnd[w * 3 + 1] = r2; rnd[w * 3 + 2] = r3;
seed4[w * 4] = g; seed4[w * 4 + 1] = ph; seed4[w * 4 + 2] = gt; seed4[w * 4 + 3] = pt;
copy[w] = s;
w++;
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('aRand', new THREE.BufferAttribute(rnd, 3));
geo.setAttribute('aSeed', new THREE.BufferAttribute(seed4, 4));
geo.setAttribute('aCopy', new THREE.BufferAttribute(copy, 1));
geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1.6);
return {geo, k, sector, total};
}
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;
}
export function createScene(renderer, params = {}) {
const o = {...DEFAULTS, ...params};
const scene = new THREE.Scene();
scene.background = new THREE.Color(meta.background);
const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 60);
const col = (hex) => new THREE.Color(hex); // sRGB hex → 선형 작업 공간
// --- 바탕: 화면 공간 삼각형 ---
const bgGeo = new THREE.BufferGeometry();
bgGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3));
const bgUni = {
uRes: {value: new THREE.Vector2(1, 1)}, uCenter: {value: new THREE.Vector2()}, uRadius: {value: 100},
uLift: {value: o.lift}, uPool: {value: o.pool}, uHalo: {value: o.halo}, uGain: {value: 1}, uPortrait: {value: 0},
uInk: {value: col(PALETTE.ink)}, uIndigo: {value: col(PALETTE.indigoWall)}, uPoolColor: {value: col('#58504A')}, uPoolTail: {value: col('#1E2840')}, uHaloColor: {value: col('#3E362F')},
uShadow: {value: o.shadow}, uLamp: {value: new THREE.Vector2(o.lampX, o.lampY)},
uKeyS: {value: new THREE.Vector2(0, 1)},
};
const bgMat = new THREE.ShaderMaterial({vertexShader: BG_VERT, fragmentShader: BG_FRAG, uniforms: bgUni, depthTest: false, depthWrite: false});
const bg = new THREE.Mesh(bgGeo, bgMat);
bg.frustumCulled = false;
bg.renderOrder = -10;
scene.add(bg);
// --- 점 구름: 같은 지오메트리를 두 번(뒤 반구 → 앞 반구) ---
const cloud = buildCloud(o);
const key = new THREE.Vector3(o.keyX, o.keyY, o.keyZ).normalize();
const uni = {
uX: {value: 0}, uK: {value: cloud.k}, uTime: {value: new THREE.Vector2()},
uScale: {value: 1}, uWobble: {value: o.wobble}, uFreq: {value: o.noiseFreq},
uThick: {value: o.thickness}, uSwing: {value: o.swing}, uTan: {value: o.tangent},
uDust: {value: o.dust}, uDustReach: {value: o.dustReach}, uDustCycles: {value: Math.round(o.dustCycles)},
uKey: {value: key}, uRimDir: {value: new THREE.Vector3(o.rimX, o.rimY, o.rimZ).normalize()},
uKeyGain: {value: 1}, uWrap: {value: o.wrap}, uFalloff: {value: o.falloff}, uInnerDim: {value: o.innerDim},
uRim: {value: Math.min(o.rimIntensity, 0.15)}, uShadowA: {value: o.shadowAlpha}, uBackDim: {value: o.backDim}, uAmb: {value: o.ambient},
uEvS: {value: 2}, uEvEnv: {value: 0}, uEvBand: {value: o.eventBand}, uEvLift: {value: o.eventLift}, uEvGain: {value: o.eventGain},
uPx: {value: 1}, uCenterDepth: {value: o.camDist}, uSizeMin: {value: o.sizeMin}, uSizeMax: {value: o.sizeMax},
uCoc: {value: o.coc}, uFocus: {value: o.camDist - 0.85},
// 밝기 사다리: 그늘 인디고 → 빛 받은 인디고 → 따뜻한 회색(스팟) → 미색 → 키
uC0: {value: col('#18212F')}, uC1: {value: col(PALETTE.indigoLit)}, uC2: {value: col(PALETTE.spot)},
uC3: {value: col('#E6D3BC')}, uC4: {value: col(o.keyColor)}, uRimColor: {value: col(o.rimColor)},
};
const makeMat = (pass) => new THREE.ShaderMaterial({
vertexShader: POINT_VERT, fragmentShader: POINT_FRAG, uniforms: {...uni, uPass: {value: pass}},
transparent: true, depthTest: false, depthWrite: false, blending: THREE.NormalBlending,
});
const backMat = makeMat(0), frontMat = makeMat(1);
const back = new THREE.Points(cloud.geo, backMat);
const front = new THREE.Points(cloud.geo, frontMat);
back.renderOrder = 1; front.renderOrder = 3;
back.frustumCulled = false; front.frustumCulled = false;
const spin = new THREE.Group();
spin.add(back, front);
const tilt = new THREE.Group();
tilt.rotation.set(o.tiltX, 0, o.tiltZ);
tilt.add(spin);
scene.add(tilt);
// --- 빛 속 먼지(중경) ---
const mr = mulberry32(o.seed + 31);
const nM = Math.min(40, Math.max(0, Math.round(o.motes)));
const mArr = new Float32Array(nM * 4);
for (let i = 0; i < nM; i++) {
const a = mr() * TAU, rad = 0.25 + 0.85 * Math.sqrt(mr());
mArr[i * 4] = Math.cos(a) * rad * 1.1; mArr[i * 4 + 1] = 0.85 + 0.75 * mr(); mArr[i * 4 + 2] = Math.sin(a) * rad * 0.8 + 0.5; mArr[i * 4 + 3] = mr();
}
const mGeo = new THREE.BufferGeometry();
mGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(nM * 3), 3));
mGeo.setAttribute('aM', new THREE.BufferAttribute(mArr, 4));
const mUni = {uU: {value: 0}, uPx: uni.uPx, uCenterDepth: uni.uCenterDepth, uCoc: uni.uCoc, uFocus: uni.uFocus, uGain: {value: 1}, uKey: uni.uKey, uColor: {value: col('#D9C8B4')}};
const mMat = new THREE.ShaderMaterial({vertexShader: MOTE_VERT, fragmentShader: MOTE_FRAG, uniforms: mUni, transparent: true, depthTest: false, depthWrite: false});
const motes = new THREE.Points(mGeo, mMat);
motes.frustumCulled = false;
motes.renderOrder = 4;
scene.add(motes);
// 4:5 → 0, 9:16 → 1 — 세로가 길수록 구를 더 위·작게(글자 띠 밖)
const view = {portrait: 0, w: 1, h: 1};
function setSize(w, h) {
view.w = w; view.h = h;
view.portrait = THREE.MathUtils.clamp((0.8 - w / h) / (0.8 - 0.5625), 0, 1);
const frac = THREE.MathUtils.lerp(o.size45, o.size916, view.portrait); // 구 반지름 / 화면 높이
const cy = THREE.MathUtils.lerp(o.centerY45, o.centerY916, view.portrait);
// 거리는 고정(원근 일정), 화각으로 크기를 맞춘다. 렌즈 시프트(view offset)로 구를 위로 — 구는 광축 위에 있어 찌그러지지 않는다
const sinA = 1 / o.camDist, tanA = sinA / Math.sqrt(1 - sinA * sinA);
camera.fov = THREE.MathUtils.radToDeg(2 * Math.atan(tanA / (2 * frac)));
camera.aspect = w / h;
camera.setViewOffset(w, h, 0, (0.5 - cy) * h, w, h);
camera.updateProjectionMatrix();
bgUni.uPortrait.value = view.portrait;
}
const buf = new THREE.Vector2(), c3 = new THREE.Vector3(), m3 = new THREE.Matrix3();
// 프레임 = t 의 순수 함수
function renderAt(t) {
const u = phase(t, o.loopSeconds);
// 속도 1 — 드리프트: 루프당 spinSectors 칸 자전(칸 위상 uX 가 함께 간다), 노이즈 시간 원 한 바퀴, 숨쉬기 1회, 카메라 미세 선회
const sp = Math.round(o.spinSectors) * u;
spin.rotation.y = cloud.sector * sp;
uni.uX.value = sp;
uni.uTime.value.set(o.noiseOrbit * Math.cos(TAU * u), o.noiseOrbit * Math.sin(TAU * u));
// 사건 — 빛의 들숨: 스팟이 밝아지고 너울이 위(+1)에서 아래(-1)로
const ev = pulse(u, o.eventAt, o.eventWidth);
uni.uEvEnv.value = ev;
uni.uEvS.value = -1.3 * sweep(u, o.eventAt, o.eventWidth);
uni.uKeyGain.value = 1 + o.eventKey * ev;
bgUni.uGain.value = 1 + 0.8 * o.eventKey * ev;
mUni.uGain.value = 1 + o.eventKey * ev;
uni.uScale.value = 1 + o.breathe * Math.sin(TAU * u + 0.4);
mUni.uU.value = u;
const yaw = o.orbit * Math.sin(TAU * u), pitch = 0.4 * o.orbit * Math.sin(TAU * u + 1.9);
camera.position.set(o.camDist * Math.sin(yaw) * Math.cos(pitch), o.camDist * Math.sin(pitch), o.camDist * Math.cos(yaw) * Math.cos(pitch));
camera.lookAt(0, 0, 0);
// 점 지름 기준 = 구의 화면 반지름(px) / 270 — 해상도·비율이 달라도 같은 질감
renderer.getDrawingBufferSize(buf);
camera.updateMatrixWorld();
const sinA = 1 / o.camDist, tanA = sinA / Math.sqrt(1 - sinA * sinA);
const rPx = (tanA / Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2)) * (buf.y / 2);
uni.uPx.value = rPx / 270;
c3.set(0, 0, 0).project(camera);
bgUni.uRes.value.copy(buf);
bgUni.uCenter.value.set((c3.x * 0.5 + 0.5) * buf.x, (c3.y * 0.5 + 0.5) * buf.y);
bgUni.uRadius.value = rPx * uni.uScale.value;
// 키 방향을 화면에 투영(구 중심 기준) — 산란광이 빛 쪽에 붙는다
c3.copy(uni.uKey.value).applyMatrix3(m3.setFromMatrix4(camera.matrixWorldInverse));
bgUni.uKeyS.value.set(c3.x, c3.y).normalize();
renderer.render(scene, camera);
}
function dispose() {
cloud.geo.dispose(); backMat.dispose(); frontMat.dispose(); bgGeo.dispose(); bgMat.dispose(); mGeo.dispose(); mMat.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-point-sphere


