Codrops 원본

Exploring Procedural Geometry with Three.js and WebGPU

배경 · MIT

배경 더 보기
ORIGINAL PREVIEW
Exploring Procedural Geometry with Three.js and WebGPU 정적 미리보기

갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.

큰 화면으로 보기 새 탭

SOURCE FILES

원본 코드 읽기

39개 파일

수집한 원본 소스와 실행 안내를 함께 제공합니다.

demos/anchor-space.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Painting on a canvas that moves — Geometry Painter demos</title>
  </head>
  <body>
    <div id="stage"></div>
    <div class="caption">
      <a class="home" href="./index.html">← all demos</a>
      <h1>Painting on a canvas that moves</h1>
      <p>Same stroke, same hand, two coordinate spaces. The sphere keeps turning after you let go — and only one of these two survives it.</p>
    </div>
    <div class="splits">
      <div>
        <span class="tag no">stored in world space</span>
        <span class="sub">the beads stay where the pointer was</span>
      </div>
      <div>
        <span class="tag yes">stored in anchor space</span>
        <span class="sub">the beads ride the surface</span>
      </div>
    </div>
    <script type="module" src="/demos/src/anchor-space.ts"></script>
  </body>
</html>
demos/blackbody.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Building the heat ramp — Geometry Painter demos</title>
  </head>
  <body>
    <div id="stage"></div>
    <div class="caption">
      <a class="home" href="./index.html">← all demos</a>
      <h1>Building the heat ramp</h1>
      <p>The molten core is one float pushed through four mix() calls. Switch the terms off one at a time and see what each is worth.</p>
    </div>
    <script type="module" src="/demos/src/blackbody.ts"></script>
  </body>
</html>
함께 쓰는 파일 37개 보기
demos/colony-pulse.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>One heartbeat, many colonies — Geometry Painter demos</title>
  </head>
  <body>
    <div id="stage"></div>
    <div class="caption">
      <a class="home" href="./index.html">← all demos</a>
      <h1>One heartbeat, many colonies</h1>
      <p>A wave that lives in world space instead of in each object, so separate strokes painted minutes apart still pulse as one organism.</p>
    </div>
    <script type="module" src="/demos/src/colony-pulse.ts"></script>
  </body>
</html>
demos/cull.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Generate at the maximum, cull with the slider — Geometry Painter demos</title>
  </head>
  <body>
    <div id="stage"></div>
    <div class="caption">
      <a class="home" href="./index.html">← all demos</a>
      <h1>Generate at the maximum, cull with the slider</h1>
      <p>The real crystal mode, driven by real sliders. Watch the rebuild counter refuse to move while you drag.</p>
    </div>
    <script type="module" src="/demos/src/cull.ts"></script>
  </body>
</html>
demos/fold-light.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Fold-locked brightness — Geometry Painter demos</title>
  </head>
  <body>
    <div id="stage"></div>
    <div class="caption">
      <a class="home" href="./index.html">← all demos</a>
      <h1>Fold-locked brightness</h1>
      <p>Two curtains, one wave. The right one shares the wave phase with its fragment shader, which is the entire difference between a wobbling plane and cloth.</p>
    </div>
    <div class="splits">
      <div>
        <span class="tag no">even brightness</span>
        <span class="sub">a sheet that happens to wobble</span>
      </div>
      <div>
        <span class="tag yes">fold-locked brightness</span>
        <span class="sub">cloth, seen edge-on</span>
      </div>
    </div>
    <script type="module" src="/demos/src/fold-light.ts"></script>
  </body>
</html>
demos/growth.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>The growth front — Geometry Painter demos</title>
  </head>
  <body>
    <div id="stage"></div>
    <div class="caption">
      <a class="home" href="./index.html">← all demos</a>
      <h1>The growth front</h1>
      <p>Every instance knows the distance at which it was seeded. Growth is just the gap between that and how far the front has travelled.</p>
    </div>
    <script type="module" src="/demos/src/growth.ts"></script>
  </body>
</html>
demos/index.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Geometry Painter — how it works</title>
  </head>
  <body class="index-page">
    <div class="index-wrap">
      <h1>💎 Geometry Painter — the pieces</h1>
      <p class="lede">
        Ten small pages, each one pulling a single mechanism out of the main app and looping it
        on its own. They exist so the moving parts can be watched instead of described:
        picking, resampling, culling, growth, and the four shaders that make the modes look
        alive. Most of them drive the production code directly.
      </p>

      <div class="index-grid">
        <a href="./picking.html">
          <span class="n">01 · PAINTING</span>
          <h3>Surface picking &amp; the tangent frame</h3>
          <p>One raycast becomes a point, a normal and the little basis every mode builds in. BVH on and off.</p>
        </a>
        <a href="./anchor-space.html">
          <span class="n">02 · PAINTING</span>
          <h3>Painting on a canvas that moves</h3>
          <p>World space versus anchor space, on two spheres that won't stop turning.</p>
        </a>
        <a href="./resample.html">
          <span class="n">03 · PAINTING</span>
          <h3>From pointer events to a centreline</h3>
          <p>Why raw pointer samples can't space anything, and what the modes get instead.</p>
        </a>
        <a href="./cull.html">
          <span class="n">04 · ARCHITECTURE</span>
          <h3>Generate at the maximum, cull with the slider</h3>
          <p>The real crystal mode, with a rebuild counter that never moves.</p>
        </a>
        <a href="./growth.html">
          <span class="n">05 · ARCHITECTURE</span>
          <h3>The growth front</h3>
          <p>Birth distance, growth window and the 5% overshoot that sells the pop.</p>
        </a>
        <a href="./ribbon.html">
          <span class="n">06 · SHADERS</span>
          <h3>A ribbon with no width</h3>
          <p>Every crack vertex sits on the centreline. The width is a uniform.</p>
        </a>
        <a href="./blackbody.html">
          <span class="n">07 · SHADERS</span>
          <h3>Building the heat ramp</h3>
          <p>Four terms multiplied into one float, then pushed through a blackbody ramp.</p>
        </a>
        <a href="./fold-light.html">
          <span class="n">08 · SHADERS</span>
          <h3>Fold-locked brightness</h3>
          <p>Share the wave phase with the fragment stage and a plane becomes fabric.</p>
        </a>
        <a href="./colony-pulse.html">
          <span class="n">09 · SHADERS</span>
          <h3>One heartbeat, many colonies</h3>
          <p>A wave that lives in world space, so separate strokes still breathe together.</p>
        </a>
        <a href="./studio.html">
          <span class="n">10 · LOOK</span>
          <h3>The environment is the lighting</h3>
          <p>Six emissive quads and no lights. Switch one off, lose a highlight.</p>
        </a>
      </div>
    </div>
    <script type="module" src="/demos/src/index.ts"></script>
  </body>
</html>
demos/picking.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Surface picking & the tangent frame — Geometry Painter demos</title>
  </head>
  <body>
    <div id="stage"></div>
    <div class="caption">
      <a class="home" href="./index.html">← all demos</a>
      <h1>Surface picking &amp; the tangent frame</h1>
      <p>One pointer event, one raycast, and the little orthonormal basis every painting mode plants its geometry in. Toggle the BVH off to watch the cost of a pick jump.</p>
    </div>
    <script type="module" src="/demos/src/picking.ts"></script>
  </body>
</html>
demos/resample.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>From pointer events to a centreline — Geometry Painter demos</title>
  </head>
  <body>
    <div id="stage"></div>
    <div class="caption">
      <a class="home" href="./index.html">← all demos</a>
      <h1>From pointer events to a centreline</h1>
      <p>Raw samples bunch where the hand slowed down. Modes need even spacing and a tangent frame, so every stroke gets resampled first.</p>
    </div>
    <script type="module" src="/demos/src/resample.ts"></script>
  </body>
</html>
demos/ribbon.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>A ribbon with no width — Geometry Painter demos</title>
  </head>
  <body>
    <div id="stage"></div>
    <div class="caption">
      <a class="home" href="./index.html">← all demos</a>
      <h1>A ribbon with no width</h1>
      <p>The fissure crack is a strip of vertices sitting on top of each other. Width, branch length and branch count all live in the vertex shader.</p>
    </div>
    <script type="module" src="/demos/src/ribbon.ts"></script>
  </body>
</html>
demos/src/anchor-space.ts
파일 저장

import * as THREE from 'three/webgpu';
import { Panel, Readouts, Replayer, arcSamples, canvasSphere, createStage, fatal, studioLights } from './kit';

/**
 * Demo — "the canvas moves while you paint".
 *
 * Both spheres bob and turn. Both get the exact same stroke, drawn by the same scripted
 * hand. The only difference is which coordinate space the samples were stored in at pick
 * time: world (left) or the anchor's local space (right).
 *
 * The bug on the left is the whole reason SurfaceSample carries `local`/`localNormal`.
 */

const RADIUS = 0.85;
const BEAD_RADIUS = 0.03;

const stage = await createStage({
  cameraPos: [0, 0.35, 4.6],
  fov: 46,
  environment: true,
  orbit: true,
}).catch((err) => {
  fatal(err);
  return null;
});

if (stage) {
  const { scene } = stage;
  stage.controls?.target.set(0, 0, 0);
  studioLights(scene);

  const beadGeo = new THREE.SphereGeometry(BEAD_RADIUS, 12, 8);
  const beadMat = new THREE.MeshBasicMaterial({ color: 0xc9a4ff, toneMapped: false });

  /** One half of the split: a floating canvas plus the beads painted onto it. */
  function makeSide(x: number, parentBeadsToAnchor: boolean): {
    root: THREE.Group;
    beads: THREE.InstancedMesh;
    place: (index: number, local: THREE.Vector3, normal: THREE.Vector3) => void;
    reset: () => void;
  } {
    const root = new THREE.Group();
    root.position.x = x;
    scene.add(root);

    const sphere = canvasSphere(RADIUS, 64);
    root.add(sphere);

    const beads = new THREE.InstancedMesh(beadGeo, beadMat, 200);
    beads.frustumCulled = false;
    beads.count = 0;
    // Left: beads live in the world, exactly where the pointer hit at pick time.
    // Right: beads are parented under the anchor, so they ride whatever it does next.
    (parentBeadsToAnchor ? root : scene).add(beads);

    const m = new THREE.Matrix4();
    const q = new THREE.Quaternion();
    const s = new THREE.Vector3(1, 1, 1);
    const p = new THREE.Vector3();

    return {
      root,
      beads,
      place(index, local, normal) {
        p.copy(local).addScaledVector(normal, BEAD_RADIUS * 0.8);
        if (!parentBeadsToAnchor) {
          // Freeze the WORLD position the pointer reported — the naive version.
          root.updateWorldMatrix(true, false);
          p.applyMatrix4(root.matrixWorld);
        }
        beads.setMatrixAt(index, m.compose(p, q, s));
        beads.count = Math.max(beads.count, index + 1);
        beads.instanceMatrix.needsUpdate = true;
      },
      reset() {
        beads.count = 0;
        beads.instanceMatrix.needsUpdate = true;
      },
    };
  }

  const left = makeSide(-1.25, false);
  const right = makeSide(1.25, true);

  // The same painted path for both, in each sphere's local space.
  const samples = arcSamples({
    radius: RADIUS,
    from: new THREE.Vector3(-0.75, -0.5, 0.9),
    to: new THREE.Vector3(0.8, 0.75, 0.75),
    count: 90,
    wobble: 0.11,
    wobbleFreq: 1.4,
  });

  let spin = 0.55;
  let drawn = 0;

  const replay = new Replayer(2.6, 3.4, () => {
    drawn = 0;
    left.reset();
    right.reset();
  });

  const ui = new Panel('Floating canvas');
  ui.slider({
    label: 'Canvas spin',
    value: spin,
    min: 0,
    max: 1.6,
    format: (v) => `${v.toFixed(2)} rad/s`,
    onChange: (v) => { spin = v; },
  });
  ui.button('▶ Replay the stroke', () => replay.restart());
  ui.note(
    'The pointer traced the <b>same path</b> on both. Only the stored coordinate space ' +
    'differs — and the sphere keeps turning after you let go.',
  );

  const out = new Readouts();
  const readDrawn = out.add('Samples painted', '0 / 90', 'hi');
  const readDrift = out.add('World-space drift', '0.00', 'bad');

  const worldA = new THREE.Vector3();
  const worldB = new THREE.Vector3();
  const readMat = new THREE.Matrix4();
  const readQuat = new THREE.Quaternion();
  const readScale = new THREE.Vector3();

  stage.onFrame((dt, time) => {
    // Both canvases float: a slow bob plus a steady turn, exactly like the app's floatRoot.
    for (const side of [left, right]) {
      side.root.rotation.y += spin * dt;
      side.root.position.y = Math.sin(time * 0.9) * 0.06;
    }

    const progress = replay.advance(dt);
    const want = Math.floor(progress * samples.length);
    while (drawn < want) {
      const s = samples[drawn];
      left.place(drawn, s.local, s.localNormal);
      right.place(drawn, s.local, s.localNormal);
      drawn++;
    }
    readDrawn(`${drawn} / ${samples.length}`);

    // How far the first bead has slipped from the surface point it was painted on.
    if (drawn > 0) {
      const s = samples[0];
      left.beads.getMatrixAt(0, readMat);
      readMat.decompose(worldA, readQuat, readScale);
      left.root.updateWorldMatrix(true, false);
      worldB.copy(s.local).applyMatrix4(left.root.matrixWorld);
      readDrift(`${worldA.distanceTo(worldB).toFixed(2)} units`);
    } else {
      readDrift('0.00 units');
    }
  });
}
demos/src/blackbody.ts
파일 저장

import * as THREE from 'three/webgpu';
import { MeshBasicNodeMaterial } from 'three/webgpu';
import { abs, float, mix, positionLocal, smoothstep, time, uniform, vec3 } from 'three/tsl';
import { Panel, createStage, fatal } from './kit';

/**
 * Demo — "a crack is four numbers multiplied together".
 *
 * The fissure core has no texture and no lights. Its colour is a single scalar — heat —
 * pushed through a blackbody-ish ramp. Heat is the product of a handful of terms, and each
 * one does exactly one job. Switch them off one at a time and watch what disappears.
 */

const LEN = 4.4;
const HALF_W = 0.24;
const X_OFF = -0.8; // nudged left so the strip clears the control panel

const stage = await createStage({
  cameraPos: [0, 0, 4.4],
  fov: 50,
  orbit: false,
  // Restrained: the ramp already runs to (4.6, 3.6, 2.4), so a heavy bloom just washes
  // the whole frame out and you can't read the terms any more.
  bloom: { strength: 0.28, threshold: 1.1 },
}).catch((err) => {
  fatal(err);
  return null;
});

if (stage) {
  const { scene, camera } = stage;

  // Term switches, as uniforms so toggling costs nothing.
  const onCenter = uniform(1);
  const onPulse = uniform(1);
  const onFlicker = uniform(1);
  const onFlash = uniform(1);
  const uHeat = uniform(1.5);
  const uPulseSpeed = uniform(1);
  const uGrown = uniform(LEN * 0.55);

  /** The ramp the mode uses: dark seam → deep red → orange → white-hot. */
  /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-function-return-type */
  function blackbody(heat: any) {
    const cSeam = vec3(0.02, 0.004, 0.002);
    const cRed = vec3(1.1, 0.1, 0.01);
    const cOrange = vec3(2.6, 0.85, 0.1);
    const cWhite = vec3(4.6, 3.6, 2.4);
    let color = mix(cSeam, cRed, smoothstep(0.0, 0.55, heat));
    color = mix(color, cOrange, smoothstep(0.55, 1.15, heat));
    return mix(color, cWhite, smoothstep(1.15, 2.1, heat));
  }
  /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-function-return-type */

  // ---------- the crack ----------

  const crackMat = new MeshBasicNodeMaterial();
  crackMat.transparent = true;
  crackMat.depthWrite = false;
  crackMat.blending = THREE.AdditiveBlending;
  {
    // A flat stand-in for the ribbon: x is distance along the crack, y is across it.
    const aDist = positionLocal.x.add(LEN / 2);
    const aAcross = positionLocal.y.div(HALF_W);

    // 1. cross-section: bright at the seam, gone at the lips.
    const center = smoothstep(0.12, 1.0, abs(aAcross)).oneMinus();
    // 2. heat waves travelling along the crack — the "breathing".
    const pulse = aDist.mul(7).sub(time.mul(uPulseSpeed.mul(2.6))).sin().mul(0.28).add(0.72);
    // 3. high-frequency flicker so the light never sits still.
    const flicker = time.mul(9).add(aDist.mul(41)).sin().mul(0.08).add(0.94);
    // 4. the white flash riding the propagation front.
    const flash = smoothstep(0.0, 0.22, abs(uGrown.sub(aDist))).oneMinus().mul(1.6);

    const heat = mix(float(1), center, onCenter)
      .mul(mix(float(1), pulse, onPulse))
      .mul(mix(float(1), flicker, onFlicker))
      .mul(uHeat)
      .add(flash.mul(onFlash));

    crackMat.colorNode = blackbody(heat);
    crackMat.opacityNode = smoothstep(0.82, 1.0, abs(aAcross)).oneMinus();
  }
  const crack = new THREE.Mesh(new THREE.PlaneGeometry(LEN, HALF_W * 2, 480, 8), crackMat);
  crack.position.set(X_OFF, 0.42, 0);
  scene.add(crack);

  // ---------- the ramp legend ----------

  const rampMat = new MeshBasicNodeMaterial();
  rampMat.colorNode = blackbody(positionLocal.x.add(LEN / 2).div(LEN).mul(2.6));
  const ramp = new THREE.Mesh(new THREE.PlaneGeometry(LEN, 0.16, 240, 1), rampMat);
  ramp.position.set(X_OFF, -0.62, 0);
  scene.add(ramp);

  // HTML ticks under the ramp, projected from world space once (the camera is fixed).
  const ticks = document.createElement('div');
  ticks.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:4';
  document.body.appendChild(ticks);
  const tickValues = [0, 0.55, 1.15, 2.1, 2.6];
  const tickLabels = ['seam', 'red', 'orange', 'white-hot', ''];
  const tickEls = tickValues.map((v, i) => {
    const el = document.createElement('div');
    el.style.cssText =
      'position:absolute;transform:translate(-50%,0);font-size:11.5px;color:#97a0b8;' +
      'font-variant-numeric:tabular-nums;text-align:center;white-space:nowrap;' +
      // The white-hot end of the ramp blooms over the labels otherwise.
      'background:rgba(10,12,20,.78);padding:4px 8px 5px;border-radius:7px';
    el.innerHTML = `<div style="width:1px;height:8px;background:rgba(150,160,200,.5);margin:0 auto 4px"></div>` +
      `heat ${v}${tickLabels[i] ? `<br><span style="color:#c9a4ff">${tickLabels[i]}</span>` : ''}`;
    ticks.appendChild(el);
    return el;
  });

  const placeTicks = (): void => {
    const p = new THREE.Vector3();
    for (let i = 0; i < tickValues.length; i++) {
      p.set(X_OFF - LEN / 2 + (tickValues[i] / 2.6) * LEN, ramp.position.y - 0.11, 0);
      p.project(camera);
      tickEls[i].style.left = `${((p.x + 1) / 2) * innerWidth}px`;
      tickEls[i].style.top = `${((1 - p.y) / 2) * innerHeight}px`;
    }
  };
  window.addEventListener('resize', placeTicks);

  // ---------- panel ----------

  let sweep = true;

  const ui = new Panel('Heat terms');
  ui.check({ label: '1 · cross-section', value: true, onChange: (v) => { onCenter.value = v ? 1 : 0; } });
  ui.check({ label: '2 · travelling pulse', value: true, onChange: (v) => { onPulse.value = v ? 1 : 0; } });
  ui.check({ label: '3 · flicker', value: true, onChange: (v) => { onFlicker.value = v ? 1 : 0; } });
  ui.check({ label: '4 · front flash', value: true, onChange: (v) => { onFlash.value = v ? 1 : 0; } });
  ui.slider({
    label: 'Heat',
    value: 1.5,
    min: 0.2,
    max: 3,
    onChange: (v) => { uHeat.value = v; },
  });
  ui.slider({
    label: 'Pulse speed',
    value: 1,
    min: 0,
    max: 3,
    onChange: (v) => { uPulseSpeed.value = v; },
  });
  ui.check({
    label: 'Sweep the front',
    value: true,
    onChange: (v) => { sweep = v; },
  });
  ui.note(
    'Everything above resolves to one float. The ramp underneath is the whole palette: ' +
    'no texture, no lights, four <b>mix()</b> calls.',
  );

  stage.onFrame((dt) => {
    if (sweep) {
      uGrown.value += dt * 1.6;
      if (uGrown.value > LEN + 0.6) uGrown.value = -0.4;
    }
    placeTicks();
  });
}
demos/src/colony-pulse.ts
파일 저장

import * as THREE from 'three/webgpu';
import { MeshBasicNodeMaterial } from 'three/webgpu';
import { float, hash, instanceIndex, mix, positionLocal, positionWorld, smoothstep, time, uniform, vec3 } from 'three/tsl';
import { mulberry32 } from '../../src/modes/mode';
import { Panel, Readouts, createStage, fatal } from './kit';

/**
 * Demo — "one heartbeat for the whole reef".
 *
 * The reef's polyps don't own a timer. Their brightness is read out of a wave that lives in
 * WORLD space, so every colony — every separate stroke, painted minutes apart — lights up
 * in the right order automatically. The floor is shaded with the same expression, which is
 * why you can see the wavefront arrive.
 *
 * Switch to per-colony phase and the illusion collapses into three unrelated blinkers.
 */

const uPulse = uniform(1);
const uSharp = uniform(2.5);
const uGlow = uniform(1.2);
const uDir = uniform(new THREE.Vector3(1.6, 1.1, 1.35));
const uGlowA = uniform(new THREE.Color(0x2ee6d6));
const uGlowB = uniform(new THREE.Color(0x4e8aff));

/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-function-return-type */
const colorVec = (u: unknown) => vec3(u as any);

/** The production expression, verbatim apart from the direction being a uniform here. */
function colonyPulse() {
  return positionWorld.dot(colorVec(uDir)).mul(2.6)
    .sub(time.mul(uPulse.mul(2.1)))
    .sin().mul(0.5).add(0.5).pow(uSharp);
}

/** The naive alternative: each colony keeps its own clock, in its own local space. */
function localPulse(phase: number) {
  return positionLocal.dot(colorVec(uDir)).mul(2.6)
    .sub(time.mul(uPulse.mul(2.1)).add(phase))
    .sin().mul(0.5).add(0.5).pow(uSharp);
}
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-function-return-type */

const stage = await createStage({
  cameraPos: [-0.5, 3.1, 6.6],
  target: [-0.5, 0.3, 0],
  fov: 44,
  bloom: { strength: 0.7, threshold: 0.5 },
  orbit: true,
}).catch((err) => {
  fatal(err);
  return null;
});

if (stage) {
  const { scene } = stage;

  // ---------- the floor, shaded with the same wave ----------

  const floorMat = new MeshBasicNodeMaterial();
  // Fade the floor out with distance instead of letting its far edge cut across the frame.
  const horizon = float(1).sub(smoothstep(5, 20, positionWorld.length()));
  floorMat.colorNode = vec3(0.03, 0.045, 0.07)
    .add(colorVec(uGlowA).mul(colonyPulse()).mul(0.16))
    .mul(horizon);
  // The wave is evaluated per fragment from positionWorld, so one quad is enough — it just
  // has to be big enough that its far edge never enters frame.
  const floor = new THREE.Mesh(new THREE.PlaneGeometry(46, 46), floorMat);
  floor.rotation.x = -Math.PI / 2;
  scene.add(floor);

  // ---------- three colonies ----------

  const rnd = mulberry32(0x1eef);
  const tipGeo = new THREE.IcosahedronGeometry(1, 1);

  const worldMat = new MeshBasicNodeMaterial();
  {
    const blink = time.mul(0.8).add(hash(instanceIndex).mul(6.283)).sin().mul(0.15).add(0.85);
    const c = mix(colorVec(uGlowA), colorVec(uGlowB), hash(instanceIndex.add(9)));
    worldMat.colorNode = c.mul(colonyPulse().mul(2.6).add(0.2)).mul(blink).mul(uGlow);
  }

  interface Colony {
    group: THREE.Group;
    localMat: MeshBasicNodeMaterial;
    mesh: THREE.InstancedMesh;
  }

  const colonies: Colony[] = [];
  const centres: [number, number][] = [[-2.8, 0.3], [-0.5, -0.4], [1.8, 0.5]];

  centres.forEach(([cx, cz], ci) => {
    const group = new THREE.Group();
    group.position.set(cx, 0, cz);
    scene.add(group);

    const localMat = new MeshBasicNodeMaterial();
    const blink = time.mul(0.8).add(hash(instanceIndex).mul(6.283)).sin().mul(0.15).add(0.85);
    const c = mix(colorVec(uGlowA), colorVec(uGlowB), hash(instanceIndex.add(9)));
    localMat.colorNode = c.mul(localPulse(ci * 2.1).mul(2.6).add(0.2)).mul(blink).mul(uGlow);

    // A scruffy little colony: polyps scattered on a few upright stalks.
    const N = 70;
    const mesh = new THREE.InstancedMesh(tipGeo, worldMat, N);
    mesh.frustumCulled = false;
    const m = new THREE.Matrix4();
    const q = new THREE.Quaternion();
    const s = new THREE.Vector3();
    const p = new THREE.Vector3();
    for (let i = 0; i < N; i++) {
      const a = rnd() * Math.PI * 2;
      const r = Math.pow(rnd(), 0.6) * 0.75;
      p.set(Math.cos(a) * r, 0.06 + Math.pow(rnd(), 1.6) * 0.85, Math.sin(a) * r);
      s.setScalar(0.028 + rnd() * 0.03);
      mesh.setMatrixAt(i, m.compose(p, q, s));
    }
    mesh.instanceMatrix.needsUpdate = true;
    group.add(mesh);

    colonies.push({ group, localMat, mesh });
  });

  // ---------- panel ----------

  let drift = true;

  const out = new Readouts();
  const readMode = out.add('Pulse source', 'positionWorld', 'good');
  const readColonies = out.add('Colonies', '3 · one shared wave');

  const ui = new Panel('Colony pulse');
  ui.check({
    label: 'World-space wave',
    value: true,
    onChange: (v) => {
      for (const c of colonies) c.mesh.material = v ? worldMat : c.localMat;
      floor.visible = v;
      readMode(v ? 'positionWorld' : 'positionLocal + phase', v ? 'good' : 'bad');
      readColonies(v ? '3 · one shared wave' : '3 · three private clocks');
    },
  });
  ui.check({
    label: 'Drift the middle colony',
    value: true,
    onChange: (v) => { drift = v; },
  });
  ui.slider({
    label: 'Pulse speed',
    value: 1,
    min: 0,
    max: 3,
    onChange: (v) => { uPulse.value = v; },
  });
  ui.slider({
    label: 'Wave sharpness',
    value: 2.5,
    min: 1,
    max: 8,
    onChange: (v) => { uSharp.value = v; },
  });
  ui.slider({
    label: 'Wave heading',
    value: 0,
    min: -Math.PI,
    max: Math.PI,
    format: (v) => `${Math.round((v * 180) / Math.PI)}°`,
    onChange: (v) => {
      const d = uDir.value as THREE.Vector3;
      d.set(Math.cos(v) * 1.9, 1.1, Math.sin(v) * 1.9);
    },
  });
  ui.slider({
    label: 'Bioluminescence',
    value: 1.2,
    min: 0,
    max: 2.5,
    onChange: (v) => { uGlow.value = v; },
  });
  ui.note(
    'With <b>positionWorld</b>, moving a colony moves it <i>through</i> the wave. With a ' +
    'local phase it carries its own beat around and nothing lines up.',
  );

  stage.onFrame((_dt, t) => {
    const middle = colonies[1].group;
    middle.position.x = drift ? -0.5 + Math.sin(t * 0.45) * 1.3 : -0.5;
  });
}
demos/src/cull.ts
파일 저장

import * as THREE from 'three/webgpu';
import {
  MAX_DENSITY, MAX_SHARDS, crystalMode, defaultCrystalSettings, type CrystalSettings,
} from '../../src/modes/crystals';
import type { StrokeInstance } from '../../src/modes/mode';
import { Panel, Readouts, arcSamples, canvasSphere, createStage, fatal, studioLights } from './kit';

/**
 * Demo — "generate at the maximum, cull with the slider".
 *
 * This runs the real crystal mode. Every slider you drag calls `applySettings()` on the
 * stroke that already exists: matrices and colors are recomposed in place and culled
 * instances collapse to a zero-scale matrix. Nothing is ever disposed or rebuilt.
 *
 * Tick "Show culled instances" to see the slots that are still allocated, still in the
 * buffer, and simply scaled to nothing.
 */

const RADIUS = 1;

const stage = await createStage({
  cameraPos: [0.1, 0.55, 3.1],
  fov: 42,
  environment: true,
  bloom: { strength: 0.35, threshold: 0.8 },
  orbit: true,
}).catch((err) => {
  fatal(err);
  return null;
});

if (stage) {
  const { scene } = stage;
  studioLights(scene);
  scene.add(canvasSphere(RADIUS, 96));

  const samples = arcSamples({
    radius: RADIUS,
    from: new THREE.Vector3(-0.8, -0.3, 0.85),
    to: new THREE.Vector3(0.85, 0.6, 0.7),
    count: 70,
    wobble: 0.09,
  });

  const settings: CrystalSettings = { ...defaultCrystalSettings, glow: 0.15 };
  const SEED = 0x51ce;

  const stroke = crystalMode.createStroke(samples, SEED, settings);
  stroke.finishGrowth();
  scene.add(stroke.group);

  // A second stroke from the SAME seed with both cull sliders pinned at their maximum.
  // Same generator, same randoms, so its crystals land exactly on top of the live ones —
  // the extras it draws are precisely the instances the sliders are hiding.
  const ghost: StrokeInstance = crystalMode.createStroke(
    samples,
    SEED,
    { ...settings, clusterDensity: MAX_DENSITY, shards: MAX_SHARDS },
  );
  ghost.finishGrowth();
  const ghostMat = new THREE.MeshBasicMaterial({
    color: 0x7d6ab5,
    wireframe: true,
    transparent: true,
    opacity: 0.4,
    depthWrite: false,
    toneMapped: false,
  });
  ghost.group.traverse((o) => {
    const mesh = o as THREE.Mesh;
    if (!mesh.isMesh) return;
    mesh.material = ghostMat;
    mesh.castShadow = false;
    mesh.receiveShadow = false;
    mesh.renderOrder = -1;
  });
  ghost.group.visible = false;
  scene.add(ghost.group);

  // ---------- instrumentation ----------

  let applyCalls = 0;

  /** Walks the real instance buffers and counts the slots that aren't zero-scaled. */
  function countInstances(): { allocated: number; live: number; meshes: number } {
    let allocated = 0;
    let live = 0;
    let meshes = 0;
    stroke.group.traverse((o) => {
      const mesh = o as THREE.InstancedMesh;
      if (!mesh.isInstancedMesh) return;
      meshes++;
      const arr = mesh.instanceMatrix.array as Float32Array;
      for (let i = 0; i < mesh.count; i++) {
        allocated++;
        const b = i * 16;
        // A zero-scale matrix has an all-zero upper 3×3 block.
        for (let k = 0; k < 11; k++) {
          if (arr[b + k] !== 0) { live++; break; }
        }
      }
    });
    // Every crystal owns a slot in BOTH the tinted and the clear mesh; only one is ever
    // posed, so halve the allocation to get the crystal count.
    return { allocated: allocated / 2, live, meshes };
  }

  const out = new Readouts();
  const readAlloc = out.add('Crystals generated', '—');
  const readLive = out.add('Crystals drawn', '—', 'hi');
  const readDraws = out.add('InstancedMesh draws', '—');
  const readRebuild = out.add('Geometry rebuilds', '0', 'good');
  const readApply = out.add('applySettings() calls', '0', 'hi');

  function apply(): void {
    applyCalls++;
    stroke.applySettings?.(settings);
    ghost.applySettings?.({ ...settings, clusterDensity: MAX_DENSITY, shards: MAX_SHARDS });
    const c = countInstances();
    readAlloc(c.allocated.toLocaleString());
    readLive(c.live.toLocaleString());
    readDraws(String(c.meshes));
    readApply(String(applyCalls));
  }

  // ---------- panel ----------

  const ui = new Panel('Crystal sliders');
  ui.slider({
    label: 'Clusters / unit',
    value: settings.clusterDensity,
    min: 1,
    max: MAX_DENSITY,
    step: 1,
    format: (v) => String(v),
    onChange: (v) => { settings.clusterDensity = v; apply(); },
  });
  ui.slider({
    label: 'Shards / cluster',
    value: settings.shards,
    min: 0,
    max: MAX_SHARDS,
    step: 1,
    format: (v) => String(v),
    onChange: (v) => { settings.shards = v; apply(); },
  });
  ui.slider({
    label: 'Crystal size',
    value: settings.crystalSize,
    min: 0.06,
    max: 0.4,
    format: (v) => v.toFixed(3),
    onChange: (v) => { settings.crystalSize = v; apply(); },
  });
  ui.slider({
    label: 'Lean',
    value: settings.tilt,
    min: 0,
    max: 1,
    onChange: (v) => { settings.tilt = v; apply(); },
  });
  ui.slider({
    label: 'Clear quartz mix',
    value: settings.clearMix,
    min: 0,
    max: 1,
    onChange: (v) => { settings.clearMix = v; apply(); },
  });
  ui.check({
    label: 'Show culled instances',
    onChange: (v) => { ghost.group.visible = v; },
  });
  ui.note(
    'Rebuild counter stays at <b>0</b> no matter how long you drag. The sliders only ever ' +
    'rewrite matrices that already exist.',
  );

  apply();
  readRebuild('0');

  stage.onFrame((dt, t) => {
    stroke.update(dt, t);
    ghost.update(dt, t);
  });
}
demos/src/demo.css
파일 저장

/* Shared chrome for the demo routes.
   Tuned for screen capture: big type, high contrast, no thin hairlines that vanish in a GIF. */

:root {
  --bg: #0a0b10;
  --ink: #eef0f6;
  --dim: #97a0b8;
  --violet: #c9a4ff;
  --violet-deep: #8a5cff;
  --good: #6ee7a8;
  --bad: #ff7a8a;
  --panel: rgba(14, 16, 26, 0.82);
  --line: rgba(150, 160, 200, 0.18);
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  height: 100%;
  overflow: hidden;
  background: var(--bg);
  color: var(--ink);
  font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
}

#stage { position: fixed; inset: 0; }
#stage canvas { display: block; }

/* ---------- caption (top-left) ---------- */

.caption {
  position: fixed;
  left: 22px;
  top: 20px;
  z-index: 5;
  max-width: 420px;
  pointer-events: none;
}
.caption h1 {
  margin: 0;
  font-size: 19px;
  font-weight: 650;
  letter-spacing: -0.01em;
}
.caption p {
  margin: 6px 0 0;
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--dim);
}
.caption .home {
  display: inline-block;
  margin-bottom: 10px;
  font-size: 11.5px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--violet);
  text-decoration: none;
  opacity: 0.75;
  pointer-events: auto;
}
.caption .home:hover { opacity: 1; }

/* ---------- control panel (top-right) ---------- */

.panel {
  position: fixed;
  right: 22px;
  top: 20px;
  z-index: 5;
  width: 268px;
  padding: 14px 16px 16px;
  border-radius: 14px;
  background: var(--panel);
  border: 1px solid var(--line);
  backdrop-filter: blur(10px);
  font-size: 13px;
}
.panel h2 {
  margin: 0 0 12px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--dim);
}
.panel .row { margin-bottom: 13px; }
.panel .row:last-child { margin-bottom: 0; }
.panel label {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 10px;
  margin-bottom: 6px;
  color: var(--ink);
}
.panel label .val {
  font-variant-numeric: tabular-nums;
  font-size: 12px;
  color: var(--violet);
}

.panel input[type='range'] {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 4px;
  border-radius: 4px;
  background: rgba(180, 190, 230, 0.22);
  outline: none;
}
.panel input[type='range']::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--violet);
  border: none;
  cursor: pointer;
  box-shadow: 0 0 0 4px rgba(201, 164, 255, 0.16);
}
.panel input[type='range']::-moz-range-thumb {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--violet);
  border: none;
  cursor: pointer;
}

.panel .check {
  display: flex;
  justify-content: flex-start;
  align-items: center;
  gap: 9px;
  cursor: pointer;
  user-select: none;
  margin-bottom: 0;
}
.panel .check input { display: none; }
.panel .check .box {
  width: 15px;
  height: 15px;
  flex: none;
  border-radius: 4px;
  border: 1.5px solid rgba(180, 190, 230, 0.4);
  position: relative;
  transition: background 0.14s, border-color 0.14s;
}
.panel .check input:checked + .box {
  background: var(--violet);
  border-color: var(--violet);
}
.panel .check input:checked + .box::after {
  content: '';
  position: absolute;
  left: 4.5px;
  top: 1px;
  width: 4px;
  height: 8px;
  border: solid #1a0f2a;
  border-width: 0 2px 2px 0;
  transform: rotate(43deg);
}

.panel button {
  width: 100%;
  padding: 9px 12px;
  border: none;
  border-radius: 9px;
  font: inherit;
  font-weight: 650;
  font-size: 12.5px;
  color: #1a0f2a;
  background: linear-gradient(180deg, #dfc2ff, #b287f0);
  cursor: pointer;
}
.panel button:hover { filter: brightness(1.06); }
.panel button:active { transform: translateY(1px); }
.panel button.ghost {
  background: rgba(255, 255, 255, 0.07);
  color: var(--ink);
  border: 1px solid var(--line);
}

.panel .seg { display: flex; gap: 6px; }
.panel .seg button { flex: 1; }

.panel .note {
  margin: 12px 0 0;
  padding-top: 12px;
  border-top: 1px solid var(--line);
  font-size: 12px;
  line-height: 1.55;
  color: var(--dim);
}
.panel .note b { color: var(--violet); font-weight: 600; }

/* ---------- readouts (bottom-left) ---------- */

.readouts {
  position: fixed;
  left: 22px;
  bottom: 22px;
  z-index: 5;
  display: flex;
  flex-direction: column;
  gap: 7px;
  padding: 13px 16px;
  border-radius: 12px;
  background: var(--panel);
  border: 1px solid var(--line);
  backdrop-filter: blur(10px);
  font-size: 12.5px;
  pointer-events: none;
}
.readouts .r {
  display: flex;
  justify-content: space-between;
  gap: 24px;
  font-variant-numeric: tabular-nums;
}
.readouts .r span:first-child { color: var(--dim); }
.readouts .r span:last-child { color: var(--ink); font-weight: 600; }
.readouts .r.hi span:last-child { color: var(--violet); }
.readouts .r.good span:last-child { color: var(--good); }
.readouts .r.bad span:last-child { color: var(--bad); }

/* ---------- split-screen labels ---------- */

.splits {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 96px;
  z-index: 4;
  display: flex;
  pointer-events: none;
}
.splits > div { flex: 1; text-align: center; }
.splits .tag {
  display: inline-block;
  padding: 6px 15px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 650;
  background: rgba(14, 16, 26, 0.8);
  border: 1px solid var(--line);
}
.splits .tag.no { color: var(--bad); border-color: rgba(255, 122, 138, 0.35); }
.splits .tag.yes { color: var(--good); border-color: rgba(110, 231, 168, 0.35); }
.splits .sub {
  display: block;
  margin-top: 7px;
  font-size: 12px;
  color: var(--dim);
}

/* ---------- demo index ---------- */

.index-page {
  overflow: auto;
  display: block;
  padding: 64px 32px 96px;
}
.index-wrap { max-width: 940px; margin: 0 auto; }
.index-wrap h1 { font-size: 30px; margin: 0 0 8px; letter-spacing: -0.02em; }
.index-wrap .lede { color: var(--dim); font-size: 15px; line-height: 1.6; max-width: 640px; margin: 0 0 40px; }
.index-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
.index-grid a {
  display: block;
  padding: 18px 20px;
  border-radius: 14px;
  text-decoration: none;
  color: var(--ink);
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--line);
  transition: background 0.15s, border-color 0.15s, transform 0.15s;
}
.index-grid a:hover {
  background: rgba(201, 164, 255, 0.07);
  border-color: rgba(201, 164, 255, 0.35);
  transform: translateY(-2px);
}
.index-grid .n {
  font-size: 11px;
  letter-spacing: 0.1em;
  color: var(--violet);
  opacity: 0.8;
}
.index-grid h3 { margin: 7px 0 6px; font-size: 16px; font-weight: 620; }
.index-grid p { margin: 0; font-size: 13px; line-height: 1.55; color: var(--dim); }

/* ---------- fatal ---------- */

.fatal {
  position: fixed;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 9;
  max-width: 460px;
  padding: 18px 22px;
  border-radius: 12px;
  background: rgba(28, 10, 14, 0.9);
  border: 1px solid rgba(255, 122, 138, 0.4);
  color: #ffb4bd;
  font-size: 13.5px;
  line-height: 1.55;
}
demos/src/fold-light.ts
파일 저장

import * as THREE from 'three/webgpu';
import { MeshBasicNodeMaterial } from 'three/webgpu';
import { abs, cos, float, mix, positionLocal, smoothstep, time, uniform, vec3 } from 'three/tsl';
import { Panel, createStage, fatal } from './kit';

/**
 * Demo — "light the folds, not the sheet".
 *
 * Both curtains run the identical vertex wave. The left one is evenly lit; the right one
 * multiplies its colour by |cos(foldPhase)| — the *same phase* that displaced the vertices.
 * That single shared term is what turns a wobbling plane into fabric.
 *
 * The offset slider breaks the link on purpose: push the fragment phase away from the
 * vertex phase and the light slides off the folds it is supposed to belong to.
 */

const WIDTH = 2.1;
const HEIGHT = 1.15;

const stage = await createStage({
  cameraPos: [0, 0.5, 3.5],
  target: [0, 0.45, 0],
  fov: 46,
  bloom: { strength: 0.4, threshold: 0.7 },
  orbit: true,
}).catch((err) => {
  fatal(err);
  return null;
});

if (stage) {
  const { scene } = stage;

  const uWave = uniform(0.55);
  const uFlow = uniform(1);
  const uOffset = uniform(0);
  const uBright = uniform(1);

  const HEM = new THREE.Color(0x3cffa8);
  const MID = new THREE.Color(0x36c9ff);
  const TOP = new THREE.Color(0xb26bff);

  /** One curtain. `foldLocked` decides whether the fragment stage knows about the wave. */
  function curtain(foldLocked: boolean): THREE.Mesh {
    const mat = new MeshBasicNodeMaterial();
    mat.transparent = true;
    mat.depthWrite = false;
    mat.side = THREE.DoubleSide;
    mat.blending = THREE.AdditiveBlending;

    const aDist = positionLocal.x;
    const aV = positionLocal.y.div(HEIGHT);
    const T = time.mul(uFlow);

    // ----- vertex: two travelling waves, amplitude growing with height -----
    const foldPhase = aDist.mul(6.3).add(T.mul(1.1));
    const amp = uWave.mul(0.34).mul(aV.pow(1.35));
    const sway = foldPhase.sin()
      .add(aDist.mul(11.7).sub(T.mul(0.7)).add(aV.mul(1.8)).sin().mul(0.5));
    mat.positionNode = positionLocal.add(vec3(0, 0, amp.mul(sway)));

    // ----- fragment -----
    let grad = mix(vec3(HEM.r, HEM.g, HEM.b), vec3(MID.r, MID.g, MID.b), smoothstep(0.03, 0.45, aV));
    grad = mix(grad, vec3(TOP.r, TOP.g, TOP.b), smoothstep(0.45, 0.95, aV));

    // The whole trick, in one line: reuse foldPhase from the vertex stage.
    // 0.95 is the average value of the fold term, so both curtains carry the same
    // overall brightness and the only difference on screen is where the light sits.
    const folds = foldLocked
      ? abs(cos(foldPhase.add(uOffset))).pow(1.6).mul(0.85).add(0.4)
      : float(0.95);

    const hemBoost = smoothstep(0.0, 0.22, aV).oneMinus().mul(1.3).add(1);
    mat.colorNode = grad.mul(folds).mul(hemBoost).mul(uBright).mul(1.3);

    const endFade = smoothstep(0.0, 0.18, abs(aDist).oneMinus());
    mat.opacityNode = float(1).sub(aV).pow(1.15).mul(endFade).mul(0.9);

    const geo = new THREE.PlaneGeometry(WIDTH, HEIGHT, 160, 30);
    geo.translate(0, HEIGHT / 2, 0); // hem pinned at y = 0
    const mesh = new THREE.Mesh(geo, mat);
    mesh.frustumCulled = false;
    return mesh;
  }

  const left = curtain(false);
  left.position.x = -1.2;
  const right = curtain(true);
  right.position.x = 1.2;
  scene.add(left, right);

  // A dim floor line under each hem, so "the hem stays pinned" is visible.
  for (const x of [-1.2, 1.2]) {
    const hem = new THREE.Mesh(
      new THREE.PlaneGeometry(WIDTH, 0.02),
      new THREE.MeshBasicMaterial({ color: 0x2a3350, toneMapped: false }),
    );
    hem.position.set(x, 0, 0);
    scene.add(hem);
  }

  const ui = new Panel('Curtain');
  ui.slider({
    label: 'Billow',
    value: 0.55,
    min: 0,
    max: 1,
    onChange: (v) => { uWave.value = v; },
  });
  ui.slider({
    label: 'Flow speed',
    value: 1,
    min: 0,
    max: 3,
    onChange: (v) => { uFlow.value = v; },
  });
  ui.slider({
    label: 'Fragment phase offset',
    value: 0,
    min: 0,
    max: Math.PI * 2,
    format: (v) => `${(v / Math.PI).toFixed(2)}π`,
    onChange: (v) => { uOffset.value = v; },
  });
  ui.slider({
    label: 'Brightness',
    value: 1,
    min: 0.2,
    max: 2.5,
    onChange: (v) => { uBright.value = v; },
  });
  ui.note(
    'Drag the offset away from <b>0</b> and the right curtain stops being cloth — the ' +
    'bright bands drift free of the geometry they are meant to be lighting.',
  );
}
demos/src/growth.ts
파일 저장

import * as THREE from 'three/webgpu';
import { mulberry32 } from '../../src/modes/mode';
import { Panel, Readouts, createStage, fatal, studioLights } from './kit';

/**
 * Demo — "the growth front".
 *
 * Nothing here is on a timer. Every crystal stores the distance along the stroke at which
 * it was seeded (`birth`), the stroke stores how far the front has travelled (`grown`), and
 * the animation is just the difference between the two. That is why growth speed is a live
 * slider and why replaying a stroke costs nothing.
 *
 * Both rows share one front. Only the easing differs.
 */

const COUNT = 34;
const SPAN = 4.2;
const _m = new THREE.Matrix4();
const _q = new THREE.Quaternion();
const _s = new THREE.Vector3();
const _zero = new THREE.Matrix4().makeScale(0, 0, 0);

/** The mode's pop: overshoots ~8% then settles, like a crystal snapping into being. */
function easeOutBack(t: number): number {
  const c1 = 1.20158;
  const c3 = c1 + 1;
  const u = t - 1;
  return 1 + c3 * u * u * u + c1 * u * u;
}

/** A stand-in for the mode's quartz point: hexagonal, tapered, flat-shaded. */
function crystalGeometry(rnd: () => number): THREE.BufferGeometry {
  const sides = 6;
  const positions: number[] = [];
  const lower: THREE.Vector3[] = [];
  const upper: THREE.Vector3[] = [];
  const apex = new THREE.Vector3((rnd() - 0.5) * 0.12, 1, (rnd() - 0.5) * 0.12);
  for (let i = 0; i < sides; i++) {
    const a = ((i + (rnd() - 0.5) * 0.3) / sides) * Math.PI * 2;
    const r = 0.2 * (0.8 + rnd() * 0.4);
    lower.push(new THREE.Vector3(Math.cos(a) * r, 0, Math.sin(a) * r));
    upper.push(new THREE.Vector3(Math.cos(a) * r * 0.85, 0.62, Math.sin(a) * r * 0.85));
  }
  const push = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3): void => {
    positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z);
  };
  for (let i = 0; i < sides; i++) {
    const j = (i + 1) % sides;
    push(lower[i], upper[i], upper[j]);
    push(lower[i], upper[j], lower[j]);
    push(upper[i], apex, upper[j]);
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  geo.computeVertexNormals();
  return geo;
}

const stage = await createStage({
  cameraPos: [0, 0.05, 4.4],
  fov: 46,
  environment: true,
  bloom: { strength: 0.4, threshold: 0.75 },
  orbit: true,
}).catch((err) => {
  fatal(err);
  return null;
});

if (stage) {
  const { scene } = stage;
  studioLights(scene);

  const rnd = mulberry32(0x9e0117);
  const geo = crystalGeometry(rnd);
  const material = new THREE.MeshPhysicalMaterial({
    color: 0xffffff,
    roughness: 0.05,
    transmission: 0.7,
    ior: 1.55,
    thickness: 0.4,
    attenuationColor: new THREE.Color(0x7a2fd6),
    attenuationDistance: 0.5,
    iridescence: 0.4,
    clearcoat: 0.5,
    envMapIntensity: 1.6,
  });

  interface Row {
    mesh: THREE.InstancedMesh;
    ease: (t: number) => number;
  }

  const births: number[] = [];
  const heights: number[] = [];
  for (let i = 0; i < COUNT; i++) {
    births.push((i / (COUNT - 1)) * SPAN + rnd() * 0.06);
    heights.push(0.34 + rnd() * 0.26);
  }

  function makeRow(y: number, ease: (t: number) => number): Row {
    const mesh = new THREE.InstancedMesh(geo, material, COUNT);
    mesh.frustumCulled = false;
    mesh.castShadow = true;
    mesh.position.set(0, y, 0);
    for (let i = 0; i < COUNT; i++) mesh.setMatrixAt(i, _zero);
    mesh.instanceMatrix.needsUpdate = true;
    scene.add(mesh);
    return { mesh, ease };
  }

  const rows: Row[] = [
    makeRow(0.42, (t) => t),          // linear
    makeRow(-0.72, easeOutBack),      // what the modes actually use
  ];

  // ---------- the front marker + its window ----------

  const frontLine = new THREE.Mesh(
    new THREE.PlaneGeometry(0.012, 2.1),
    new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true, opacity: 0.85 }),
  );
  const window0 = new THREE.Mesh(
    new THREE.PlaneGeometry(1, 2.1),
    new THREE.MeshBasicMaterial({
      color: 0x8a5cff, toneMapped: false, transparent: true, opacity: 0.035,
      blending: THREE.AdditiveBlending, depthWrite: false,
    }),
  );
  frontLine.position.z = 0.4;
  window0.position.z = 0.38;
  scene.add(frontLine, window0);

  // ---------- state ----------

  let speed = 1.4;
  let growWindow = 0.45;
  let grown = 0;

  const out = new Readouts();
  const readGrown = out.add('Front position', '0.00', 'hi');
  const readBorn = out.add('Crystals born', `0 / ${COUNT}`);
  const readPopping = out.add('Inside the window', '0', 'good');

  const ui = new Panel('Growth');
  ui.slider({
    label: 'Growth speed',
    value: speed,
    min: 0.2,
    max: 4,
    format: (v) => `${v.toFixed(2)} u/s`,
    onChange: (v) => { speed = v; },
  });
  ui.slider({
    label: 'Growth window',
    value: growWindow,
    min: 0.08,
    max: 1.4,
    format: (v) => v.toFixed(2),
    onChange: (v) => { growWindow = v; },
  });
  ui.button('▶ Replay', () => { grown = 0; });
  ui.note(
    'Top row scales linearly. Bottom row runs <b>easeOutBack</b> — that 5% overshoot is the ' +
    'entire difference between "a mesh appeared" and "a crystal snapped into being".' +
    '<svg id="plot" viewBox="0 0 104 62" style="width:100%;margin-top:12px;overflow:visible">' +
    '<line x1="2" y1="52" x2="102" y2="52" stroke="rgba(150,160,200,.35)" stroke-width="1"/>' +
    '<line x1="2" y1="52" x2="2" y2="4" stroke="rgba(150,160,200,.35)" stroke-width="1"/>' +
    '<line x1="2" y1="12" x2="102" y2="12" stroke="rgba(150,160,200,.18)" stroke-width="1" stroke-dasharray="3 3"/>' +
    '<path id="pLin" fill="none" stroke="#8ea0c8" stroke-width="1.6"/>' +
    '<path id="pBack" fill="none" stroke="#c9a4ff" stroke-width="1.8"/>' +
    '<circle id="dLin" r="2.6" fill="#8ea0c8"/>' +
    '<circle id="dBack" r="2.6" fill="#c9a4ff"/>' +
    '</svg>',
  );

  // Plot the two easings once: x = t (0..1), y = scale (0 at the axis, 1 at the dashed line).
  const px = (t: number): number => 2 + t * 100;
  const py = (k: number): number => 52 - k * 40;
  const plotPath = (id: string, fn: (t: number) => number): void => {
    const pts: string[] = [];
    for (let i = 0; i <= 48; i++) {
      const t = i / 48;
      pts.push(`${i === 0 ? 'M' : 'L'}${px(t).toFixed(1)},${py(fn(t)).toFixed(1)}`);
    }
    document.getElementById(id)?.setAttribute('d', pts.join(' '));
  };
  plotPath('pLin', (t) => t);
  plotPath('pBack', easeOutBack);
  const dotLin = document.getElementById('dLin');
  const dotBack = document.getElementById('dBack');

  // ---------- frame ----------

  const mid = Math.floor(COUNT / 2);
  const _p = new THREE.Vector3();

  stage.onFrame((dt) => {
    grown += dt * speed;
    if (grown > SPAN + growWindow + 1.2) grown = 0;

    let born = 0;
    let popping = 0;

    for (const row of rows) {
      for (let i = 0; i < COUNT; i++) {
        const t = (grown - births[i]) / growWindow;
        if (t <= 0) {
          row.mesh.setMatrixAt(i, _zero);
          continue;
        }
        const k = t >= 1 ? 1 : row.ease(t);
        const h = heights[i] * k;
        // Crystals emerge narrower than tall, then relax — the mode does the same.
        const w = heights[i] * k * (0.6 + 0.4 * k) * 0.55;
        _s.set(w, h, w);
        _p.set(-SPAN / 2 + births[i], 0, 0);
        _m.compose(_p, _q, _s);
        row.mesh.setMatrixAt(i, _m);
      }
      row.mesh.instanceMatrix.needsUpdate = true;
    }

    for (let i = 0; i < COUNT; i++) {
      const t = (grown - births[i]) / growWindow;
      if (t > 0) born++;
      if (t > 0 && t < 1) popping++;
    }

    const frontX = -SPAN / 2 + Math.min(grown, SPAN + growWindow);
    frontLine.position.x = frontX;
    frontLine.visible = grown < SPAN + growWindow;
    window0.scale.x = growWindow;
    window0.position.x = frontX - growWindow / 2;
    window0.visible = frontLine.visible;

    readGrown(grown.toFixed(2));
    readBorn(`${born} / ${COUNT}`);
    readPopping(String(popping));

    const tMid = THREE.MathUtils.clamp((grown - births[mid]) / growWindow, 0, 1);
    dotLin?.setAttribute('cx', String(px(tMid)));
    dotLin?.setAttribute('cy', String(py(tMid)));
    dotBack?.setAttribute('cx', String(px(tMid)));
    dotBack?.setAttribute('cy', String(py(easeOutBack(tMid))));
  });
}
demos/src/index.ts
파일 저장

import './demo.css';
demos/src/kit.ts
파일 저장

import * as THREE from 'three/webgpu';
import { pass } from 'three/tsl';
import { bloom } from 'three/addons/tsl/display/BloomNode.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import type { SurfaceSample } from '../../src/modes/mode';
import './demo.css';

/**
 * Shared plumbing for the demo routes.
 *
 * Every route in /demos isolates ONE mechanism from the main app and loops it, so it can be
 * screen-recorded as a short GIF without anyone having to drive it by hand. This module owns
 * the boring half of that: renderer bootstrap, the studio environment, a capture-friendly
 * control panel, and a scripted "hand" that paints the same stroke over and over.
 */

// ---------- stage ----------

export interface StageOptions {
  cameraPos?: [number, number, number];
  target?: [number, number, number];
  fov?: number;
  /** Adds the studio environment map (see ENV_PANELS). */
  environment?: boolean;
  /** Adds the bloom + tone-mapping post chain the main app uses. */
  bloom?: false | { strength: number; threshold: number };
  orbit?: boolean;
  background?: number;
  exposure?: number;
}

export interface Stage {
  renderer: THREE.WebGPURenderer;
  scene: THREE.Scene;
  camera: THREE.PerspectiveCamera;
  controls: OrbitControls | null;
  /** Rebuild the PMREM environment from a subset of ENV_PANELS. */
  setEnvironment(panels: readonly EnvPanel[]): void;
  /** Register a per-frame callback. `dt` is clamped seconds, `t` is seconds since start. */
  onFrame(cb: (dt: number, t: number) => void): void;
  /** Steps and draws exactly one frame. Used by the automated checks. */
  renderOnce(seconds?: number): Promise<void>;
}

/** Boots a WebGPU (or WebGL2-fallback) stage into #stage and starts the render loop. */
export async function createStage(options: StageOptions = {}): Promise<Stage> {
  const container = document.getElementById('stage') as HTMLElement;
  const renderer = new THREE.WebGPURenderer({ antialias: true });
  await renderer.init();
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = options.exposure ?? 1.1;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  container.appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(options.background ?? 0x0a0b10);

  const camera = new THREE.PerspectiveCamera(options.fov ?? 42, 1, 0.01, 100);
  camera.position.set(...(options.cameraPos ?? [0, 0.8, 4.2]));

  let controls: OrbitControls | null = null;
  if (options.orbit !== false) {
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = 0.08;
    controls.minDistance = 1.2;
    controls.maxDistance = 16;
    controls.target.set(...(options.target ?? [0, 0, 0]));
  } else {
    camera.lookAt(new THREE.Vector3(...(options.target ?? [0, 0, 0])));
  }

  let pmremTexture: THREE.Texture | null = null;
  const setEnvironment = (panels: readonly EnvPanel[]): void => {
    pmremTexture?.dispose();
    pmremTexture = buildEnvironmentTexture(renderer, panels);
    scene.environment = pmremTexture;
  };
  if (options.environment) setEnvironment(ENV_PANELS);

  let post: THREE.PostProcessing | null = null;
  if (options.bloom) {
    const scenePass = pass(scene, camera, { samples: 4 });
    const color = scenePass.getTextureNode();
    post = new THREE.PostProcessing(renderer);
    post.outputNode = color.add(bloom(color, options.bloom.strength, 0.6, options.bloom.threshold));
  }

  const resize = (): void => {
    const w = container.clientWidth;
    const h = container.clientHeight;
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
    renderer.setSize(w, h);
  };
  window.addEventListener('resize', resize);
  resize();

  const callbacks: ((dt: number, t: number) => void)[] = [];
  let last = 0;

  const advance = (dt: number, t: number): void => {
    controls?.update();
    for (const cb of callbacks) cb(dt, t);
  };
  const draw = (): void | Promise<void> => (post ? post.render() : renderer.render(scene, camera));
  const step = (dt: number, t: number): void | Promise<void> => {
    advance(dt, t);
    return draw();
  };

  // `?still=4` simulates four seconds, draws one frame and stops. Handy for stills, and it
  // lets a headless browser capture these pages — an endless rAF loop never goes idle.
  const still = new URLSearchParams(location.search).get('still');
  if (still === null) {
    renderer.setAnimationLoop((time: number) => {
      const dt = Math.min((time - last) / 1000, 0.05);
      last = time;
      void step(dt, time / 1000);
    });
  } else {
    const seconds = Number(still) || 3;
    setTimeout(() => { // callbacks are registered after this function returns
      const dt = 1 / 60;
      for (let i = 1; i <= Math.round(seconds / dt); i++) advance(dt, i * dt);
      void draw();
    }, 0);
  }

  const stage: Stage = {
    renderer,
    scene,
    camera,
    controls,
    setEnvironment,
    onFrame: (cb) => callbacks.push(cb),
    renderOnce: async (seconds = 1) => { await step(1 / 60, seconds); },
  };
  // Debug/scripting hook, same as the main app's `window.__app`.
  (window as unknown as { __stage: Stage }).__stage = stage;
  return stage;
}

/** Renders the fatal-error card the demos share when the renderer can't start. */
export function fatal(err: unknown): void {
  const el = document.createElement('div');
  el.className = 'fatal';
  el.textContent =
    `Couldn't start the renderer: ${(err as Error)?.message ?? err}. ` +
    'These demos need WebGPU or WebGL2 — try a recent Chrome, Edge or Firefox.';
  document.body.appendChild(el);
  console.error(err);
}

// ---------- studio environment ----------

export interface EnvPanel {
  id: string;
  label: string;
  color: number;
  intensity: number;
  size: [number, number];
  pos: [number, number, number];
}

/**
 * The light panels the main app prefilters into its environment map. Crystals and the
 * lacquered sphere are mostly REFLECTION, so this list — not the light rig — decides what
 * the highlights look like. The `studio` demo toggles them one at a time.
 */
export const ENV_PANELS: readonly EnvPanel[] = [
  { id: 'softbox', label: 'Overhead softbox', color: 0xfff6ea, intensity: 9, size: [4.5, 3], pos: [1.5, 8, 2] },
  { id: 'topback', label: 'Hard top-back strip', color: 0xffffff, intensity: 22, size: [0.7, 4.5], pos: [-2.5, 5, -6] },
  { id: 'cool', label: 'Cool strip (left)', color: 0x9db8ff, intensity: 5, size: [1.2, 7], pos: [-7, 2, -2] },
  { id: 'warm', label: 'Warm strip (right)', color: 0xffd9b0, intensity: 3.5, size: [1.6, 5], pos: [6, 1.5, 3] },
  { id: 'violet', label: 'Violet back wash', color: 0x8a5cff, intensity: 4, size: [6, 3.5], pos: [0, 2.5, -8] },
  { id: 'floor', label: 'Floor bounce', color: 0x2e3c58, intensity: 1.2, size: [9, 9], pos: [0, -5, 0] },
];

/** Builds the little room of emissive quads that becomes the environment map. */
export function buildEnvironmentScene(panels: readonly EnvPanel[]): THREE.Scene {
  const env = new THREE.Scene();
  const geo = new THREE.PlaneGeometry(1, 1);
  for (const p of panels) {
    const mat = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide });
    // HDR: colors above 1 stop being surfaces and start being light sources.
    mat.color.set(p.color).multiplyScalar(p.intensity);
    const mesh = new THREE.Mesh(geo, mat);
    mesh.scale.set(p.size[0], p.size[1], 1);
    mesh.position.set(...p.pos);
    mesh.lookAt(0, 0, 0);
    mesh.userData.panelId = p.id;
    env.add(mesh);
  }
  return env;
}

export function buildEnvironmentTexture(
  renderer: THREE.WebGPURenderer,
  panels: readonly EnvPanel[],
): THREE.Texture {
  const env = buildEnvironmentScene(panels);
  const pmrem = new THREE.PMREMGenerator(renderer);
  const texture = pmrem.fromScene(env, 0.04).texture;
  pmrem.dispose();
  return texture;
}

/** The app's canvas: a satin basalt sphere, matte enough to let painted geometry star. */
export function canvasSphere(radius = 1, segments = 96): THREE.Mesh {
  const mesh = new THREE.Mesh(
    new THREE.SphereGeometry(radius, segments, Math.round(segments * 0.66)),
    new THREE.MeshPhysicalMaterial({
      color: 0x1b1d24,
      metalness: 0.05,
      roughness: 0.52,
      clearcoat: 0.35,
      clearcoatRoughness: 0.3,
      envMapIntensity: 0.55,
    }),
  );
  mesh.castShadow = true;
  mesh.receiveShadow = true;
  return mesh;
}

/** A compact version of the app's three-point rig, for demos that need real shading. */
export function studioLights(parent: THREE.Object3D): void {
  const key = new THREE.SpotLight(0xfff2e2, 60, 0, Math.PI / 5, 0.55, 1.8);
  key.position.set(3.4, 5.6, 2.6);
  key.castShadow = true;
  key.shadow.mapSize.set(1024, 1024);
  key.shadow.bias = -0.0004;
  key.shadow.normalBias = 0.02;
  key.shadow.radius = 5;

  const back = new THREE.DirectionalLight(0xa9b8ff, 2.4);
  back.position.set(-3, 3.2, -4.5);
  const kick = new THREE.DirectionalLight(0xcaa6ff, 1.2);
  kick.position.set(4.5, 1.2, -3);
  const hemi = new THREE.HemisphereLight(0x8ea0c8, 0x0c0a14, 0.18);

  parent.add(key, key.target, back, kick, hemi);
}

// ---------- the scripted hand ----------

export interface ArcOptions {
  radius?: number;
  /** Direction of the first sample (normalised internally). */
  from: THREE.Vector3;
  to: THREE.Vector3;
  count?: number;
  /** Lateral wobble in radians — a hand-drawn stroke is never a great circle. */
  wobble?: number;
  wobbleFreq?: number;
  /**
   * Bunches samples where the "hand" slowed down. Pointer events arrive at a fixed rate,
   * not a fixed distance, so real strokes are dense in the corners and sparse in the sweep.
   */
  uneven?: boolean;
}

/** A believable painted stroke across a sphere, in anchor-local space. */
export function arcSamples(options: ArcOptions): SurfaceSample[] {
  const radius = options.radius ?? 1;
  const count = options.count ?? 60;
  const wobble = options.wobble ?? 0.06;
  const freq = options.wobbleFreq ?? 1.6;

  const a = options.from.clone().normalize();
  const b = options.to.clone().normalize();
  const axis = new THREE.Vector3().crossVectors(a, b).normalize();
  const angle = a.angleTo(b);

  const samples: SurfaceSample[] = [];
  for (let i = 0; i < count; i++) {
    let t = i / (count - 1);
    // Ease in and out so the middle of the stroke is fast and the ends linger.
    if (options.uneven) t = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
    const dir = a.clone().applyAxisAngle(axis, angle * t);
    const tangent = new THREE.Vector3().crossVectors(axis, dir).normalize();
    dir.applyAxisAngle(tangent, Math.sin(t * freq * Math.PI * 2) * wobble);
    const position = dir.clone().multiplyScalar(radius);
    samples.push({
      position,
      normal: dir.clone(),
      local: position.clone(),
      localNormal: dir.clone(),
    });
  }
  return samples;
}

/**
 * A looping playhead: rises 0 → 1 over `draw` seconds, holds, then snaps back.
 * Every demo that replays a stroke shares this so the GIFs cut cleanly.
 */
export class Replayer {
  time = 0;
  constructor(
    private draw: number,
    private hold: number,
    private onReset: () => void,
  ) {}

  /** Returns the current progress, 0..1. */
  advance(dt: number): number {
    this.time += dt;
    if (this.time > this.draw + this.hold) {
      this.time = 0;
      this.onReset();
    }
    return Math.min(this.time / this.draw, 1);
  }

  restart(): void {
    this.time = 0;
    this.onReset();
  }
}

// ---------- control panel ----------

export interface SliderOptions {
  label: string;
  value: number;
  min: number;
  max: number;
  step?: number;
  format?: (v: number) => string;
  onChange: (v: number) => void;
}

export interface CheckOptions {
  label: string;
  value?: boolean;
  onChange: (v: boolean) => void;
}

export class Panel {
  readonly el = document.createElement('div');

  constructor(title = 'Controls') {
    this.el.className = 'panel';
    const h = document.createElement('h2');
    h.textContent = title;
    this.el.appendChild(h);
    document.body.appendChild(this.el);
  }

  private row(): HTMLDivElement {
    const row = document.createElement('div');
    row.className = 'row';
    this.el.appendChild(row);
    return row;
  }

  slider(options: SliderOptions): (v: number) => void {
    const row = this.row();
    const format = options.format ?? ((v: number) => v.toFixed(2));
    const label = document.createElement('label');
    const name = document.createElement('span');
    name.textContent = options.label;
    const val = document.createElement('span');
    val.className = 'val';
    val.textContent = format(options.value);
    label.append(name, val);

    const input = document.createElement('input');
    input.type = 'range';
    input.min = String(options.min);
    input.max = String(options.max);
    input.step = String(options.step ?? (options.max - options.min) / 200);
    input.value = String(options.value);
    input.addEventListener('input', () => {
      const v = Number(input.value);
      val.textContent = format(v);
      options.onChange(v);
    });

    row.append(label, input);
    return (v: number) => {
      input.value = String(v);
      val.textContent = format(v);
      options.onChange(v);
    };
  }

  check(options: CheckOptions): (v: boolean) => void {
    const row = this.row();
    const label = document.createElement('label');
    label.className = 'check';
    const input = document.createElement('input');
    input.type = 'checkbox';
    input.checked = options.value ?? false;
    const box = document.createElement('span');
    box.className = 'box';
    const text = document.createElement('span');
    text.textContent = options.label;
    label.append(input, box, text);
    input.addEventListener('change', () => options.onChange(input.checked));
    row.append(label);
    return (v: boolean) => {
      input.checked = v;
      options.onChange(v);
    };
  }

  button(label: string, onClick: () => void, ghost = false): void {
    const row = this.row();
    const btn = document.createElement('button');
    if (ghost) btn.className = 'ghost';
    btn.textContent = label;
    btn.addEventListener('click', onClick);
    row.append(btn);
  }

  note(html: string): void {
    const p = document.createElement('p');
    p.className = 'note';
    p.innerHTML = html;
    this.el.appendChild(p);
  }
}

// ---------- readouts ----------

export type Tone = 'plain' | 'hi' | 'good' | 'bad';

export class Readouts {
  private el = document.createElement('div');

  constructor() {
    this.el.className = 'readouts';
    document.body.appendChild(this.el);
  }

  add(label: string, initial = '—', tone: Tone = 'plain'): (v: string, tone?: Tone) => void {
    const row = document.createElement('div');
    row.className = tone === 'plain' ? 'r' : `r ${tone}`;
    const name = document.createElement('span');
    name.textContent = label;
    const value = document.createElement('span');
    value.textContent = initial;
    row.append(name, value);
    this.el.appendChild(row);
    return (v: string, newTone?: Tone) => {
      value.textContent = v;
      if (newTone) row.className = newTone === 'plain' ? 'r' : `r ${newTone}`;
    };
  }
}

// ---------- small helpers ----------

/** Unlit, always-visible line — for normals, tangents, frames and other debug overlays. */
export function debugLine(color: number, points: THREE.Vector3[]): THREE.Line {
  const geo = new THREE.BufferGeometry().setFromPoints(points);
  const mat = new THREE.LineBasicMaterial({ color, depthTest: false, toneMapped: false, transparent: true });
  const line = new THREE.Line(geo, mat);
  line.renderOrder = 20;
  line.frustumCulled = false;
  return line;
}

/** A cone tip so a debug line reads as an arrow at GIF resolution. */
export function debugArrow(color: number, length: number): THREE.Group {
  const group = new THREE.Group();
  const mat = new THREE.MeshBasicMaterial({ color, depthTest: false, toneMapped: false });
  const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, length, 8), mat);
  shaft.position.y = length / 2;
  const head = new THREE.Mesh(new THREE.ConeGeometry(0.03, 0.09, 12), mat);
  head.position.y = length;
  group.add(shaft, head);
  group.renderOrder = 20;
  group.traverse((o) => { o.renderOrder = 20; });
  return group;
}

/** Points the +Y of an object along `dir`. */
export function orientY(object: THREE.Object3D, dir: THREE.Vector3): void {
  object.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize());
}
demos/src/picking.ts
파일 저장

import * as THREE from 'three/webgpu';
import { BVHHelper } from 'three-mesh-bvh';
import { firstHitOnly, indexForRaycasts } from '../../src/bvh';
import { Panel, Readouts, canvasSphere, createStage, debugArrow, fatal, orientY, studioLights } from './kit';

/**
 * Demo — "one pointer event becomes a surface sample".
 *
 * Everything the painter hands to a mode comes out of this single raycast: the hit point,
 * the interpolated face normal, and the tangent frame we build from it. The demo draws that
 * frame live, and lets you switch the BVH off to watch the pick cost jump.
 */

const stage = await createStage({
  cameraPos: [0.2, 0.9, 3.6],
  environment: true,
  orbit: true,
}).catch((err) => {
  fatal(err);
  return null;
});

if (stage) {
  const { scene, camera, renderer } = stage;
  studioLights(scene);

  // Deliberately dense: ~34k triangles, so "walk the BVH" vs "test every triangle" is a
  // difference you can read off the panel instead of taking on faith.
  const sphere = canvasSphere(1, 160);
  scene.add(sphere);
  indexForRaycasts(sphere);
  const boundsTree = (sphere.geometry as unknown as { boundsTree: unknown }).boundsTree;
  const triangles = (sphere.geometry.getIndex()?.count ?? sphere.geometry.getAttribute('position').count) / 3;

  const bvhHelper = new BVHHelper(sphere, 10);
  bvhHelper.visible = false;
  scene.add(bvhHelper);

  // ---------- the frame gizmo ----------

  const gizmo = new THREE.Group();
  gizmo.visible = false;
  scene.add(gizmo);

  const normalArrow = debugArrow(0xc9a4ff, 0.55); // n  — the surface normal
  const t1Arrow = debugArrow(0x5ad6ff, 0.4);      // t1 — first tangent
  const t2Arrow = debugArrow(0xffc46a, 0.4);      // t2 — n × t1
  const ring = new THREE.Mesh(
    new THREE.RingGeometry(0.16, 0.185, 48),
    new THREE.MeshBasicMaterial({ color: 0xc9a4ff, side: THREE.DoubleSide, depthTest: false, toneMapped: false }),
  );
  ring.renderOrder = 20;
  const plane = new THREE.Mesh(
    new THREE.CircleGeometry(0.17, 48),
    new THREE.MeshBasicMaterial({
      color: 0x8a5cff, side: THREE.DoubleSide, transparent: true, opacity: 0.18,
      depthTest: false, toneMapped: false,
    }),
  );
  plane.renderOrder = 19;
  gizmo.add(normalArrow, t1Arrow, t2Arrow, ring, plane);

  // ---------- picking ----------

  const raycaster = firstHitOnly(new THREE.Raycaster());
  const pointer = new THREE.Vector2();
  const t1 = new THREE.Vector3();
  const t2 = new THREE.Vector3();

  let autoTour = true;
  let useBvh = true;
  let costEma = 0;

  renderer.domElement.addEventListener('pointermove', (e) => {
    const rect = renderer.domElement.getBoundingClientRect();
    pointer.set(
      ((e.clientX - rect.left) / rect.width) * 2 - 1,
      -((e.clientY - rect.top) / rect.height) * 2 + 1,
    );
    if (autoTour) setAuto(false);
  });

  function pick(): THREE.Intersection | null {
    raycaster.setFromCamera(pointer, camera);
    // One raycast is ~microseconds; average ten so the readout doesn't flicker.
    const t0 = performance.now();
    let hits: THREE.Intersection[] = [];
    for (let i = 0; i < 10; i++) hits = raycaster.intersectObject(sphere, false);
    costEma = costEma * 0.9 + ((performance.now() - t0) / 10) * 0.1;
    return hits.find((h) => h.face) ?? null;
  }

  // ---------- panel ----------

  const ui = new Panel('Picking');
  const setAuto = ui.check({
    label: 'Auto tour',
    value: true,
    onChange: (v) => { autoTour = v; },
  });
  ui.check({
    label: 'Use the BVH',
    value: true,
    onChange: (v) => {
      useBvh = v;
      // three-mesh-bvh's patched raycast falls back to the stock one when no tree is present.
      (sphere.geometry as unknown as { boundsTree: unknown }).boundsTree = v ? boundsTree : null;
      if (!v) bvhHelper.visible = false;
      readBvh(v ? 'walking the tree' : 'brute force', v ? 'good' : 'bad');
    },
  });
  ui.check({
    label: 'Show BVH boxes',
    onChange: (v) => { bvhHelper.visible = v && useBvh; },
  });
  ui.slider({
    label: 'BVH depth',
    value: 10,
    min: 1,
    max: 20,
    step: 1,
    format: (v) => String(v),
    onChange: (v) => {
      bvhHelper.depth = v;
      bvhHelper.update();
    },
  });
  ui.note(
    'The frame is <b>n</b> (violet), <b>t1</b> (blue) and <b>t2</b> = n × t1 (amber). ' +
    'Every mode plants its geometry in that frame.',
  );

  const out = new Readouts();
  const readTris = out.add('Sphere triangles', triangles.toLocaleString());
  const readBvh = out.add('Raycast strategy', 'walking the tree', 'good');
  const readCost = out.add('Cost per pick', '—', 'hi');
  const readHit = out.add('Hit point', '—');
  const readNormal = out.add('Normal', '—');
  readTris(triangles.toLocaleString());

  // ---------- frame ----------

  let frame = 0;
  stage.onFrame((_dt, time) => {
    if (autoTour) {
      // Keep the synthetic pointer inside the sphere's silhouette whatever the aspect is.
      const dist = camera.position.length();
      const maxY = (1 / dist) / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * 0.62;
      pointer.set(
        (maxY / camera.aspect) * Math.sin(time * 0.63),
        maxY * Math.sin(time * 0.41 + 1.1),
      );
    }

    const hit = pick();
    if (!hit?.face) {
      gizmo.visible = false;
      return;
    }
    gizmo.visible = true;

    const n = hit.face.normal.clone().transformDirection(sphere.matrixWorld);
    // The same two lines every mode runs: pick any axis that isn't parallel to n, then
    // cross twice to get an orthonormal basis lying in the tangent plane.
    t1.set(1, 0, 0);
    if (Math.abs(n.x) > 0.9) t1.set(0, 1, 0);
    t1.cross(n).normalize();
    t2.crossVectors(n, t1);

    gizmo.position.copy(hit.point);
    orientY(normalArrow, n);
    orientY(t1Arrow, t1);
    orientY(t2Arrow, t2);
    ring.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), n);
    plane.quaternion.copy(ring.quaternion);
    ring.position.copy(n).multiplyScalar(0.002);
    plane.position.copy(ring.position);

    if (++frame % 3 === 0) {
      const v = (a: THREE.Vector3): string => `${a.x.toFixed(2)}, ${a.y.toFixed(2)}, ${a.z.toFixed(2)}`;
      readCost(`${(costEma * 1000).toFixed(1)} µs`);
      readHit(v(hit.point));
      readNormal(v(n));
    }
  });
}
demos/src/resample.ts
파일 저장

import * as THREE from 'three/webgpu';
import { Panel, Readouts, arcSamples, canvasSphere, createStage, fatal, studioLights } from './kit';
import type { SurfaceSample } from '../../src/modes/mode';

/**
 * Demo — "pointer events are not a path".
 *
 * White dots are the raw samples the browser gave us: they bunch up where the hand slowed
 * down and stretch out where it swept. Violet dots are what the modes actually consume —
 * the same stroke resampled at a fixed step, each one carrying a tangent frame.
 */

const RADIUS = 1;

interface PathPoint {
  pos: THREE.Vector3;
  normal: THREE.Vector3;
  side: THREE.Vector3;
  dist: number;
}

/** The resampler every ribbon-based mode runs before it builds anything. */
function buildPath(samples: SurfaceSample[], step: number): PathPoint[] {
  const pts: PathPoint[] = [];
  let travelled = 0;
  let next = 0;
  const tangent = new THREE.Vector3();
  for (let i = 0; i < samples.length; i++) {
    if (i > 0) travelled += samples[i].local.distanceTo(samples[i - 1].local);
    if (travelled < next && i !== samples.length - 1) continue;
    next = travelled + step;
    const a = samples[Math.max(i - 1, 0)];
    const b = samples[Math.min(i + 1, samples.length - 1)];
    tangent.subVectors(b.local, a.local);
    if (tangent.lengthSq() < 1e-8) tangent.set(1, 0, 0);
    tangent.normalize();
    const normal = samples[i].localNormal.clone().normalize();
    const side = new THREE.Vector3().crossVectors(tangent, normal).normalize();
    pts.push({ pos: samples[i].local.clone(), normal, side, dist: travelled });
  }
  return pts;
}

const stage = await createStage({
  cameraPos: [0, 0.15, 3.2],
  fov: 40,
  environment: true,
  orbit: true,
}).catch((err) => {
  fatal(err);
  return null;
});

if (stage) {
  const { scene } = stage;
  studioLights(scene);
  scene.add(canvasSphere(RADIUS, 96));

  // `uneven` bunches samples at the ends and stretches them through the middle — what a
  // real hand does, and what makes raw pointer data useless for spacing anything.
  const samples = arcSamples({
    radius: RADIUS,
    from: new THREE.Vector3(-0.85, -0.35, 0.85),
    to: new THREE.Vector3(0.9, 0.55, 0.75),
    count: 46,
    wobble: 0.1,
    wobbleFreq: 1.8,
    uneven: true,
  });

  // ---------- raw samples ----------

  // The two sets are offset to opposite sides of the stroke. Drawn on top of each other you
  // just get one violet smear and the whole point of the demo is lost.
  const LANE = 0.075;

  /** The across direction at a sample, so each lane can be pushed off the path. */
  function sideAt(i: number): THREE.Vector3 {
    const a = samples[Math.max(i - 1, 0)].local;
    const b = samples[Math.min(i + 1, samples.length - 1)].local;
    const tangent = new THREE.Vector3().subVectors(b, a);
    if (tangent.lengthSq() < 1e-8) tangent.set(1, 0, 0);
    return tangent.normalize().cross(samples[i].localNormal).normalize();
  }

  const rawMat = new THREE.MeshBasicMaterial({ color: 0xf2f4fa, toneMapped: false });
  const rawDots = new THREE.InstancedMesh(new THREE.SphereGeometry(0.017, 10, 8), rawMat, samples.length);
  rawDots.frustumCulled = false;
  {
    const m = new THREE.Matrix4();
    const q = new THREE.Quaternion();
    const s = new THREE.Vector3(1, 1, 1);
    const p = new THREE.Vector3();
    for (let i = 0; i < samples.length; i++) {
      p.copy(samples[i].local).multiplyScalar(1.014).addScaledVector(sideAt(i), -LANE);
      rawDots.setMatrixAt(i, m.compose(p, q, s));
    }
    rawDots.instanceMatrix.needsUpdate = true;
  }
  scene.add(rawDots);

  // ---------- resampled path ----------

  const MAX_POINTS = 400;
  const pathMat = new THREE.MeshBasicMaterial({ color: 0xc9a4ff, toneMapped: false });
  const pathDots = new THREE.InstancedMesh(new THREE.SphereGeometry(0.021, 12, 8), pathMat, MAX_POINTS);
  pathDots.frustumCulled = false;
  pathDots.count = 0;
  scene.add(pathDots);

  const frameGeo = new THREE.BufferGeometry();
  frameGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(MAX_POINTS * 6), 3));
  const frames = new THREE.LineSegments(
    frameGeo,
    new THREE.LineBasicMaterial({ color: 0x5ad6ff, toneMapped: false, transparent: true, opacity: 0.9 }),
  );
  frames.frustumCulled = false;
  scene.add(frames);

  let step = 0.045;
  let showFrames = true;

  const out = new Readouts();
  const readRaw = out.add('Raw samples', String(samples.length));
  const readRawGap = out.add('Raw spacing', '—', 'bad');
  const readPath = out.add('Resampled points', '—', 'hi');
  const readPathGap = out.add('Resampled spacing', '—', 'good');

  function rebuild(): void {
    const path = buildPath(samples, step);
    const n = Math.min(path.length, MAX_POINTS);

    const m = new THREE.Matrix4();
    const q = new THREE.Quaternion();
    const s = new THREE.Vector3(1, 1, 1);
    const p = new THREE.Vector3();
    for (let i = 0; i < n; i++) {
      p.copy(path[i].pos).multiplyScalar(1.014).addScaledVector(path[i].side, LANE);
      pathDots.setMatrixAt(i, m.compose(p, q, s));
    }
    pathDots.count = n;
    pathDots.instanceMatrix.needsUpdate = true;

    // One tick per point, laid along `side` — the direction ribbons expand into.
    const attr = frameGeo.getAttribute('position') as THREE.BufferAttribute;
    const arr = attr.array as Float32Array;
    arr.fill(0);
    for (let i = 0; i < n; i++) {
      const c = path[i].pos.clone().multiplyScalar(1.014).addScaledVector(path[i].side, LANE);
      const a = c.clone().addScaledVector(path[i].side, -0.055);
      const b = c.clone().addScaledVector(path[i].side, 0.055);
      arr.set([a.x, a.y, a.z, b.x, b.y, b.z], i * 6);
    }
    attr.needsUpdate = true;
    frameGeo.setDrawRange(0, showFrames ? n * 2 : 0);

    readPath(String(n));
    readPathGap(`${step.toFixed(3)} (fixed)`);
  }

  {
    let min = Infinity;
    let max = 0;
    for (let i = 1; i < samples.length; i++) {
      const d = samples[i].local.distanceTo(samples[i - 1].local);
      min = Math.min(min, d);
      max = Math.max(max, d);
    }
    readRaw(String(samples.length));
    readRawGap(`${min.toFixed(3)} → ${max.toFixed(3)}`);
  }

  const ui = new Panel('Resampling');
  ui.slider({
    label: 'Step (world units)',
    value: step,
    min: 0.015,
    max: 0.14,
    step: 0.005,
    format: (v) => v.toFixed(3),
    onChange: (v) => { step = v; rebuild(); },
  });
  ui.check({
    label: 'Raw samples (white)',
    value: true,
    onChange: (v) => { rawDots.visible = v; },
  });
  ui.check({
    label: 'Resampled path (violet)',
    value: true,
    onChange: (v) => { pathDots.visible = v; },
  });
  ui.check({
    label: 'Show tangent frames',
    value: true,
    onChange: (v) => { showFrames = v; rebuild(); },
  });
  ui.note(
    'Crystals scatter one cluster per <b>0.0625</b> units, fissures step the crack every ' +
    '<b>0.025</b>. Neither can be spaced off raw events.',
  );

  rebuild();
}
demos/src/ribbon.ts
파일 저장

import * as THREE from 'three/webgpu';
import { defaultFissureSettings, fissureMode, type FissureSettings } from '../../src/modes/fissures';
import { Panel, Readouts, arcSamples, canvasSphere, createStage, fatal, studioLights } from './kit';

/**
 * Demo — "the ribbon has no width".
 *
 * This is the real fissure mode. Its crack is one indexed strip whose vertices all sit on
 * the centerline: every vertex has a twin at exactly the same position, and the only thing
 * separating them is `aSide × uWidth × 0.5 × aAcross` in the vertex stage.
 *
 * Turn on "Source vertices" and drag the width slider — the dots never move.
 */

const RADIUS = 1;

const stage = await createStage({
  cameraPos: [0.1, 0.5, 2.9],
  fov: 42,
  environment: true,
  bloom: { strength: 0.5, threshold: 0.7 },
  orbit: true,
}).catch((err) => {
  fatal(err);
  return null;
});

if (stage) {
  const { scene } = stage;
  studioLights(scene);
  scene.add(canvasSphere(RADIUS, 96));

  const samples = arcSamples({
    radius: RADIUS,
    from: new THREE.Vector3(-0.8, -0.35, 0.85),
    to: new THREE.Vector3(0.85, 0.5, 0.75),
    count: 70,
    wobble: 0.08,
  });

  const settings: FissureSettings = { ...defaultFissureSettings, emberRate: 14 };
  const stroke = fissureMode.createStroke(samples, 0xf1552e, settings);
  stroke.finishGrowth();
  scene.add(stroke.group);

  // Pull the pieces of the stroke apart so the demo can show them one at a time.
  const ribbonMeshes: THREE.Mesh[] = [];
  const extras: THREE.Object3D[] = [];
  stroke.group.traverse((o) => {
    const mesh = o as THREE.Mesh;
    if (!mesh.isMesh) return;
    if (mesh.geometry.getAttribute('aAcross')) ribbonMeshes.push(mesh);
    else extras.push(mesh);
  });
  const [underMesh, coreMesh] = ribbonMeshes; // added in that order by the mode
  const ribbonGeo = coreMesh.geometry;

  // ---------- the source vertices ----------

  // Each path point emits two vertices at the SAME position (aAcross = -1 and +1), so
  // plotting every second one gives exactly the centerline the mode walked.
  const posAttr = ribbonGeo.getAttribute('position') as THREE.BufferAttribute;
  const pairCount = Math.floor(posAttr.count / 2);
  const dots = new THREE.InstancedMesh(
    new THREE.SphereGeometry(0.007, 8, 6),
    new THREE.MeshBasicMaterial({ color: 0xffe8b0, toneMapped: false, depthTest: false }),
    pairCount,
  );
  dots.frustumCulled = false;
  dots.renderOrder = 20;
  dots.visible = false;
  {
    const m = new THREE.Matrix4();
    const q = new THREE.Quaternion();
    const s = new THREE.Vector3(1, 1, 1);
    const p = new THREE.Vector3();
    for (let i = 0; i < pairCount; i++) {
      p.fromBufferAttribute(posAttr, i * 2);
      // Nudge along the surface normal so the dots read on top of the glow.
      dots.setMatrixAt(i, m.compose(p.multiplyScalar(1.004), q, s));
    }
    dots.instanceMatrix.needsUpdate = true;
  }
  stroke.group.add(dots);

  // ---------- panel ----------

  let applyCalls = 0;
  const out = new Readouts();
  out.add('Ribbon vertices', posAttr.count.toLocaleString());
  out.add('Centerline points', pairCount.toLocaleString(), 'hi');
  out.add('Triangles', ((ribbonGeo.getIndex()?.count ?? 0) / 3).toLocaleString());
  const readRebuild = out.add('Geometry rebuilds', '0', 'good');
  const readApply = out.add('Uniform writes', '0', 'hi');

  function apply(): void {
    applyCalls++;
    stroke.applySettings?.(settings);
    readApply(String(applyCalls));
    readRebuild('0');
  }

  const ui = new Panel('Fissure ribbon');
  ui.slider({
    label: 'Crack width',
    value: settings.width,
    min: 0.02,
    max: 0.16,
    format: (v) => v.toFixed(3),
    onChange: (v) => { settings.width = v; apply(); },
  });
  ui.slider({
    label: 'Branches / unit',
    value: settings.branchDensity,
    min: 0,
    max: 8,
    step: 0.1,
    format: (v) => v.toFixed(1),
    onChange: (v) => { settings.branchDensity = v; apply(); },
  });
  ui.slider({
    label: 'Branch length',
    value: settings.branchLength,
    min: 0.05,
    max: 0.6,
    format: (v) => v.toFixed(2),
    onChange: (v) => { settings.branchLength = v; apply(); },
  });
  ui.slider({
    label: 'Heat',
    value: settings.heat,
    min: 0.2,
    max: 3,
    onChange: (v) => { settings.heat = v; apply(); },
  });

  let wireframe = false;
  let shaded = true;
  const sync = (): void => {
    coreMesh.visible = shaded;
    underMesh.visible = shaded && !wireframe;
    (coreMesh.material as THREE.Material & { wireframe: boolean }).wireframe = wireframe;
  };
  ui.check({
    label: 'Source vertices',
    onChange: (v) => { dots.visible = v; },
  });
  ui.check({
    label: 'Wireframe',
    onChange: (v) => { wireframe = v; sync(); },
  });
  ui.check({
    label: 'Shaded crack',
    value: true,
    onChange: (v) => { shaded = v; sync(); },
  });
  ui.check({
    label: 'Rock lips + embers',
    value: true,
    onChange: (v) => { for (const e of extras) e.visible = v; },
  });
  ui.note(
    'Branch culling lives in the shader too: <b>step(aRank, uBranchFrac)</b> collapses a ' +
    'whole branch to zero width without touching a buffer.',
  );

  stage.onFrame((dt, t) => stroke.update(dt, t));
}
demos/src/studio.ts
파일 저장

import * as THREE from 'three/webgpu';
import { crystalMode, defaultCrystalSettings } from '../../src/modes/crystals';
import { ENV_PANELS, Panel, Readouts, arcSamples, buildEnvironmentScene, canvasSphere, createStage, fatal, studioLights, type EnvPanel } from './kit';

/**
 * Demo — "the environment is the lighting".
 *
 * There are no lights in this scene by default. Everything you can see on the crystals is a
 * reflection of six emissive quads floating around the subject, prefiltered into an
 * environment map. Switch a quad off and its highlight goes with it.
 *
 * Turn on "Show the panels" to see the room the reflections are coming from.
 */

const RADIUS = 1;

const stage = await createStage({
  cameraPos: [0.4, 0.9, 3.2],
  fov: 42,
  environment: true,
  bloom: { strength: 0.35, threshold: 0.85 },
  orbit: true,
}).catch((err) => {
  fatal(err);
  return null;
});

if (stage) {
  const { scene } = stage;
  scene.add(canvasSphere(RADIUS, 96));

  const samples = arcSamples({
    radius: RADIUS,
    from: new THREE.Vector3(-0.55, -0.1, 0.95),
    to: new THREE.Vector3(0.75, 0.65, 0.7),
    count: 60,
    wobble: 0.07,
  });
  const stroke = crystalMode.createStroke(
    samples,
    0xa11ce,
    { ...defaultCrystalSettings, crystalSize: 0.2, clusterDensity: 8, shards: 8 },
  );
  stroke.finishGrowth();
  scene.add(stroke.group);

  // The same quads that get prefiltered, added to the real scene at their real positions.
  const panelRoom = buildEnvironmentScene(ENV_PANELS);
  const panelMeshes = new Map<string, THREE.Object3D>();
  for (const child of [...panelRoom.children]) {
    scene.add(child);
    panelMeshes.set(child.userData.panelId as string, child);
    child.visible = false;
  }

  // An optional three-point rig, off by default — the point is what the env map alone does.
  const rig = new THREE.Group();
  studioLights(rig);
  rig.visible = false;
  scene.add(rig);

  // ---------- state ----------

  const enabled = new Set(ENV_PANELS.map((p) => p.id));
  let showPanels = false;
  let rebuilds = 0;

  const out = new Readouts();
  const readPanels = out.add('Panels lit', `${enabled.size} / ${ENV_PANELS.length}`, 'hi');
  const readLights = out.add('Actual lights', '0', 'good');
  const readRebuild = out.add('PMREM bakes', '1');

  function refresh(): void {
    const active: EnvPanel[] = ENV_PANELS.filter((p) => enabled.has(p.id));
    stage!.setEnvironment(active);
    rebuilds++;
    readPanels(`${enabled.size} / ${ENV_PANELS.length}`);
    readRebuild(String(rebuilds));
    for (const p of ENV_PANELS) {
      const mesh = panelMeshes.get(p.id);
      if (mesh) mesh.visible = showPanels && enabled.has(p.id);
    }
  }

  const ui = new Panel('Light panels');
  for (const p of ENV_PANELS) {
    ui.check({
      label: p.label,
      value: true,
      onChange: (v) => {
        if (v) enabled.add(p.id);
        else enabled.delete(p.id);
        refresh();
      },
    });
  }
  ui.check({
    label: 'Show the panels',
    onChange: (v) => { showPanels = v; refresh(); },
  });
  ui.check({
    label: 'Add a three-point rig',
    onChange: (v) => {
      rig.visible = v;
      readLights(v ? '4' : '0', v ? 'plain' : 'good');
    },
  });
  ui.note(
    'Every glint on those facets is one of these quads. The hard top-back strip at ' +
    '<b>intensity 22</b> is doing most of the work.',
  );

  refresh();
  rebuilds = 1;
  readRebuild('1');

  stage.onFrame((dt, t) => stroke.update(dt, t));
}
demos/src/vite-env.d.ts
파일 저장

/// <reference types="vite/client" />
demos/studio.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>The environment is the lighting — Geometry Painter demos</title>
  </head>
  <body>
    <div id="stage"></div>
    <div class="caption">
      <a class="home" href="./index.html">← all demos</a>
      <h1>The environment is the lighting</h1>
      <p>Six emissive quads, prefiltered into an environment map, and no lights at all. Switch a quad off and its highlight goes with it.</p>
    </div>
    <script type="module" src="/demos/src/studio.ts"></script>
  </body>
</html>
index.html
파일 저장

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Geometry Painter — three.js WebGPU</title>
    <style>
      html, body { margin: 0; height: 100%; overflow: hidden; background: #0a0b10; font-family: system-ui, sans-serif; }
      #app { position: fixed; inset: 0; }
      /* Custom "brush" cursor while drawing; grab cursor while orbiting. */
      body.draw #app { cursor: crosshair; }
      body.orbit #app { cursor: grab; }
      body.orbit #app:active { cursor: grabbing; }

      #title {
        position: fixed; left: 16px; top: 14px; color: #eef0f6; font-size: 15px; font-weight: 600;
        letter-spacing: .02em; pointer-events: none; opacity: .92; z-index: 3;
      }
      #title span { opacity: .55; font-weight: 400; font-size: 13px; }

      /* Draw-mode border glow: a pulsing violet frame so it's unmistakable you're painting. */
      #drawFrame {
        position: fixed; inset: 0; pointer-events: none; z-index: 2; opacity: 0;
        border: 2px solid rgba(190, 140, 255, .9);
        box-shadow: inset 0 0 60px rgba(150, 90, 240, .30), inset 0 0 12px rgba(190, 140, 255, .45);
        transition: opacity .25s ease;
      }
      body.draw #drawFrame { opacity: 1; animation: framePulse 2.4s ease-in-out infinite; }
      @keyframes framePulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }

      /* Clickable mode pill (bottom-center) — the primary, obvious mode switch. */
      #modeBtn {
        position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); z-index: 4;
        display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none;
        padding: 9px 16px 9px 12px; border-radius: 999px; font-size: 13.5px; font-weight: 600; color: #17081f;
        background: linear-gradient(180deg, #dfc2ff, #b287f0); border: none;
        box-shadow: 0 6px 20px rgba(0,0,0,.35), 0 0 0 4px rgba(190,140,255,.14);
        transition: transform .12s ease, box-shadow .2s ease;
      }
      #modeBtn:hover { transform: translateX(-50%) translateY(-1px); }
      #modeBtn:active { transform: translateX(-50%) translateY(1px); }
      #modeBtn .dot { width: 9px; height: 9px; border-radius: 50%; background: #2a1240; box-shadow: 0 0 8px #2a1240; }
      #modeBtn .key {
        margin-left: 2px; padding: 2px 7px; border-radius: 6px; font-size: 11px; font-weight: 700;
        background: rgba(23,8,31,.22); color: #2a1240;
      }
      /* Orbit mode: pill goes calm/blue so the current state reads at a glance. */
      body.orbit #modeBtn {
        color: #dbe7f5; background: rgba(24,28,40,.85); box-shadow: 0 6px 20px rgba(0,0,0,.4), 0 0 0 1px rgba(130,150,200,.25);
      }
      body.orbit #modeBtn .dot { background: #6ea8ff; box-shadow: 0 0 8px #6ea8ff; }
      body.orbit #modeBtn .key { background: rgba(255,255,255,.1); color: #dbe7f5; }

      #hud {
        position: fixed; left: 16px; bottom: 16px; color: #cfd4e0; font-size: 13px; line-height: 1.5;
        background: rgba(10, 12, 20, .55); padding: 10px 14px; border-radius: 10px; pointer-events: none;
        backdrop-filter: blur(6px); max-width: 340px; z-index: 3;
      }
      #hud b { color: #cfa4ff; }
      #hud .sub { opacity: .5; margin-top: 6px; font-size: 12px; }

      /* Transient toast, e.g. "crystals seeded 💎". */
      #toast {
        position: fixed; left: 50%; top: 64px; transform: translateX(-50%) translateY(-8px); z-index: 5;
        padding: 8px 16px; border-radius: 999px; font-size: 13px; font-weight: 600; color: #efe2ff;
        background: rgba(38, 18, 58, .9); box-shadow: 0 4px 16px rgba(0,0,0,.4);
        opacity: 0; pointer-events: none; transition: opacity .3s ease, transform .3s ease;
      }
      #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }

      .fatal {
        position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); color: #ff8a80;
        background: rgba(20, 8, 8, .85); padding: 16px 22px; border-radius: 10px; font-size: 14px; max-width: 480px; z-index: 6;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>
    <div id="drawFrame"></div>
    <div id="title">💎 Geometry Painter <span>three.js WebGPU</span></div>
    <button id="modeBtn"><span class="dot"></span><span class="label">Paint mode</span><span class="key">D</span></button>
    <div id="hud"></div>
    <div id="toast"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
src/app.ts
파일 저장

import * as THREE from 'three/webgpu';
import { float, pass, screenUV, smoothstep, vec2 } from 'three/tsl';
import { bloom } from 'three/addons/tsl/display/BloomNode.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { indexForRaycasts } from './bvh';
import { SurfacePainter } from './surfacePainter';
import type { PaintMode, StrokeInstance, SurfaceSample } from './modes/mode';
import {
  crystalMode,
  defaultCrystalSettings,
  setCrystalGlow,
  type CrystalSettings,
} from './modes/crystals';
import { defaultFissureSettings, fissureMode, type FissureSettings } from './modes/fissures';
import { auroraMode, defaultAuroraSettings, type AuroraSettings } from './modes/aurora';
import { defaultReefSettings, reefMode, type ReefSettings } from './modes/reef';
import { buildGui } from './ui';

export type ModeName = 'Crystals' | 'Molten fissures' | 'Aurora silk' | 'Bioluminescent reef';

const GROUND_Y = -1.55; // the floor the sphere floats above

interface Stroke {
  samples: SurfaceSample[];
  index: number;    // stable per-stroke id; combined with the global seed to vary each stroke
  mode: ModeName;   // which painting mode authored it (strokes rebuild through their own mode)
}

/** Everything the GUI edits. Mode-specific settings live in their own sub-objects. */
export interface AppSettings {
  mode: ModeName;
  drawMode: boolean;
  seed: number;
  exposure: number;
  envIntensity: number;
  backlight: number; // scales the kickers that stream light through the crystals
  bloomStrength: number;
  bloomThreshold: number;
}

export class App {
  readonly settings: AppSettings = {
    mode: 'Crystals',
    drawMode: true,
    seed: 1,
    exposure: 1.1,
    envIntensity: 0.9,
    backlight: 1,
    bloomStrength: 0.4,
    bloomThreshold: 0.75,
  };

  readonly crystal: CrystalSettings = { ...defaultCrystalSettings };
  readonly fissure: FissureSettings = { ...defaultFissureSettings };
  readonly aurora: AuroraSettings = { ...defaultAuroraSettings };
  readonly reef: ReefSettings = { ...defaultReefSettings };

  /** Registry of painting modes — new modes plug in here. */
  private modes: Record<ModeName, PaintMode<unknown>> = {
    'Crystals': crystalMode as PaintMode<unknown>,
    'Molten fissures': fissureMode as PaintMode<unknown>,
    'Aurora silk': auroraMode as PaintMode<unknown>,
    'Bioluminescent reef': reefMode as PaintMode<unknown>,
  };

  /** Snapshot of the settings object a given mode consumes. */
  private settingsFor(mode: ModeName): unknown {
    switch (mode) {
      case 'Crystals': return { ...this.crystal };
      case 'Molten fissures': return { ...this.fissure };
      case 'Aurora silk': return { ...this.aurora };
      case 'Bioluminescent reef': return { ...this.reef };
    }
  }

  private renderer!: THREE.WebGPURenderer;
  private post!: THREE.PostProcessing;
  private bloomNode!: ReturnType<typeof bloom>;
  private scene = new THREE.Scene();
  private camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100);
  private controls!: OrbitControls;
  private painter!: SurfacePainter;

  /** The floating canvas: sphere + everything painted on it bob and turn together. */
  private floatRoot = new THREE.Group();
  private sphere!: THREE.Mesh;
  private paintRoot = new THREE.Group(); // strokes parent here (child of floatRoot)

  private strokes: Stroke[] = [];
  private live: StrokeInstance[] = [];
  private strokeCounter = 0;

  private dust!: THREE.Points;
  private dustVel: number[] = [];
  /** The backlight/kicker pair, scaled together by the Backlight slider. */
  private backLights: { light: THREE.DirectionalLight; base: number }[] = [];

  private hud = document.getElementById('hud')!;
  private lastTime = 0;
  private hovering = false;
  private toastTimer = 0;
  private regrowPending: { mode: 'instant' | 'animate' } | null = null;
  private lastRegrowAt = 0;
  private regrowCost = 0;

  constructor(private container: HTMLElement) {}

  async start(): Promise<void> {
    const renderer = new THREE.WebGPURenderer({ antialias: true });
    await renderer.init();
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = this.settings.exposure;
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    this.container.appendChild(renderer.domElement);
    this.renderer = renderer;

    this.scene.background = new THREE.Color(0x0a0b10);
    this.scene.fog = new THREE.Fog(0x0a0b10, 9, 22);
    this.camera.position.set(2.7, 1.15, 3.3);

    this.controls = new OrbitControls(this.camera, renderer.domElement);
    this.controls.enableDamping = true;
    this.controls.dampingFactor = 0.08;
    this.controls.minDistance = 1.6;
    this.controls.maxDistance = 10;
    this.controls.target.set(0, -0.05, 0);
    // Keep the camera above the horizon so you can't tumble under the floor.
    this.controls.maxPolarAngle = Math.PI / 2 - 0.02;

    this.setupEnvironment();
    this.setupLights();
    this.setupCanvasSphere();
    this.setupDust();
    this.setupPost();

    this.painter = new SurfacePainter(
      renderer.domElement,
      this.camera,
      this.scene,
      () => [this.sphere],
      this.floatRoot,
    );
    this.painter.onStroke = (samples) => this.addStroke(samples);
    this.painter.onActiveChange = (active) => {
      this.controls.enabled = !active;
    };
    this.painter.onHoverChange = (over) => {
      this.hovering = over;
      this.updateHud();
    };

    buildGui(this);
    this.applyModes();

    document.getElementById('modeBtn')!.addEventListener('click', () => this.toggleMode());
    window.addEventListener('keydown', (e) => {
      if (e.repeat || e.target instanceof HTMLInputElement) return;
      if (e.key.toLowerCase() === 'd') this.toggleMode();
    });

    window.addEventListener('resize', this.onResize);
    this.onResize();

    renderer.setAnimationLoop((t) => this.tick(t));
  }

  // ---------- environment: a dark studio captured into a PMREM env map ----------

  /**
   * The "perfect light set" starts here: crystals and the lacquered sphere are mostly
   * REFLECTION, so what matters most is what there is to reflect. We build a black studio
   * with a huge overhead softbox, a cool strip camera-left, a warm strip camera-right and a
   * violet wash behind — classic three-point product lighting — and prefilter it into the
   * environment map. Every glossy highlight in the scene is one of these shapes.
   */
  private setupEnvironment(): void {
    const env = new THREE.Scene();
    const geo = new THREE.PlaneGeometry(1, 1);

    const panel = (
      color: number,
      intensity: number,
      w: number,
      h: number,
      pos: [number, number, number],
    ): void => {
      const mat = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide });
      mat.color.set(color).multiplyScalar(intensity); // HDR: >1 colors become light sources
      const m = new THREE.Mesh(geo, mat);
      m.scale.set(w, h, 1);
      m.position.set(...pos);
      m.lookAt(0, 0, 0);
      env.add(m);
    };

    panel(0xfff6ea, 9, 4.5, 3, [1.5, 8, 2]);     // overhead softbox, biased toward camera
    panel(0xffffff, 22, 0.7, 4.5, [-2.5, 5, -6]); // hard top-back strip — facet glints
    panel(0x9db8ff, 5, 1.2, 7, [-7, 2, -2]);     // cool strip, camera-left
    panel(0xffd9b0, 3.5, 1.6, 5, [6, 1.5, 3]);   // warm strip, camera-right
    panel(0x8a5cff, 4, 6, 3.5, [0, 2.5, -8]);    // violet wash behind the subject
    panel(0x2e3c58, 1.2, 9, 9, [0, -5, 0]);      // dim floor bounce

    const pmrem = new THREE.PMREMGenerator(this.renderer);
    this.scene.environment = pmrem.fromScene(env, 0.04).texture;
    this.scene.environmentIntensity = this.settings.envIntensity;
    pmrem.dispose();
    geo.dispose();
  }

  /**
   * A cinematic three-point rig, tuned like a product macro shot:
   *  - KEY: a focused warm spot from top-front-right with a soft penumbra — a pool of
   *    light on the subject instead of a flat wash over the whole set.
   *  - BACKLIGHT + KICKER: cool violet-blue from behind. These are what make the
   *    transmissive crystals GLOW from within (transmission responds to light arriving
   *    from behind the surface) — the signature of the reference look.
   *  - FILL: a whisper of hemisphere so shadows never crush to pure black.
   */
  private setupLights(): void {
    const hemi = new THREE.HemisphereLight(0x8ea0c8, 0x0c0a14, 0.15);

    const key = new THREE.SpotLight(0xfff2e2, 70, 0, Math.PI / 5, 0.55, 1.8);
    key.position.set(3.4, 5.6, 2.6);
    key.target.position.set(0, 0, 0);
    key.castShadow = true;
    key.shadow.mapSize.set(2048, 2048);
    key.shadow.camera.near = 1;
    key.shadow.camera.far = 20;
    key.shadow.bias = -0.0004;
    key.shadow.normalBias = 0.02;
    key.shadow.radius = 5; // soft penumbra under the floating sphere

    const back = new THREE.DirectionalLight(0xa9b8ff, 2.4);
    back.position.set(-3, 3.2, -4.5);
    const kick = new THREE.DirectionalLight(0xcaa6ff, 1.2);
    kick.position.set(4.5, 1.2, -3);
    this.backLights = [
      { light: back, base: 2.4 },
      { light: kick, base: 1.2 },
    ];

    // Faint violet underglow: lifts the sphere's shadowed underside off the floor,
    // selling the "floating" read.
    const under = new THREE.PointLight(0x6a4bd6, 0.4, 6, 1.6);
    under.position.set(0, GROUND_Y + 0.25, 0);

    // The floor: near-black satin with a soft radial sheen, mostly there to catch the
    // sphere's soft shadow and the crystals' colored bounce.
    const ground = new THREE.Mesh(
      new THREE.CircleGeometry(14, 64),
      new THREE.MeshPhysicalMaterial({
        map: makeFloorTexture(),
        color: 0xffffff,
        roughness: 0.95,
        metalness: 0,
        // The grey wash on a dark floor is SPECULAR (the huge overhead softbox reflected
        // by a rough surface), not albedo — so dim both specular paths hard.
        specularIntensity: 0.15,
        envMapIntensity: 0.15,
      }),
    );
    ground.rotation.x = -Math.PI / 2;
    ground.position.y = GROUND_Y;
    ground.receiveShadow = true;

    // Backdrop: a huge inward-facing sphere with soft violet blooms over near-black,
    // like the defocused studio behind a macro lens. Unlit and unfogged.
    const backdrop = new THREE.Mesh(
      new THREE.SphereGeometry(30, 32, 16),
      new THREE.MeshBasicMaterial({ map: makeBackdropTexture(), side: THREE.BackSide, fog: false }),
    );

    this.scene.add(hemi, key, key.target, back, kick, under, ground, backdrop);
  }

  /** The canvas itself: a satin basalt sphere — a quiet stage that lets the crystals star.
   *  Matte enough that the studio doesn't mirror across it, with just enough clearcoat
   *  for a soft polished-stone sheen at grazing angles. */
  private setupCanvasSphere(): void {
    const mat = new THREE.MeshPhysicalMaterial({
      color: 0x1b1d24,
      metalness: 0.05,
      roughness: 0.52,
      clearcoat: 0.35,
      clearcoatRoughness: 0.3,
      sheen: 0.15,
      sheenColor: new THREE.Color(0x5a6bb0),
      sheenRoughness: 0.7,
      envMapIntensity: 0.55,
    });
    this.sphere = new THREE.Mesh(new THREE.SphereGeometry(1, 96, 64), mat);
    this.sphere.castShadow = true;
    this.sphere.receiveShadow = true;

    this.floatRoot.add(this.sphere, this.paintRoot);
    this.scene.add(this.floatRoot);
    indexForRaycasts(this.floatRoot);
  }

  /** A whisper of drifting dust — depth cue and atmosphere, kept deliberately subtle. */
  private setupDust(): void {
    const N = 320;
    const positions = new Float32Array(N * 3);
    for (let i = 0; i < N; i++) {
      const r = 1.9 + Math.random() * 4.5;
      const a = Math.random() * Math.PI * 2;
      positions[i * 3] = Math.cos(a) * r;
      positions[i * 3 + 1] = GROUND_Y + 0.1 + Math.random() * 4.2;
      positions[i * 3 + 2] = Math.sin(a) * r;
      this.dustVel.push(0.02 + Math.random() * 0.05);
    }
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    this.dust = new THREE.Points(
      geo,
      new THREE.PointsMaterial({
        color: 0x9db4e8,
        size: 0.02,
        transparent: true,
        opacity: 0.45,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
        sizeAttenuation: true,
      }),
    );
    this.dust.frustumCulled = false;
    this.scene.add(this.dust);
  }

  /** Post: MSAA scene pass + bloom + a gentle lens vignette, tone-mapped on output. */
  private setupPost(): void {
    const scenePass = pass(this.scene, this.camera, { samples: 4 });
    const color = scenePass.getTextureNode();
    this.bloomNode = bloom(color, this.settings.bloomStrength, 0.6, this.settings.bloomThreshold);
    // Vignette: full exposure in the middle, ~35% falloff into the corners — pulls the
    // eye to the subject the way a fast lens does.
    const vignette = float(1).sub(smoothstep(0.5, 0.92, screenUV.distance(vec2(0.5, 0.5))).mul(0.35));
    this.post = new THREE.PostProcessing(this.renderer);
    this.post.outputNode = color.add(this.bloomNode).mul(vignette);
  }

  // ---------- strokes ----------

  addStroke(samples: SurfaceSample[]): void {
    const stroke: Stroke = { samples, index: this.strokeCounter++, mode: this.settings.mode };
    this.strokes.push(stroke);
    this.buildStroke(stroke, true);
    const toasts: Record<ModeName, string> = {
      'Crystals': '💎 crystals seeded — watch them grow',
      'Molten fissures': '🔥 fissure torn open — stand back',
      'Aurora silk': '🌌 aurora silk unfurling — look up',
      'Bioluminescent reef': '🪸 reef colony seeded — watch it come alive',
    };
    this.showToast(toasts[stroke.mode]);
  }

  private buildStroke(stroke: Stroke, animate: boolean): void {
    const seed = this.effectiveSeed(stroke.index);
    const instance = this.modes[stroke.mode].createStroke(stroke.samples, seed, this.settingsFor(stroke.mode));
    this.paintRoot.add(instance.group);
    this.live.push(instance);
    if (!animate) instance.finishGrowth();
  }

  private regrow(animate: boolean): void {
    for (const s of this.live) s.dispose();
    this.live = [];
    for (const stroke of this.strokes) this.buildStroke(stroke, animate);
  }

  /**
   * Ask for a rebuild. Requests are coalesced and throttled in the tick (slider drags fire
   * onChange dozens of times a second). 'instant' snaps to fully grown; 'animate' replays
   * the crystal growth.
   */
  scheduleRegrow(mode: 'instant' | 'animate'): void {
    if (this.regrowPending?.mode === 'animate') return; // an animate request always wins
    this.regrowPending = { mode };
  }

  undoLast(): void {
    this.strokes.pop();
    const s = this.live.pop();
    s?.dispose();
  }

  clearAll(): void {
    for (const s of this.live) s.dispose();
    this.live = [];
    this.strokes = [];
    this.regrowPending = null;
  }

  /** Mix the global seed with a stroke's stable id so strokes stay distinct but reseed together. */
  private effectiveSeed(index: number): number {
    return ((this.settings.seed * 2654435761) ^ (index * 40503 + 1)) >>> 0;
  }

  // ---------- live (no-rebuild) setting paths ----------

  /**
   * Push a mode's current settings into its live strokes IN PLACE — matrices, colors and
   * shader uniforms update on the existing objects, nothing is recreated. Falls back to a
   * rebuild only for stroke types that can't re-derive themselves.
   */
  updateModeSettings(mode: ModeName): void {
    let needRebuild = false;
    for (let i = 0; i < this.live.length; i++) {
      if (this.strokes[i].mode !== mode) continue;
      const s = this.live[i];
      if (s.applySettings) s.applySettings(this.settingsFor(mode));
      else needRebuild = true;
    }
    if (needRebuild) this.scheduleRegrow('instant');
  }

  setGlow(v: number): void {
    this.crystal.glow = v;
    setCrystalGlow(v);
  }

  setExposure(v: number): void {
    this.settings.exposure = v;
    this.renderer.toneMappingExposure = v;
  }

  setEnvIntensity(v: number): void {
    this.settings.envIntensity = v;
    this.scene.environmentIntensity = v;
  }

  /** Backlight slider: scales the rear rig — how hard light streams through the crystals. */
  setBacklight(v: number): void {
    this.settings.backlight = v;
    for (const { light, base } of this.backLights) light.intensity = base * v;
  }

  setBloomStrength(v: number): void {
    this.settings.bloomStrength = v;
    this.bloomNode.strength.value = v;
  }

  setBloomThreshold(v: number): void {
    this.settings.bloomThreshold = v;
    this.bloomNode.threshold.value = v;
  }

  // ---------- modes / hud ----------

  toggleMode(): void {
    this.settings.drawMode = !this.settings.drawMode;
    this.applyModes();
  }

  applyModes(): void {
    const draw = this.settings.drawMode;
    this.painter.setEnabled(draw);
    this.controls.enableRotate = !draw;
    document.body.classList.toggle('draw', draw);
    document.body.classList.toggle('orbit', !draw);

    const btn = document.getElementById('modeBtn')!;
    btn.querySelector('.label')!.textContent = draw ? 'Paint mode' : 'Orbit mode';

    if (!draw) this.hovering = false;
    this.updateHud();
  }

  private updateHud(): void {
    const backend = (this.renderer.backend as { isWebGPUBackend?: boolean }).isWebGPUBackend
      ? 'WebGPU'
      : 'WebGL2 (fallback)';
    const nouns: Record<ModeName, string> = {
      'Crystals': 'crystal vein',
      'Molten fissures': 'molten fissure',
      'Aurora silk': 'silk of aurora',
      'Bioluminescent reef': 'reef colony',
    };
    const noun = nouns[this.settings.mode];
    let mode: string;
    if (this.settings.drawMode) {
      mode = this.hovering
        ? `<b>Drag now</b> to paint a ${noun} across the sphere — it grows when you let go.`
        : `Move over the sphere, then <b>drag</b> to paint a ${noun}. Press <b>D</b> to orbit.`;
    } else {
      mode = '<b>Orbit mode</b> — drag to rotate, scroll to zoom, right-drag to pan. ' +
        `Press <b>D</b> to paint.`;
    }
    this.hud.innerHTML = `${mode}<div class="sub">Mode: ${this.settings.mode} · Renderer: ${backend}</div>`;
  }

  private showToast(msg: string): void {
    const el = document.getElementById('toast')!;
    el.textContent = msg;
    el.classList.add('show');
    clearTimeout(this.toastTimer);
    this.toastTimer = window.setTimeout(() => el.classList.remove('show'), 1800);
  }

  // ---------- frame loop ----------

  private onResize = (): void => {
    const w = this.container.clientWidth;
    const h = this.container.clientHeight;
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(w, h);
  };

  private tick(time: number): void {
    const dt = Math.min((time - this.lastTime) / 1000, 0.05);
    this.lastTime = time;
    const tSec = time / 1000;

    if (this.regrowPending) {
      // Adaptive throttle: the heavier the last rebuild, the longer we wait before the
      // next one, so slider drags stay smooth whatever the scene costs.
      const now = performance.now();
      const interval = this.regrowPending.mode === 'animate'
        ? 0
        : THREE.MathUtils.clamp(this.regrowCost * 3, 60, 400);
      if (now - this.lastRegrowAt >= interval) {
        const req = this.regrowPending;
        this.regrowPending = null;
        const t0 = performance.now();
        this.regrow(req.mode === 'animate');
        this.regrowCost = performance.now() - t0;
        this.lastRegrowAt = performance.now();
      }
    }

    // Dust drifts upward and wraps.
    const posAttr = this.dust.geometry.getAttribute('position') as THREE.BufferAttribute;
    const arr = posAttr.array as Float32Array;
    for (let i = 0; i < this.dustVel.length; i++) {
      arr[i * 3 + 1] += this.dustVel[i] * dt;
      if (arr[i * 3 + 1] > GROUND_Y + 4.4) arr[i * 3 + 1] = GROUND_Y + 0.1;
    }
    posAttr.needsUpdate = true;

    this.controls.update();
    this.painter.update(dt);
    for (const s of this.live) s.update(dt, tSec);

    this.post.render();
  }
}

/**
 * The out-of-focus studio behind the subject: near-black with two soft violet/blue blooms,
 * like distant practicals through a wide-open lens. Painted once onto a canvas and wrapped
 * on an inward-facing sphere.
 */
function makeBackdropTexture(): THREE.CanvasTexture {
  const w = 1024;
  const h = 512;
  const canvas = document.createElement('canvas');
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext('2d')!;
  ctx.fillStyle = '#06070b';
  ctx.fillRect(0, 0, w, h);

  const blob = (x: number, y: number, r: number, rgba: string): void => {
    const g = ctx.createRadialGradient(x, y, 0, x, y, r);
    g.addColorStop(0, rgba);
    g.addColorStop(1, 'rgba(0,0,0,0)');
    ctx.fillStyle = g;
    ctx.fillRect(x - r, y - r, r * 2, r * 2);
  };
  blob(w * 0.3, h * 0.38, 280, 'rgba(74, 52, 138, 0.34)');  // violet bloom, camera-left
  blob(w * 0.78, h * 0.45, 220, 'rgba(40, 58, 118, 0.22)'); // cooler bloom, camera-right
  blob(w * 0.55, h * 0.2, 180, 'rgba(120, 100, 190, 0.10)'); // faint high sparkle wash

  const tex = new THREE.CanvasTexture(canvas);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

/** Near-black satin floor with a soft radial sheen — a quiet stage for the sphere's shadow. */
function makeFloorTexture(): THREE.CanvasTexture {
  const size = 512;
  const canvas = document.createElement('canvas');
  canvas.width = canvas.height = size;
  const ctx = canvas.getContext('2d')!;
  const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
  g.addColorStop(0, '#0f1118');
  g.addColorStop(0.45, '#0b0c12');
  g.addColorStop(1, '#08090d');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, size, size);
  const tex = new THREE.CanvasTexture(canvas);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}
src/bvh.ts
파일 저장

import * as THREE from 'three';
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh';

/**
 * BVH acceleration for every raycast in the app. The canvas meshes get a bounds tree, and
 * the patched Mesh.raycast walks it instead of brute-forcing triangles — this keeps stroke
 * painting and the hover brush smooth even on dense geometry. Meshes without a tree fall
 * back to the stock raycast, so the patch is safe globally.
 */

/* eslint-disable @typescript-eslint/no-explicit-any */
(THREE.BufferGeometry.prototype as any).computeBoundsTree = computeBoundsTree;
(THREE.BufferGeometry.prototype as any).disposeBoundsTree = disposeBoundsTree;
(THREE.Mesh.prototype as any).raycast = acceleratedRaycast;

/** Build bounds trees for every mesh under `root` (the paint-target model). */
export function indexForRaycasts(root: THREE.Object3D): void {
  root.traverse((o) => {
    const mesh = o as THREE.Mesh;
    if (mesh.isMesh && !(mesh.geometry as any).boundsTree) {
      (mesh.geometry as any).computeBoundsTree();
    }
  });
}

export function disposeRaycastIndex(geometry: THREE.BufferGeometry): void {
  (geometry as any).disposeBoundsTree?.();
}

/** BVH honors this flag: stop at the closest hit instead of collecting every intersection. */
export function firstHitOnly(raycaster: THREE.Raycaster): THREE.Raycaster {
  (raycaster as any).firstHitOnly = true;
  return raycaster;
}
src/main.ts
파일 저장

import { App } from './app';

const app = new App(document.getElementById('app') as HTMLElement);
// Debug/scripting hook (used by the automated visual tests).
(window as unknown as { __app: App }).__app = app;

app.start().catch((err: Error) => {
  console.error(err);
  const el = document.createElement('div');
  el.className = 'fatal';
  el.textContent = `Failed to start the renderer: ${err.message}. ` +
    'This app needs WebGPU or WebGL2 — try a recent Chrome, Edge or Firefox.';
  document.body.appendChild(el);
});
src/modes/aurora.ts
파일 저장

import * as THREE from 'three';
import { MeshBasicNodeMaterial } from 'three/webgpu';
import {
  abs, attribute, cos, float, mix, positionLocal, smoothstep, time, uniform, vec3,
} from 'three/tsl';
import { mulberry32, type PaintMode, type StrokeInstance, type SurfaceSample } from './mode';

/**
 * Aurora silk mode. A stroke unfurls a curtain of luminous silk from the surface — a tall
 * waving sheet of light in the spirit of an aurora borealis, rendered as pure shader:
 *
 *  - CURTAIN ×2 — one grid geometry drawn twice (front + a shorter back layer with its own
 *    phase), displaced in the vertex stage by layered sine waves whose amplitude grows
 *    with height, so the hem stays pinned to the stroke while the top billows.
 *  - FOLD LIGHT — the fragment brightness is locked to the *same phase* as the vertex
 *    wave, so the curtain glows brightest along its folds, exactly like translucent
 *    fabric seen edge-on. The folds therefore visibly travel with the cloth.
 *  - RAYS — thin vertical striations drifting slowly along the curtain (the aurora
 *    "curtain of rays" look), plus a bright hem at the bottom edge.
 *  - HEM GLOW — an additive strip laid on the surface, tinting the sphere beneath.
 *  - MOTES — twinkling star-dust drifting inside the curtain volume.
 *  - LIGHT SPILL — cool point lights breathing softly along the stroke.
 *
 * Palettes are color UNIFORMS (switching retints everything live), 'Spectrum' swaps in a
 * cosine color-cycling palette. Height, wave, flow, rays, brightness: all uniforms. The
 * curtain unfurls along the stroke as the growth front passes.
 */

export type AuroraPaletteName = 'Borealis' | 'Twilight' | 'Ember' | 'Spectrum';

export interface AuroraSettings {
  palette: AuroraPaletteName;
  height: number;      // curtain height (world units)
  wave: number;        // billow amplitude
  flow: number;        // animation speed
  rays: number;        // vertical striation strength
  brightness: number;  // overall curtain intensity
  sparkles: number;    // motes inside the curtain (live-culled up to MAX_MOTES)
  lightSpill: number;  // breathing point-light intensity
  growthSpeed: number; // unfurl speed (world units / second)
}

export const defaultAuroraSettings: AuroraSettings = {
  palette: 'Borealis',
  height: 0.62,
  wave: 0.55,
  flow: 1,
  rays: 0.7,
  brightness: 1,
  sparkles: 140,
  lightSpill: 0.8,
  growthSpeed: 1.2,
};

export const MAX_MOTES = 240;

interface AuroraPalette {
  hem: THREE.Color; // bottom edge (the intense border)
  mid: THREE.Color;
  top: THREE.Color; // fades out at the crest
}

const PALETTES: Record<Exclude<AuroraPaletteName, 'Spectrum'>, AuroraPalette> = {
  Borealis: { hem: new THREE.Color(0x3cffa8), mid: new THREE.Color(0x36c9ff), top: new THREE.Color(0xb26bff) },
  Twilight: { hem: new THREE.Color(0xff8ac2), mid: new THREE.Color(0xa06bff), top: new THREE.Color(0x3d2bd6) },
  Ember: { hem: new THREE.Color(0xffc46a), mid: new THREE.Color(0xff6a8a), top: new THREE.Color(0x8a3dff) },
};

const PATH_STEP = 0.03;
const HEIGHT_SEGS = 14;

/* eslint-disable @typescript-eslint/no-explicit-any */
// @types/three loses node types on attribute() and color uniforms — rewrap via converts.
const attrFloat = (name: string) => float(attribute(name, 'float') as any);
const attrVec3 = (name: string) => vec3(attribute(name, 'vec3') as any);
const colorVec = (u: unknown) => vec3(u as any);
/* eslint-enable @typescript-eslint/no-explicit-any */

// ---------- shared sprite ----------

let moteTexture: THREE.CanvasTexture | null = null;

function getMoteTexture(): THREE.CanvasTexture {
  if (!moteTexture) {
    const size = 64;
    const canvas = document.createElement('canvas');
    canvas.width = canvas.height = size;
    const ctx = canvas.getContext('2d')!;
    const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
    g.addColorStop(0, 'rgba(255,255,255,1)');
    g.addColorStop(0.3, 'rgba(220,235,255,0.7)');
    g.addColorStop(1, 'rgba(160,190,255,0)');
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, size, size);
    moteTexture = new THREE.CanvasTexture(canvas);
  }
  return moteTexture;
}

let moteMaterial: THREE.MeshBasicMaterial | null = null;

function getMoteMaterial(): THREE.MeshBasicMaterial {
  if (!moteMaterial) {
    moteMaterial = new THREE.MeshBasicMaterial({
      map: getMoteTexture(),
      transparent: true,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      side: THREE.DoubleSide,
    });
  }
  return moteMaterial;
}

// ---------- path ----------

interface PathPoint {
  pos: THREE.Vector3;
  normal: THREE.Vector3; // "up" for the curtain — radially off the surface
  side: THREE.Vector3;
  dist: number;
}

function buildPath(samples: SurfaceSample[]): PathPoint[] {
  const pts: PathPoint[] = [];
  let travelled = 0;
  let next = 0;
  const tangent = new THREE.Vector3();
  for (let i = 0; i < samples.length; i++) {
    if (i > 0) travelled += samples[i].local.distanceTo(samples[i - 1].local);
    if (travelled < next && i !== samples.length - 1) continue;
    next = travelled + PATH_STEP;
    const a = samples[Math.max(i - 1, 0)];
    const b = samples[Math.min(i + 1, samples.length - 1)];
    tangent.subVectors(b.local, a.local);
    if (tangent.lengthSq() < 1e-8) tangent.set(1, 0, 0);
    tangent.normalize();
    const normal = samples[i].localNormal.clone().normalize();
    const side = new THREE.Vector3().crossVectors(tangent, normal).normalize();
    pts.push({ pos: samples[i].local.clone(), normal, side, dist: travelled });
  }
  return pts;
}

/** Curtain grid: columns along the stroke × rows up the curtain. All vertices sit at the
 *  HEM (the lift happens in the vertex shader), so height/wave/unfurl are pure uniforms. */
function buildCurtainGeometry(path: PathPoint[], rnd: () => number): THREE.BufferGeometry {
  const cols = path.length;
  const rows = HEIGHT_SEGS + 1;
  const positions = new Float32Array(cols * rows * 3);
  const ups = new Float32Array(cols * rows * 3);
  const sides = new Float32Array(cols * rows * 3);
  const dists = new Float32Array(cols * rows);
  const vs = new Float32Array(cols * rows);
  const colJits = new Float32Array(cols * rows);
  const indices: number[] = [];

  let jit = 1;
  for (let i = 0; i < cols; i++) {
    const p = path[i];
    // Smooth random walk → an organic, uneven curtain crest.
    jit = THREE.MathUtils.clamp(jit + (rnd() - 0.5) * 0.22, 0.68, 1.32);
    for (let r = 0; r < rows; r++) {
      const vi = i * rows + r;
      positions[vi * 3] = p.pos.x;
      positions[vi * 3 + 1] = p.pos.y;
      positions[vi * 3 + 2] = p.pos.z;
      ups[vi * 3] = p.normal.x;
      ups[vi * 3 + 1] = p.normal.y;
      ups[vi * 3 + 2] = p.normal.z;
      sides[vi * 3] = p.side.x;
      sides[vi * 3 + 1] = p.side.y;
      sides[vi * 3 + 2] = p.side.z;
      dists[vi] = p.dist;
      vs[vi] = r / HEIGHT_SEGS;
      colJits[vi] = jit;
    }
  }
  for (let i = 0; i < cols - 1; i++) {
    for (let r = 0; r < rows - 1; r++) {
      const a = i * rows + r;
      const b = (i + 1) * rows + r;
      indices.push(a, b, a + 1, b, b + 1, a + 1);
    }
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geo.setAttribute('aUp', new THREE.BufferAttribute(ups, 3));
  geo.setAttribute('aSide', new THREE.BufferAttribute(sides, 3));
  geo.setAttribute('aDist', new THREE.BufferAttribute(dists, 1));
  geo.setAttribute('aV', new THREE.BufferAttribute(vs, 1));
  geo.setAttribute('aColJit', new THREE.BufferAttribute(colJits, 1));
  geo.setIndex(indices);
  return geo;
}

/** Two-row strip on the surface for the hem glow (across displacement in the shader). */
function buildHemGeometry(path: PathPoint[]): THREE.BufferGeometry {
  const n = path.length;
  const positions = new Float32Array(n * 2 * 3);
  const sides = new Float32Array(n * 2 * 3);
  const across = new Float32Array(n * 2);
  const dists = new Float32Array(n * 2);
  const indices: number[] = [];
  for (let i = 0; i < n; i++) {
    const p = path[i];
    for (let k = 0; k < 2; k++) {
      const vi = i * 2 + k;
      positions[vi * 3] = p.pos.x + p.normal.x * 0.005;
      positions[vi * 3 + 1] = p.pos.y + p.normal.y * 0.005;
      positions[vi * 3 + 2] = p.pos.z + p.normal.z * 0.005;
      sides[vi * 3] = p.side.x;
      sides[vi * 3 + 1] = p.side.y;
      sides[vi * 3 + 2] = p.side.z;
      across[vi] = k === 0 ? -1 : 1;
      dists[vi] = p.dist;
    }
  }
  for (let i = 0; i < n - 1; i++) {
    const a = i * 2;
    indices.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geo.setAttribute('aSide', new THREE.BufferAttribute(sides, 3));
  geo.setAttribute('aAcross', new THREE.BufferAttribute(across, 1));
  geo.setAttribute('aDist', new THREE.BufferAttribute(dists, 1));
  geo.setIndex(indices);
  return geo;
}

// ---------- motes ----------

interface Mote {
  base: THREE.Vector3; // rest position inside the curtain
  up: THREE.Vector3;
  side: THREE.Vector3;
  v: number;           // height fraction (drifts with the wave amplitude)
  dist: number;
  size: number;
  phase: number;
  twinkle: number;     // twinkle rate
  colorMix: number;    // 0..1 blend across the palette
  quat: THREE.Quaternion;
}

const _m = new THREE.Matrix4();
const _s = new THREE.Vector3();
const _p = new THREE.Vector3();
const _zero = new THREE.Matrix4().makeScale(0, 0, 0);
const _color = new THREE.Color();
const _cA = new THREE.Color();
const _cB = new THREE.Color();

// ---------- the stroke ----------

class AuroraStroke implements StrokeInstance {
  readonly group = new THREE.Group();

  private settings: AuroraSettings;
  private path: PathPoint[];
  private readonly total: number;
  private grown = 0;

  // live uniforms
  private uGrown = uniform(0);
  private uTotal = uniform(1);
  private uHeight = uniform(0.6);
  private uWave = uniform(0.5);
  private uFlow = uniform(1);
  private uRays = uniform(0.7);
  private uBright = uniform(1);
  private uSpectrum = uniform(0);
  private uHem = uniform(new THREE.Color());
  private uMid = uniform(new THREE.Color());
  private uTop = uniform(new THREE.Color());

  private curtainGeo!: THREE.BufferGeometry;
  private hemGeo!: THREE.BufferGeometry;
  private materials: MeshBasicNodeMaterial[] = [];

  private motes: Mote[] = [];
  private moteMesh: THREE.InstancedMesh;

  private lights: { light: THREE.PointLight; dist: number; phase: number; warm: boolean }[] = [];

  constructor(samples: SurfaceSample[], seed: number, settings: AuroraSettings) {
    this.settings = { ...settings };
    const rnd = mulberry32(seed);
    this.path = buildPath(samples);
    this.total = this.path.length ? this.path[this.path.length - 1].dist : 0;
    this.uTotal.value = Math.max(this.total, 1e-3);

    // ----- curtains: one geometry, two layers with their own phase and stature -----
    this.curtainGeo = buildCurtainGeometry(this.path, rnd);
    const front = this.makeCurtainMaterial(0, 1, 1);
    const back = this.makeCurtainMaterial(2.4, 0.72, 0.55);
    const frontMesh = new THREE.Mesh(this.curtainGeo, front);
    const backMesh = new THREE.Mesh(this.curtainGeo, back);
    for (const m of [backMesh, frontMesh]) {
      m.renderOrder = 2;
      m.frustumCulled = false;
      this.group.add(m);
    }

    // ----- hem glow -----
    this.hemGeo = buildHemGeometry(this.path);
    const hemMat = this.makeHemMaterial();
    const hemMesh = new THREE.Mesh(this.hemGeo, hemMat);
    hemMesh.renderOrder = 1;
    hemMesh.frustumCulled = false;
    this.group.add(hemMesh);

    // ----- motes -----
    for (let i = 0; i < MAX_MOTES; i++) {
      const p = this.path[Math.floor(rnd() * this.path.length)];
      const v = Math.pow(rnd(), 1.4); // cluster toward the hem
      this.motes.push({
        base: p.pos.clone(),
        up: p.normal,
        side: p.side,
        v,
        dist: p.dist,
        size: 0.008 + rnd() * 0.016,
        phase: rnd() * Math.PI * 2,
        twinkle: 0.6 + rnd() * 2.2,
        colorMix: rnd(),
        quat: new THREE.Quaternion().setFromEuler(
          new THREE.Euler(rnd() * Math.PI, rnd() * Math.PI, rnd() * Math.PI),
        ),
      });
    }
    this.moteMesh = new THREE.InstancedMesh(new THREE.PlaneGeometry(1, 1), getMoteMaterial(), MAX_MOTES);
    for (let i = 0; i < MAX_MOTES; i++) {
      this.moteMesh.setMatrixAt(i, _zero);
      this.moteMesh.setColorAt(i, _color.setRGB(0, 0, 0));
    }
    this.moteMesh.renderOrder = 3;
    this.moteMesh.frustumCulled = false;
    this.group.add(this.moteMesh);

    // ----- light spill: cool lights breathing along the stroke -----
    const nLights = Math.min(3, Math.max(1, Math.round(this.total * 1.2)));
    for (let i = 0; i < nLights; i++) {
      const f = nLights === 1 ? 0.5 : 0.15 + (0.7 * i) / (nLights - 1);
      const p = this.pathAt(this.total * f);
      const light = new THREE.PointLight(0xffffff, 0, 1.6, 2);
      light.position.copy(p.pos).addScaledVector(p.normal, 0.16);
      this.group.add(light);
      this.lights.push({ light, dist: this.total * f, phase: rnd() * 20, warm: i % 2 === 1 });
    }

    this.applySettings(settings);
  }

  /**
   * The curtain shader. `phase` de-synchronizes the back layer; `stature`/`dim` shrink
   * and soften it so the two sheets read as separate bands of one aurora.
   */
  private makeCurtainMaterial(phase: number, stature: number, dim: number): MeshBasicNodeMaterial {
    const mat = new MeshBasicNodeMaterial();
    mat.transparent = true;
    mat.depthWrite = false;
    mat.side = THREE.DoubleSide;
    mat.blending = THREE.AdditiveBlending;
    this.materials.push(mat);

    const aUp = attrVec3('aUp');
    const aSide = attrVec3('aSide');
    const aDist = attrFloat('aDist');
    const aV = attrFloat('aV');
    const aColJit = attrFloat('aColJit');
    const T = time.mul(this.uFlow);

    // Unfurl: the curtain lifts out of the surface as the growth front sweeps past.
    const unfurl = smoothstep(0.0, 0.4, this.uGrown.sub(aDist));
    const lift = this.uHeight.mul(aColJit).mul(aV).mul(unfurl).mul(stature);

    // Billow: two traveling waves + a fine ripple; amplitude grows with height so the
    // hem stays pinned. A slow global breath keeps the whole sheet alive.
    const breath = T.mul(0.23).add(phase).sin().mul(0.2).add(0.8);
    const amp = this.uWave.mul(0.17).mul(aV.pow(1.35)).mul(unfurl).mul(breath);
    const foldPhase = aDist.mul(6.3).add(T.mul(1.1)).add(phase);
    const sway = foldPhase.sin()
      .add(aDist.mul(11.7).sub(T.mul(0.7)).add(aV.mul(1.8)).add(phase).sin().mul(0.5));
    const ripple = aDist.mul(23).add(T.mul(1.9)).add(aV.mul(4)).add(phase).sin().mul(0.02).mul(aV);

    mat.positionNode = positionLocal
      .add(aUp.mul(lift.add(ripple.mul(0.4))))
      .add(aSide.mul(amp.mul(sway).add(ripple)));

    // ----- fragment -----
    // Fold light: same phase as the sway → the curtain glows along its moving folds.
    const folds = abs(cos(foldPhase)).pow(1.6).mul(0.85).add(0.4);
    // Vertical rays drifting slowly along the stroke.
    const rayWave = aDist.mul(36).add(T.mul(0.45).sin().mul(1.6)).add(aV.mul(2.2)).sin().mul(0.5).add(0.5);
    const rays = mix(float(1), rayWave.pow(2.4).mul(1.7).add(0.25), this.uRays);
    // Intense lower border, like the real thing.
    const hemBoost = smoothstep(0.0, 0.22, aV).oneMinus().mul(1.3).add(1);

    // Palette gradient hem → mid → top, or the cosine spectrum that cycles along the stroke.
    let grad = mix(colorVec(this.uHem), colorVec(this.uMid), smoothstep(0.03, 0.45, aV));
    grad = mix(grad, colorVec(this.uTop), smoothstep(0.45, 0.95, aV));
    const spec = cos(
      vec3(aDist.mul(0.9).add(T.mul(0.1)), aDist.mul(0.9).add(T.mul(0.1)).add(2.09), aDist.mul(0.9).add(T.mul(0.1)).add(4.18)),
    ).mul(0.5).add(0.5).mul(vec3(0.9, 1.0, 1.2));
    const color = mix(grad, spec, this.uSpectrum);

    mat.colorNode = color.mul(folds).mul(rays).mul(hemBoost).mul(this.uBright).mul(1.3 * dim);

    // Feathered crest and soft ends; the sheet fades with height.
    const endFade = smoothstep(0.0, 0.22, aDist.min(this.uTotal.sub(aDist)));
    const feather = aDist.mul(17).add(aV.mul(9)).add(T.mul(0.8)).sin().mul(0.12).add(0.88);
    mat.opacityNode = float(1).sub(aV).pow(1.15).mul(unfurl).mul(endFade).mul(feather).mul(0.85);
    return mat;
  }

  /** Soft additive pool of light where the silk meets the surface. */
  private makeHemMaterial(): MeshBasicNodeMaterial {
    const mat = new MeshBasicNodeMaterial();
    mat.transparent = true;
    mat.depthWrite = false;
    mat.blending = THREE.AdditiveBlending;
    this.materials.push(mat);

    const aSide = attrVec3('aSide');
    const aAcross = attrFloat('aAcross');
    const aDist = attrFloat('aDist');
    const T = time.mul(this.uFlow);

    mat.positionNode = positionLocal.add(aSide.mul(aAcross.mul(this.uHeight.mul(0.22).add(0.05))));

    const unfurl = smoothstep(0.0, 0.3, this.uGrown.sub(aDist));
    const endFade = smoothstep(0.0, 0.2, aDist.min(this.uTotal.sub(aDist)));
    const falloff = abs(aAcross).oneMinus().max(0).pow(1.5);
    const shimmer = aDist.mul(6.3).add(T.mul(1.1)).cos().mul(0.2).add(0.8);
    const color = mix(colorVec(this.uHem), colorVec(this.uMid), 0.35);
    mat.colorNode = color.mul(falloff).mul(shimmer).mul(this.uBright).mul(0.5);
    mat.opacityNode = unfurl.mul(endFade);
    return mat;
  }

  // ----- live settings -----

  applySettings(settings: unknown): void {
    const s = settings as AuroraSettings;
    this.settings = { ...s };
    this.uHeight.value = s.height;
    this.uWave.value = s.wave;
    this.uFlow.value = s.flow;
    this.uRays.value = s.rays;
    this.uBright.value = s.brightness;
    this.uSpectrum.value = s.palette === 'Spectrum' ? 1 : 0;
    const pal = PALETTES[s.palette === 'Spectrum' ? 'Borealis' : s.palette];
    (this.uHem.value as THREE.Color).copy(pal.hem);
    (this.uMid.value as THREE.Color).copy(pal.mid);
    (this.uTop.value as THREE.Color).copy(pal.top);
  }

  // ----- StrokeInstance -----

  update(dt: number, t: number): void {
    if (this.grown < this.total + 1) {
      this.grown += dt * this.settings.growthSpeed;
      this.uGrown.value = this.grown;
    }
    this.updateMotes(t);
    this.updateLights(t);
  }

  finishGrowth(): void {
    this.grown = this.total + 2;
    this.uGrown.value = this.grown;
  }

  private pathAt(dist: number): PathPoint {
    const i = THREE.MathUtils.clamp(Math.round(dist / PATH_STEP), 0, this.path.length - 1);
    return this.path[i];
  }

  private updateMotes(t: number): void {
    const s = this.settings;
    const flow = t * s.flow;
    const pal = PALETTES[s.palette === 'Spectrum' ? 'Borealis' : s.palette];
    _cA.copy(pal.hem);
    _cB.copy(pal.top);
    const open = this.grown;
    for (let i = 0; i < this.motes.length; i++) {
      const m = this.motes[i];
      if (i >= s.sparkles || m.dist > open) {
        this.moteMesh.setMatrixAt(i, _zero);
        continue;
      }
      // Drift with (a simplification of) the curtain's own wave, so motes ride the silk.
      const lift = s.height * m.v * (0.35 + 0.65 * Math.min((open - m.dist) / 0.4, 1));
      const sway = Math.sin(m.dist * 6.3 + flow * 1.1) * s.wave * 0.17 * Math.pow(m.v, 1.35);
      const bob = Math.sin(flow * 0.6 + m.phase) * 0.02;
      _p.copy(m.base)
        .addScaledVector(m.up, lift + bob)
        .addScaledVector(m.side, sway + Math.sin(flow * 0.4 + m.phase * 1.7) * 0.02);
      const tw = Math.pow(0.5 + 0.5 * Math.sin(flow * m.twinkle * 2 + m.phase), 2.5);
      _s.setScalar(m.size * (0.7 + tw * 0.6));
      _m.compose(_p, m.quat, _s);
      this.moteMesh.setMatrixAt(i, _m);
      _color.copy(_cA).lerp(_cB, m.colorMix).multiplyScalar((0.25 + tw * 1.3) * s.brightness);
      this.moteMesh.setColorAt(i, _color);
    }
    this.moteMesh.instanceMatrix.needsUpdate = true;
    if (this.moteMesh.instanceColor) this.moteMesh.instanceColor.needsUpdate = true;
  }

  private updateLights(t: number): void {
    const pal = PALETTES[this.settings.palette === 'Spectrum' ? 'Borealis' : this.settings.palette];
    for (const { light, dist, phase, warm } of this.lights) {
      if (this.grown <= dist) {
        light.intensity = 0;
        continue;
      }
      const ignite = THREE.MathUtils.clamp((this.grown - dist) / 0.5, 0, 1);
      const breathe = 0.72 + 0.28 * Math.sin(t * 0.9 * this.settings.flow + phase);
      light.color.copy(warm ? pal.top : pal.hem);
      light.intensity = this.settings.lightSpill * 1.1 * ignite * breathe;
    }
  }

  dispose(): void {
    this.group.removeFromParent();
    this.curtainGeo.dispose();
    this.hemGeo.dispose();
    for (const m of this.materials) m.dispose();
    this.moteMesh.geometry.dispose();
    this.moteMesh.dispose(); // material + sprite are shared
  }
}

// ---------- the mode ----------

export const auroraMode: PaintMode<AuroraSettings> = {
  id: 'Aurora silk',
  createStroke(samples, seed, settings): StrokeInstance {
    return new AuroraStroke(samples, seed, settings);
  },
};
src/modes/crystals.ts
파일 저장

import * as THREE from 'three';
import { mulberry32, type PaintMode, type StrokeInstance, type SurfaceSample } from './mode';

/**
 * Crystal painting mode. Each stroke seeds clusters of quartz-like points along the painted
 * path: one dominant crystal per cluster surrounded by smaller shards and rubble, all leaning
 * off the surface normal at natural angles. Crystals are transmissive (refractive glass with
 * colored absorption), lightly iridescent, and grow in with an elastic pop as the growth
 * front sweeps along the stroke.
 *
 * Every slider is TRULY live: a stroke stores each crystal's generative parameters (anchor,
 * tangent frame, stable randoms) rather than baked matrices, and instances are allocated at
 * the slider maxima. Changing size/spread/tilt/jitter/palette recomposes matrices and colors
 * in place; changing density/shards zero-scales culled instances — nothing is ever
 * disposed or recreated while you drag.
 */

export type CrystalPaletteName = 'Amethyst' | 'Ice' | 'Emerald' | 'Citrine' | 'Rose' | 'Prism';

export interface CrystalSettings {
  palette: CrystalPaletteName;
  clusterDensity: number; // clusters per world unit of stroke (live-culled up to MAX_DENSITY)
  crystalSize: number;    // height of a cluster's main crystal (world units)
  shards: number;         // secondary crystals per cluster (live-culled up to MAX_SHARDS)
  spread: number;         // cluster footprint, as a multiple of crystalSize
  tilt: number;           // 0..1 — how far crystals lean away from the surface normal
  sizeJitter: number;     // 0..1 — per-crystal size variation
  clearMix: number;       // 0..1 — fraction of crystals that are clear refractive quartz
  glow: number;           // emissive intensity (feeds the bloom pass)
  growthSpeed: number;    // world units of stroke length grown per second
}

export const defaultCrystalSettings: CrystalSettings = {
  palette: 'Amethyst',
  clusterDensity: 7,
  crystalSize: 0.17,
  shards: 7,
  spread: 1.0,
  tilt: 0.4,
  sizeJitter: 0.55,
  clearMix: 0.35,
  glow: 0,
  growthSpeed: 1.4,
};

/** Instances are generated at these maxima; the density/shard sliders cull, never rebuild.
 *  Keep in sync with the GUI slider ranges. */
export const MAX_DENSITY = 16;
export const MAX_SHARDS = 16;

// ---------- palettes ----------

interface Palette {
  base: THREE.Color;        // per-instance tint base
  attenuation: THREE.Color; // color light turns while passing through (the "body" color)
  emissive: THREE.Color;    // faint inner light, amplified by the glow slider + bloom
  hueJitter: number;        // per-crystal hue variation (0..1 of the full wheel)
}

const PALETTES: Record<CrystalPaletteName, Palette> = {
  Amethyst: {
    base: new THREE.Color(0xa878e8),
    attenuation: new THREE.Color(0x7a2fd6),
    emissive: new THREE.Color(0x8a5cff),
    hueJitter: 0.045,
  },
  Ice: {
    base: new THREE.Color(0xcfe8ff),
    attenuation: new THREE.Color(0x5aa6e8),
    emissive: new THREE.Color(0x7fc4ff),
    hueJitter: 0.03,
  },
  Emerald: {
    base: new THREE.Color(0x74e8a0),
    attenuation: new THREE.Color(0x0f9c4a),
    emissive: new THREE.Color(0x3cf58a),
    hueJitter: 0.04,
  },
  Citrine: {
    base: new THREE.Color(0xf5c76a),
    attenuation: new THREE.Color(0xd68a1e),
    emissive: new THREE.Color(0xffb84d),
    hueJitter: 0.035,
  },
  Rose: {
    base: new THREE.Color(0xf5a8c8),
    attenuation: new THREE.Color(0xd6488a),
    emissive: new THREE.Color(0xff7ab8),
    hueJitter: 0.03,
  },
  Prism: {
    base: new THREE.Color(0xe8ecf5),
    attenuation: new THREE.Color(0x9aa8c4),
    emissive: new THREE.Color(0xbcc8ff),
    hueJitter: 1.0, // full rainbow spread per crystal
  },
};

// ---------- shared geometry variants ----------

/**
 * A quartz point: hexagonal prism with jittered facet columns, a slight taper, and an
 * off-axis pyramidal termination. Non-indexed so every facet is flat-shaded — the hard
 * planar faces are what read as "crystal" under an environment map.
 * Normalized to height 1 with the base at y=0.
 */
function makeCrystalGeometry(rnd: () => number): THREE.BufferGeometry {
  const sides = 6;
  const baseR = 0.16 + rnd() * 0.1;
  const shaftH = 0.55 + rnd() * 0.2;   // where the termination starts
  const taper = 0.78 + rnd() * 0.16;   // shaft narrows slightly toward the tip
  const apex = new THREE.Vector3((rnd() - 0.5) * 0.14, 1, (rnd() - 0.5) * 0.14);

  // Jitter each facet column once so the prism edges stay straight top to bottom.
  const angles: number[] = [];
  const radii: number[] = [];
  for (let i = 0; i < sides; i++) {
    angles.push(((i + (rnd() - 0.5) * 0.34) / sides) * Math.PI * 2);
    radii.push(baseR * (0.8 + rnd() * 0.4));
  }

  const lower: THREE.Vector3[] = [];
  const upper: THREE.Vector3[] = [];
  for (let i = 0; i < sides; i++) {
    const c = Math.cos(angles[i]);
    const s = Math.sin(angles[i]);
    lower.push(new THREE.Vector3(c * radii[i], 0, s * radii[i]));
    upper.push(new THREE.Vector3(c * radii[i] * taper, shaftH, s * radii[i] * taper));
  }

  const positions: number[] = [];
  const push = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3): void => {
    positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z);
  };
  const bottom = new THREE.Vector3(0, -0.02, 0); // tiny below-base apex closes tilted crystals
  for (let i = 0; i < sides; i++) {
    const j = (i + 1) % sides;
    push(lower[i], upper[i], upper[j]); // shaft facet (two tris)
    push(lower[i], upper[j], lower[j]);
    push(upper[i], apex, upper[j]);     // termination facet
    push(lower[j], bottom, lower[i]);   // base cap
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  geo.computeVertexNormals(); // non-indexed → true flat facets
  return geo;
}

/** A few cached shape variants; instances mix them so no two clusters look stamped. */
const VARIANTS = 5;
let variantGeos: THREE.BufferGeometry[] | null = null;

function getVariantGeometries(): THREE.BufferGeometry[] {
  if (!variantGeos) {
    const rnd = mulberry32(0xc0ffee);
    variantGeos = Array.from({ length: VARIANTS }, () => makeCrystalGeometry(rnd));
  }
  return variantGeos;
}

// ---------- shared materials (one per palette, so glow edits hit every stroke) ----------

const materials = new Map<CrystalPaletteName, THREE.MeshPhysicalMaterial>();

function getMaterial(name: CrystalPaletteName, glow: number): THREE.MeshPhysicalMaterial {
  let mat = materials.get(name);
  if (!mat) {
    const p = PALETTES[name];
    // The palette tint lives in the PER-INSTANCE colors and the colored absorption —
    // the base color stays white. (Tinting both multiplies the tint into itself and
    // the crystals go dark and opaque-looking.)
    mat = new THREE.MeshPhysicalMaterial({
      color: 0xffffff,
      metalness: 0,
      roughness: 0.05,
      // Partially transmissive: full transmission over the dark sphere reads as flat
      // black glass. Keeping ~35% diffuse gives facet-by-facet shading (the milky,
      // translucent read of a real amethyst cluster) while the glass depth remains.
      transmission: 0.7,
      ior: 1.55,
      thickness: 0.4,
      attenuationColor: p.attenuation,
      attenuationDistance: 0.5,
      dispersion: 0.3, // chromatic fringing inside the glass — the "gem fire"
      iridescence: 0.4,
      iridescenceIOR: 1.3,
      clearcoat: 0.5,
      clearcoatRoughness: 0.12,
      specularIntensity: 1,
      emissive: p.emissive,
      emissiveIntensity: glow,
      envMapIntensity: 1.6,
    });
    materials.set(name, mat);
  }
  mat.emissiveIntensity = glow;
  return mat;
}

/**
 * Clear quartz: the transparent, refractive companion material (one shared instance).
 * It lives on highlights — full transmission, near-zero roughness, strong dispersion —
 * so it reads as glass fire next to the tinted, absorbing crystals.
 */
let clearMaterial: THREE.MeshPhysicalMaterial | null = null;

function getClearMaterial(glow: number): THREE.MeshPhysicalMaterial {
  if (!clearMaterial) {
    clearMaterial = new THREE.MeshPhysicalMaterial({
      color: 0xffffff,
      metalness: 0,
      roughness: 0.02,
      transmission: 1,
      ior: 1.55,
      thickness: 0.5,
      attenuationColor: 0xdfe8ff, // the faintest cool cast, like real rock crystal
      attenuationDistance: 1.6,
      dispersion: 0.4,
      iridescence: 0.15,
      iridescenceIOR: 1.3,
      clearcoat: 0.6,
      clearcoatRoughness: 0.08,
      specularIntensity: 1.2,
      emissive: 0xcfd8ff,
      emissiveIntensity: glow * 0.35,
      envMapIntensity: 2.0,
    });
  }
  clearMaterial.emissiveIntensity = glow * 0.35;
  return clearMaterial;
}

/** Live glow slider: retint every material in place — no rebuild. */
export function setCrystalGlow(glow: number): void {
  for (const mat of materials.values()) mat.emissiveIntensity = glow;
  if (clearMaterial) clearMaterial.emissiveIntensity = glow * 0.35;
}

// ---------- per-stroke instance ----------

type CrystalKind = 'main' | 'shard' | 'rubble';

/**
 * One crystal = its stable generative parameters. Everything derived (matrix, color,
 * visibility) is recomputed from these + the current settings, which is what makes every
 * slider live without recreating anything.
 */
interface CrystalInstance {
  variant: number;
  kind: CrystalKind;
  // where it sits on the stroke
  anchor: THREE.Vector3;   // cluster's anchor-local surface point
  n: THREE.Vector3;        // surface normal there
  t1: THREE.Vector3;       // tangent frame
  t2: THREE.Vector3;
  birth: number;           // stroke distance at which this crystal starts growing
  // culling ranks
  clusterRnd: number;      // same for the whole cluster → density culling
  shardIndex: number;      // 0..MAX_SHARDS-1 → shard-count culling
  shardCountRnd: number;   // per-cluster variation of the shard count
  // stable per-crystal randoms (all 0..1)
  offAz: number;           // azimuth of the offset from the cluster anchor
  offFrac: number;         // offset radius, as a fraction of the cluster footprint
  heightBase: number;      // kind-specific height, as a multiple of crystalSize
  jitterRnd: number;       // feeds the sizeJitter slider
  widthRnd: number;        // width relative to height
  tiltScale: number;       // kind-specific lean multiplier
  leanRnd: number;         // lean magnitude
  leanAz: number;          // lean azimuth (radians)
  spin: number;            // rotation about own axis (radians)
  hueRnd: number;
  satRnd: number;
  lightRnd: number;
  clearRnd: number;        // stable rank for the clearMix slider (below the mix → clear quartz)
  // derived cache, rewritten by applySettings()
  visible: boolean;
  isClear: boolean;
  pos: THREE.Vector3;
  quat: THREE.Quaternion;
  scale: THREE.Vector3;
  color: THREE.Color;
}

const GROW_WINDOW = 0.45;  // stroke-distance span over which one crystal scales in
const _m = new THREE.Matrix4();
const _s = new THREE.Vector3();
const _dir = new THREE.Vector3();
const _align = new THREE.Quaternion();
const _Y = new THREE.Vector3(0, 1, 0);
const _zero = new THREE.Matrix4().makeScale(0, 0, 0);
const _hsl = { h: 0, s: 0, l: 0 };
const _white = new THREE.Color(0xffffff);
const _clearTint = new THREE.Color();

/** Elastic-ish pop: overshoots ~8% then settles, like a crystal snapping into being. */
function easeOutBack(t: number): number {
  const c1 = 1.20158;
  const c3 = c1 + 1;
  const u = t - 1;
  return 1 + c3 * u * u * u + c1 * u * u;
}

class CrystalStroke implements StrokeInstance {
  readonly group = new THREE.Group();

  /** Two mesh sets per variant: tinted palette crystals and clear refractive quartz.
   *  Every instance owns a slot in BOTH; the clearMix slider decides which one is live
   *  (the other stays zero-scaled) — so the mix is instant, nothing rebuilt. */
  private tinted: THREE.InstancedMesh[] = [];
  private clear: THREE.InstancedMesh[] = [];
  private byVariant: CrystalInstance[][];
  private settings: CrystalSettings;
  private grown = 0;
  private readonly total: number;
  private done = false;

  constructor(samples: SurfaceSample[], seed: number, settings: CrystalSettings) {
    this.settings = { ...settings };
    const rnd = mulberry32(seed);
    const instances = this.scatter(samples, rnd);

    // Bucket instances per geometry variant → one tinted + one clear InstancedMesh each.
    this.byVariant = Array.from({ length: VARIANTS }, () => []);
    for (const inst of instances) this.byVariant[inst.variant].push(inst);

    const geos = getVariantGeometries();
    const tintedMat = getMaterial(settings.palette, settings.glow);
    const clearMat = getClearMaterial(settings.glow);
    const makeMesh = (v: number, mat: THREE.MeshPhysicalMaterial): THREE.InstancedMesh => {
      const list = this.byVariant[v];
      const mesh = new THREE.InstancedMesh(geos[v], mat, Math.max(list.length, 1));
      mesh.castShadow = true;
      mesh.receiveShadow = true;
      mesh.frustumCulled = false; // grows over time; cheap enough to always draw
      for (let i = 0; i < list.length; i++) mesh.setMatrixAt(i, _zero);
      mesh.count = list.length;
      mesh.instanceMatrix.needsUpdate = true;
      this.group.add(mesh);
      return mesh;
    };
    for (let v = 0; v < VARIANTS; v++) {
      this.tinted.push(makeMesh(v, tintedMat));
      this.clear.push(makeMesh(v, clearMat));
    }

    this.total = this.strokeLength(samples);
    this.applySettings(settings); // derive matrices/colors/visibility for the first time
  }

  // ----- generation: stable parameters only, at slider maxima -----

  private strokeLength(samples: SurfaceSample[]): number {
    let d = 0;
    for (let i = 1; i < samples.length; i++) d += samples[i].local.distanceTo(samples[i - 1].local);
    return d;
  }

  /** Walk the stroke and drop a crystal cluster at MAX density; the slider culls live. */
  private scatter(samples: SurfaceSample[], rnd: () => number): CrystalInstance[] {
    const out: CrystalInstance[] = [];
    const spacing = 1 / MAX_DENSITY;

    let travelled = 0;
    let nextAt = 0;
    for (let i = 0; i < samples.length; i++) {
      if (i > 0) travelled += samples[i].local.distanceTo(samples[i - 1].local);
      if (travelled < nextAt) continue;
      nextAt = travelled + spacing * (0.75 + rnd() * 0.5);
      this.cluster(out, samples[i], travelled, rnd);
    }
    return out;
  }

  /** One cluster: a dominant point, MAX_SHARDS shard slots, and a dusting of rubble. */
  private cluster(out: CrystalInstance[], sample: SurfaceSample, dist: number, rnd: () => number): void {
    const n = sample.localNormal.clone();
    const t1 = new THREE.Vector3(1, 0, 0);
    if (Math.abs(n.x) > 0.9) t1.set(0, 1, 0);
    t1.cross(n).normalize();
    const t2 = new THREE.Vector3().crossVectors(n, t1);

    const clusterRnd = rnd();
    const shardCountRnd = rnd();

    const add = (
      kind: CrystalKind,
      shardIndex: number,
      offFrac: number,
      heightBase: number,
      tiltScale: number,
      birthLag: number,
    ): void => {
      out.push({
        variant: Math.floor(rnd() * VARIANTS),
        kind,
        anchor: sample.local,
        n, t1, t2,
        birth: dist + birthLag + rnd() * 0.12,
        clusterRnd,
        shardIndex,
        shardCountRnd,
        offAz: rnd() * Math.PI * 2,
        offFrac,
        heightBase,
        jitterRnd: rnd(),
        widthRnd: rnd(),
        tiltScale,
        leanRnd: rnd(),
        leanAz: rnd() * Math.PI * 2,
        spin: rnd() * Math.PI * 2,
        hueRnd: rnd(),
        satRnd: rnd(),
        lightRnd: rnd(),
        clearRnd: rnd(),
        visible: true,
        isClear: false,
        pos: new THREE.Vector3(),
        quat: new THREE.Quaternion(),
        scale: new THREE.Vector3(1, 1, 1),
        color: new THREE.Color(),
      });
    };

    // Dominant point — tallest, most upright, born first.
    add('main', -1, 0.15 * rnd(), 1.1 + rnd() * 0.5, 0.55, 0);
    // Shard slots — the supporting ring, culled live by the shards slider.
    for (let k = 0; k < MAX_SHARDS; k++) {
      add('shard', k, 0.25 + rnd() * 0.75, 0.35 + rnd() * 0.4, 1, 0.05 + rnd() * 0.1);
    }
    // Rubble — tiny chips at the skirt that ground the cluster visually.
    const rubble = 2 + Math.floor(rnd() * 3);
    for (let k = 0; k < rubble; k++) {
      add('rubble', -1, 0.6 + rnd() * 0.7, 0.12 + rnd() * 0.12, 1.3, 0.12 + rnd() * 0.15);
    }
  }

  // ----- live settings: re-derive everything in place -----

  applySettings(settings: unknown): void {
    const s = settings as CrystalSettings;
    this.settings = { ...s };
    const palette = PALETTES[s.palette];
    const tintedMat = getMaterial(s.palette, s.glow);
    const clearMat = getClearMaterial(s.glow);
    const footprint = s.crystalSize * s.spread;
    const densityFrac = s.clusterDensity / MAX_DENSITY;

    for (let v = 0; v < VARIANTS; v++) {
      const tMesh = this.tinted[v];
      const cMesh = this.clear[v];
      if (tMesh.material !== tintedMat) tMesh.material = tintedMat;
      if (cMesh.material !== clearMat) cMesh.material = clearMat;

      const list = this.byVariant[v];
      for (let i = 0; i < list.length; i++) {
        const inst = list[i];

        // Visibility: density culls whole clusters; the shards slider culls shard slots.
        const shardCap = Math.round(s.shards * (0.7 + inst.shardCountRnd * 0.6));
        inst.visible =
          inst.clusterRnd <= densityFrac &&
          (inst.kind !== 'shard' || inst.shardIndex < shardCap);

        // Clear-quartz mix: stable rank, so raising the slider converts the same
        // crystals every time instead of reshuffling.
        inst.isClear = inst.clearRnd < s.clearMix;

        // Size (height + independent width), through the jitter slider.
        const jitterMul = 1 - s.sizeJitter * 0.5 + inst.jitterRnd * s.sizeJitter;
        const h = inst.heightBase * s.crystalSize * jitterMul;
        const w = h * (0.8 + inst.widthRnd * 0.45);
        inst.scale.set(w, h, w);

        // Lean direction: surface normal tipped around a stable azimuth.
        const lean = s.tilt * inst.tiltScale * (0.25 + inst.leanRnd * 0.75) * 0.9;
        _dir.copy(inst.n).multiplyScalar(Math.cos(lean))
          .addScaledVector(inst.t1, Math.cos(inst.leanAz) * Math.sin(lean))
          .addScaledVector(inst.t2, Math.sin(inst.leanAz) * Math.sin(lean))
          .normalize();
        _align.setFromUnitVectors(_Y, _dir);
        inst.quat.setFromAxisAngle(_dir, inst.spin).multiply(_align);

        // Position: offset in the tangent plane, base sunk slightly into the surface.
        inst.pos.copy(inst.anchor)
          .addScaledVector(inst.t1, Math.cos(inst.offAz) * inst.offFrac * footprint)
          .addScaledVector(inst.t2, Math.sin(inst.offAz) * inst.offFrac * footprint)
          .addScaledVector(inst.n, -0.05 * h);

        // Tint from the palette + this crystal's stable color randoms.
        inst.color.copy(palette.base);
        inst.color.getHSL(_hsl);
        inst.color.setHSL(
          (_hsl.h + (inst.hueRnd - 0.5) * palette.hueJitter + 1) % 1,
          THREE.MathUtils.clamp(_hsl.s * (1.15 + inst.satRnd * 0.35), 0, 1),
          THREE.MathUtils.clamp(_hsl.l * (0.8 + inst.lightRnd * 0.45), 0, 1),
        );
        tMesh.setColorAt(i, inst.color);
        // Clear slot: near-white with the faintest palette memory, varied per crystal.
        _clearTint.copy(inst.color).lerp(_white, 0.82 + inst.lightRnd * 0.12);
        cMesh.setColorAt(i, _clearTint);
      }
      if (tMesh.instanceColor) tMesh.instanceColor.needsUpdate = true;
      if (cMesh.instanceColor) cMesh.instanceColor.needsUpdate = true;
    }

    // Re-pose every born instance with the new derived values.
    this.done = false;
    this.pose(true);
  }

  // ----- StrokeInstance -----

  update(dt: number, _time: number): void {
    if (this.done) return;
    this.grown += dt * this.settings.growthSpeed;
    this.pose(false);
  }

  finishGrowth(): void {
    this.grown = this.total + GROW_WINDOW + 1;
    this.pose(true);
  }

  /**
   * Recompose matrices for crystals inside the growth window; freeze once all are grown.
   * `force` recomposes every instance (settings changed → even settled ones moved, and a
   * crystal may have flipped between its tinted and clear slot).
   */
  private pose(force: boolean): void {
    let allDone = this.grown >= this.total + GROW_WINDOW + 0.3;
    for (let v = 0; v < VARIANTS; v++) {
      const list = this.byVariant[v];
      const tMesh = this.tinted[v];
      const cMesh = this.clear[v];
      let dirty = force;
      for (let i = 0; i < list.length; i++) {
        const inst = list[i];
        const on = inst.isClear ? cMesh : tMesh;
        const off = inst.isClear ? tMesh : cMesh;
        if (!inst.visible) {
          if (force) {
            on.setMatrixAt(i, _zero);
            off.setMatrixAt(i, _zero);
          }
          continue;
        }
        const t = (this.grown - inst.birth) / GROW_WINDOW;
        if (t <= 0) {
          if (force) {
            on.setMatrixAt(i, _zero);
            off.setMatrixAt(i, _zero);
          }
          allDone = false;
          continue; // still unborn — matrix stays zero
        }
        const k = t >= 1 ? 1 : easeOutBack(t);
        if (t < 1.2 || force) {
          // Crystals emerge slightly narrower than tall, then relax — reads as mineral growth.
          _s.set(inst.scale.x * k * (0.6 + 0.4 * k), inst.scale.y * k, inst.scale.z * k * (0.6 + 0.4 * k));
          _m.compose(inst.pos, inst.quat, _s);
          on.setMatrixAt(i, _m);
          if (force) off.setMatrixAt(i, _zero); // it may have just switched buckets
          dirty = true;
          if (t < 1) allDone = false;
        }
      }
      if (dirty) {
        tMesh.instanceMatrix.needsUpdate = true;
        cMesh.instanceMatrix.needsUpdate = true;
      }
    }
    if (allDone) this.done = true;
  }

  dispose(): void {
    this.group.removeFromParent();
    // Instanced buffers only; geometry + materials are shared across strokes.
    for (const mesh of this.tinted) mesh.dispose();
    for (const mesh of this.clear) mesh.dispose();
  }
}

// ---------- the mode ----------

export const crystalMode: PaintMode<CrystalSettings> = {
  id: 'Crystals',
  createStroke(samples, seed, settings): StrokeInstance {
    return new CrystalStroke(samples, seed, settings);
  },
};
src/modes/fissures.ts
파일 저장

import * as THREE from 'three';
import { MeshBasicNodeMaterial } from 'three/webgpu';
import {
  abs, attribute, float, mix, positionLocal, smoothstep, step, time, uniform, vec3,
} from 'three/tsl';
import { mulberry32, type PaintMode, type StrokeInstance, type SurfaceSample } from './mode';

/* eslint-disable @typescript-eslint/no-explicit-any */
// @types/three loses the node type of attribute() (returns AttributeNode<string>), which
// breaks the fluent TSL API — wrap through float()/vec3() converts to restore typing.
const attrFloat = (name: string) => float(attribute(name, 'float') as any);
const attrVec3 = (name: string) => vec3(attribute(name, 'vec3') as any);
/* eslint-enable @typescript-eslint/no-explicit-any */

/**
 * Molten fissures mode. A stroke tears a glowing crack into the surface: a ribbon of
 * white-hot core light that races along the painted path, flanked by dark basalt lips,
 * breathing with traveling heat pulses, shedding embers, and spilling flickering orange
 * light onto the surface around it.
 *
 * Anatomy of one stroke:
 *  - CORE ribbon      — surface-hugging strip whose color is a blackbody ramp driven by a
 *                       TSL node graph (pulse waves + flicker + a white flash at the
 *                       propagating crack front). Width is a shader uniform → live.
 *  - UNDERGLOW ribbon — the same geometry, ~3× wider, additive — the radiant spill that
 *                       "lights" the surface where point lights can't reach.
 *  - ROCK lips        — instanced basalt chunks along both edges (live-culled like the
 *                       crystal mode), giving the crack physical relief.
 *  - EMBERS           — a small CPU particle pool of glowing motes rising from the melt.
 *  - LIGHT SPILL      — up to 3 flickering point lights along the crack.
 *
 * Every slider is live: widths/heat/pulse are uniforms, rocks re-pose in place, embers and
 * lights read settings at update time. Nothing is rebuilt while dragging.
 */

export interface FissureSettings {
  width: number;        // crack width (world units)
  heat: number;         // core temperature/brightness multiplier
  pulseSpeed: number;   // traveling heat-wave speed
  branchDensity: number; // side branches per world unit (live-culled up to MAX_BRANCHES)
  branchLength: number;  // branch reach (world units, live-tapered up to MAX_BRANCH_LEN)
  emberRate: number;    // embers per second per world unit of open crack
  rockDensity: number;  // lip chunks per world unit (live-culled up to MAX_ROCKS)
  rockSize: number;     // lip chunk size (world units)
  lightSpill: number;   // flickering point-light intensity scale
  growthSpeed: number;  // crack propagation speed (world units / second)
}

export const defaultFissureSettings: FissureSettings = {
  width: 0.055,
  heat: 1.5,
  pulseSpeed: 1,
  branchDensity: 4,
  branchLength: 0.24,
  emberRate: 26,
  rockDensity: 18,
  rockSize: 0.065,
  lightSpill: 1.2,
  growthSpeed: 2.6,
};

/** Rock slots are generated at this density; the slider culls, never rebuilds. */
export const MAX_ROCKS = 30;
/** Branches are generated at these maxima; the sliders cull/taper them in the shader. */
export const MAX_BRANCHES = 8;
export const MAX_BRANCH_LEN = 0.6;

const PATH_STEP = 0.025;     // centerline resample step (world units)
const ROCK_GROW = 0.35;      // stroke-distance window over which a lip chunk pops in
const MAX_EMBERS = 320;      // particle pool per stroke
const SPILL_LIGHTS = 3;

// ---------- shared resources ----------

/** Flattened jagged basalt chunk, flat-shaded. Normalized to ~unit size, base at y=0. */
function makeRockGeometry(rnd: () => number): THREE.BufferGeometry {
  const geo = new THREE.BoxGeometry(1, 0.55, 0.7, 2, 1, 1).toNonIndexed();
  const pos = geo.getAttribute('position') as THREE.BufferAttribute;
  // Jitter shared corners consistently: displace by a hash of the rounded position.
  const seen = new Map<string, [number, number, number]>();
  for (let i = 0; i < pos.count; i++) {
    const key = `${pos.getX(i).toFixed(3)},${pos.getY(i).toFixed(3)},${pos.getZ(i).toFixed(3)}`;
    let d = seen.get(key);
    if (!d) {
      d = [(rnd() - 0.5) * 0.45, (rnd() - 0.5) * 0.3, (rnd() - 0.5) * 0.4];
      seen.set(key, d);
    }
    pos.setXYZ(i, pos.getX(i) + d[0], pos.getY(i) * (0.7 + rnd() * 0.1) + d[1] * 0.5 + 0.25, pos.getZ(i) + d[2]);
  }
  geo.computeVertexNormals();
  return geo;
}

const ROCK_VARIANTS = 4;
let rockGeos: THREE.BufferGeometry[] | null = null;

function getRockGeometries(): THREE.BufferGeometry[] {
  if (!rockGeos) {
    const rnd = mulberry32(0xba5a17);
    rockGeos = Array.from({ length: ROCK_VARIANTS }, () => makeRockGeometry(rnd));
  }
  return rockGeos;
}

let rockMaterial: THREE.MeshStandardMaterial | null = null;

function getRockMaterial(): THREE.MeshStandardMaterial {
  if (!rockMaterial) {
    rockMaterial = new THREE.MeshStandardMaterial({
      color: 0x565056, // multiplied by per-instance charcoal tints → near-black basalt
      roughness: 0.95,
      metalness: 0.02,
      envMapIntensity: 0.15,
    });
  }
  return rockMaterial;
}

/** Shared additive material for the instanced ember quads. */
let emberMaterial: THREE.MeshBasicMaterial | null = null;

function getEmberMaterial(): THREE.MeshBasicMaterial {
  if (!emberMaterial) {
    emberMaterial = new THREE.MeshBasicMaterial({
      map: getEmberTexture(),
      transparent: true,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      side: THREE.DoubleSide,
    });
  }
  return emberMaterial;
}

/** Soft round sprite for the ember points. */
let emberTexture: THREE.CanvasTexture | null = null;

function getEmberTexture(): THREE.CanvasTexture {
  if (!emberTexture) {
    const size = 64;
    const canvas = document.createElement('canvas');
    canvas.width = canvas.height = size;
    const ctx = canvas.getContext('2d')!;
    const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
    g.addColorStop(0, 'rgba(255,255,255,1)');
    g.addColorStop(0.35, 'rgba(255,220,180,0.8)');
    g.addColorStop(1, 'rgba(255,120,40,0)');
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, size, size);
    emberTexture = new THREE.CanvasTexture(canvas);
  }
  return emberTexture;
}

// ---------- path + ribbon geometry ----------

interface PathPoint {
  pos: THREE.Vector3;    // on-surface centerline point (anchor space)
  normal: THREE.Vector3;
  side: THREE.Vector3;   // tangent × normal — the ribbon's across direction
  dist: number;          // distance along the stroke (branches: origin dist + walked)
  walked: number;        // distance walked from the branch origin (0 on the main crack)
  maxWalk: number;       // this branch's full generated length (1 on the main crack)
  rank: number;          // branch culling rank (0 on the main crack → never culled)
}

/** Resample the painted samples into an even centerline with a stable tangent frame. */
function buildPath(samples: SurfaceSample[]): PathPoint[] {
  const pts: PathPoint[] = [];
  let travelled = 0;
  let next = 0;
  const tangent = new THREE.Vector3();
  for (let i = 0; i < samples.length; i++) {
    if (i > 0) travelled += samples[i].local.distanceTo(samples[i - 1].local);
    if (travelled < next && i !== samples.length - 1) continue;
    next = travelled + PATH_STEP;
    const a = samples[Math.max(i - 1, 0)];
    const b = samples[Math.min(i + 1, samples.length - 1)];
    tangent.subVectors(b.local, a.local);
    if (tangent.lengthSq() < 1e-8) tangent.set(1, 0, 0);
    tangent.normalize();
    const normal = samples[i].localNormal.clone().normalize();
    const side = new THREE.Vector3().crossVectors(tangent, normal).normalize();
    pts.push({ pos: samples[i].local.clone(), normal, side, dist: travelled, walked: 0, maxWalk: 1, rank: 0 });
  }
  return pts;
}

/**
 * Grow lightning-like side branches off the main crack. Each walks across the surface
 * from a point on the main path, veering and curving, at MAX length — the sliders then
 * cull whole branches (rank vs density) and pull the taper in (walked vs length), both
 * as shader uniforms, so branch controls are live with zero rebuilds.
 *
 * Surface following: positions re-project onto the sphere of radius |origin| around the
 * anchor origin — exact for the sphere canvas, a fair approximation for gentle meshes.
 */
function growBranches(main: PathPoint[], rnd: () => number): PathPoint[][] {
  const branches: PathPoint[][] = [];
  const spacing = 1 / MAX_BRANCHES;
  let next = spacing * (0.3 + rnd() * 0.5);
  let sideSign = rnd() < 0.5 ? 1 : -1;
  const q = new THREE.Quaternion();

  for (const origin of main) {
    if (origin.dist < next) continue;
    next = origin.dist + spacing * (0.7 + rnd() * 0.6);
    sideSign = -sideSign;

    const radius = origin.pos.length();
    const maxWalk = MAX_BRANCH_LEN * (0.45 + rnd() * 0.75);
    const curvature = (rnd() - 0.5) * 3; // radians of veer per unit walked
    const rank = rnd();

    // Launch direction: the main tangent swung 32°–72° to one side around the normal.
    const tangent = new THREE.Vector3().crossVectors(origin.normal, origin.side);
    const dir = tangent.clone().applyQuaternion(
      q.setFromAxisAngle(origin.normal, sideSign * (0.55 + rnd() * 0.7)),
    );

    const pts: PathPoint[] = [];
    const pos = origin.pos.clone();
    const normal = origin.normal.clone();
    for (let walked = 0; walked <= maxWalk; walked += PATH_STEP) {
      pts.push({
        pos: pos.clone(),
        normal: normal.clone(),
        side: new THREE.Vector3().crossVectors(dir, normal).normalize(),
        dist: origin.dist + walked,
        walked,
        maxWalk,
        rank,
      });
      // Step, re-project to the surface, re-orthogonalize and veer the direction.
      pos.addScaledVector(dir, PATH_STEP);
      if (radius > 1e-4) pos.setLength(radius);
      normal.copy(pos).normalize();
      dir.addScaledVector(normal, -dir.dot(normal)).normalize();
      dir.applyQuaternion(q.setFromAxisAngle(normal, curvature * PATH_STEP));
    }
    if (pts.length >= 2) branches.push(pts);
  }
  return branches;
}

/**
 * Ribbon geometry for the main crack + all its branches, in ONE indexed mesh. Vertices sit
 * at the CENTERLINE (the across displacement happens in the vertex shader via
 * `aSide × width-uniform × taper`), so crack width, branch density and branch length are
 * all live. The main crack's width jitter is pinched to a point at both stroke ends;
 * branches carry `aWalk`/`aMaxWalk`/`aRank` for the shader-side taper and culling.
 */
function buildRibbonGeometry(
  segments: PathPoint[][],
  total: number,
  rnd: () => number,
): THREE.BufferGeometry {
  const positions: number[] = [];
  const sides: number[] = [];
  const across: number[] = [];
  const dists: number[] = [];
  const jitters: number[] = [];
  const walks: number[] = [];
  const maxWalks: number[] = [];
  const ranks: number[] = [];
  const indices: number[] = [];

  for (const path of segments) {
    const base = positions.length / 3;
    const isBranch = path[0].rank > 0;
    let jit = 1;
    for (let i = 0; i < path.length; i++) {
      const p = path[i];
      // Smoothed random walk → organic width variation baked per point.
      jit = THREE.MathUtils.clamp(jit + (rnd() - 0.5) * 0.35, 0.6, 1.45);
      // Main crack: pinch to a TRUE zero-width point over the last 0.18 units at both
      // ends — a crack terminates in a spike, not a rounded cap. The 0.65 exponent keeps
      // the point long and needle-like instead of a linear wedge.
      // Branches: narrower than the main crack; their tip taper is dynamic (shader).
      let w = jit;
      if (isBranch) w *= 0.62;
      else w *= Math.pow(THREE.MathUtils.clamp(Math.min(p.dist, total - p.dist) / 0.18, 0, 1), 0.65);
      for (let k = 0; k < 2; k++) {
        positions.push(p.pos.x + p.normal.x * 0.006, p.pos.y + p.normal.y * 0.006, p.pos.z + p.normal.z * 0.006);
        sides.push(p.side.x, p.side.y, p.side.z);
        across.push(k === 0 ? -1 : 1);
        dists.push(p.dist);
        jitters.push(w);
        walks.push(p.walked);
        maxWalks.push(p.maxWalk);
        ranks.push(p.rank);
      }
    }
    for (let i = 0; i < path.length - 1; i++) {
      const a = base + i * 2;
      indices.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
    }
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  geo.setAttribute('aSide', new THREE.Float32BufferAttribute(sides, 3));
  geo.setAttribute('aAcross', new THREE.Float32BufferAttribute(across, 1));
  geo.setAttribute('aDist', new THREE.Float32BufferAttribute(dists, 1));
  geo.setAttribute('aJit', new THREE.Float32BufferAttribute(jitters, 1));
  geo.setAttribute('aWalk', new THREE.Float32BufferAttribute(walks, 1));
  geo.setAttribute('aMaxWalk', new THREE.Float32BufferAttribute(maxWalks, 1));
  geo.setAttribute('aRank', new THREE.Float32BufferAttribute(ranks, 1));
  geo.setIndex(indices);
  return geo;
}

// ---------- per-stroke rock instances ----------

interface RockInstance {
  variant: number;
  anchor: THREE.Vector3;
  n: THREE.Vector3;
  side: THREE.Vector3;  // signed: which lip of the crack it sits on
  tangent: THREE.Vector3;
  birth: number;
  cullRnd: number;      // density culling rank
  offRnd: number;       // how far outside the crack edge
  yaw: number;
  sizeRnd: number;
  flatRnd: number;      // height squash
  tint: number;         // 0..1 charcoal variation
  visible: boolean;
  pos: THREE.Vector3;
  quat: THREE.Quaternion;
  scale: THREE.Vector3;
}

const _m = new THREE.Matrix4();
const _s = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _basis = new THREE.Matrix4();
const _zero = new THREE.Matrix4().makeScale(0, 0, 0);
const _color = new THREE.Color();

function easeOutBack(t: number): number {
  const c1 = 1.20158;
  const c3 = c1 + 1;
  const u = t - 1;
  return 1 + c3 * u * u * u + c1 * u * u;
}

// ---------- ember particles ----------

interface Ember {
  alive: boolean;
  pos: THREE.Vector3;
  vel: THREE.Vector3;
  quat: THREE.Quaternion; // random fixed facing — reads as a spark, no billboarding needed
  size: number;
  life: number;
  maxLife: number;
  heat: number; // 0..1 — how white it starts
}

// ---------- the stroke ----------

class FissureStroke implements StrokeInstance {
  readonly group = new THREE.Group();

  private settings: FissureSettings;
  private path: PathPoint[];       // main crack only (lights, rocks)
  private allPts: PathPoint[];     // main + branches (ember spawning)
  private readonly total: number;
  private grown = 0;
  private rocksDone = false;

  // shader uniforms (live sliders)
  private uGrown = uniform(0);
  private uWidth = uniform(0.05);
  private uGlowWidth = uniform(0.16);
  private uHeat = uniform(1);
  private uPulse = uniform(1);
  private uBranchFrac = uniform(0.5); // branchDensity / MAX_BRANCHES
  private uLenFrac = uniform(0.4);    // branchLength / MAX_BRANCH_LEN
  private uTotal = uniform(1);        // main crack length, for the tip light fade

  private ribbonGeo!: THREE.BufferGeometry;
  private coreMat!: MeshBasicNodeMaterial;
  private underMat!: MeshBasicNodeMaterial;
  private rockMeshes: THREE.InstancedMesh[] = [];
  private rocksByVariant: RockInstance[][];

  private embers: Ember[] = [];
  private emberMesh: THREE.InstancedMesh;
  private emberSpawnDebt = 0;

  private lights: { light: THREE.PointLight; dist: number; phase: number }[] = [];

  constructor(samples: SurfaceSample[], seed: number, settings: FissureSettings) {
    this.settings = { ...settings };
    const rnd = mulberry32(seed);
    this.path = buildPath(samples);
    this.total = this.path.length ? this.path[this.path.length - 1].dist : 0;
    this.uTotal.value = Math.max(this.total, 1e-3);
    const branches = growBranches(this.path, rnd);
    this.allPts = [...this.path, ...branches.flat()];

    // ----- ribbons (one geometry: main + branches, two node materials) -----
    this.ribbonGeo = buildRibbonGeometry([this.path, ...branches], this.total, rnd);

    this.coreMat = new MeshBasicNodeMaterial();
    this.coreMat.transparent = true;
    this.coreMat.depthWrite = false;
    // Additive: where two fissures (or a branch and its parent) cross, their light SUMS
    // into a hotter junction instead of one crack's edge painting over the other.
    this.coreMat.blending = THREE.AdditiveBlending;
    this.buildCoreNodes(this.coreMat);
    const coreMesh = new THREE.Mesh(this.ribbonGeo, this.coreMat);
    coreMesh.renderOrder = 2;
    coreMesh.frustumCulled = false;

    this.underMat = new MeshBasicNodeMaterial();
    this.underMat.transparent = true;
    this.underMat.depthWrite = false;
    this.underMat.blending = THREE.AdditiveBlending;
    this.buildUnderglowNodes(this.underMat);
    const underMesh = new THREE.Mesh(this.ribbonGeo, this.underMat);
    underMesh.renderOrder = 1;
    underMesh.frustumCulled = false;

    this.group.add(underMesh, coreMesh);

    // ----- rock lips -----
    this.rocksByVariant = Array.from({ length: ROCK_VARIANTS }, () => []);
    this.scatterRocks(rnd);
    const geos = getRockGeometries();
    const rockMat = getRockMaterial();
    for (let v = 0; v < ROCK_VARIANTS; v++) {
      const list = this.rocksByVariant[v];
      const mesh = new THREE.InstancedMesh(geos[v], rockMat, Math.max(list.length, 1));
      mesh.castShadow = true;
      mesh.receiveShadow = true;
      mesh.frustumCulled = false;
      for (let i = 0; i < list.length; i++) {
        mesh.setMatrixAt(i, _zero);
        _color.setHSL(0.06 + list[i].tint * 0.02, 0.08, 0.045 + list[i].tint * 0.03);
        mesh.setColorAt(i, _color);
      }
      mesh.count = list.length;
      mesh.instanceMatrix.needsUpdate = true;
      if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
      this.rockMeshes.push(mesh);
      this.group.add(mesh);
    }

    // ----- embers -----
    // Instanced quads, NOT Points: WebGPU point primitives are always 1px, so a
    // PointsMaterial ember would be invisible. Random fixed facings read fine as sparks.
    for (let i = 0; i < MAX_EMBERS; i++) {
      this.embers.push({
        alive: false,
        pos: new THREE.Vector3(),
        vel: new THREE.Vector3(),
        quat: new THREE.Quaternion(),
        size: 0.02,
        life: 0,
        maxLife: 1,
        heat: 0,
      });
    }
    this.emberMesh = new THREE.InstancedMesh(new THREE.PlaneGeometry(1, 1), getEmberMaterial(), MAX_EMBERS);
    for (let i = 0; i < MAX_EMBERS; i++) {
      this.emberMesh.setMatrixAt(i, _zero);
      this.emberMesh.setColorAt(i, _color.setRGB(0, 0, 0));
    }
    this.emberMesh.renderOrder = 3;
    this.emberMesh.frustumCulled = false;
    this.group.add(this.emberMesh);

    // ----- light spill -----
    const nLights = Math.min(SPILL_LIGHTS, Math.max(1, Math.round(this.total * 1.2)));
    for (let i = 0; i < nLights; i++) {
      const f = nLights === 1 ? 0.5 : 0.12 + (0.76 * i) / (nLights - 1);
      const p = this.pathAt(this.total * f);
      const light = new THREE.PointLight(0xff7030, 0, 1.5, 2);
      light.position.copy(p.pos).addScaledVector(p.normal, 0.07);
      this.group.add(light);
      this.lights.push({ light, dist: this.total * f, phase: rnd() * 20 });
    }

    this.applySettings(settings);
  }

  /**
   * Branch culling + tip shaping, computed in the shader so the sliders stay live:
   *  - `sel` — 1 while a branch's rank is under the density fraction (main crack rank=0,
   *    so it always survives); culled branches collapse to zero width.
   *  - `taper` — pinches a branch to a point at `branchLength`, wherever the slider is.
   *  - `tip` — dims the LIGHT into the main crack's needle points, so the glow dies into
   *    the spike instead of haloing it into a rounded cap. Branches are exempt (their
   *    aDist can exceed the main length) — their own taper already cools their tips.
   */
  // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- inferred TSL node types
  private branchFactors() {
    const aWalk = attrFloat('aWalk');
    const aMaxWalk = attrFloat('aMaxWalk');
    const aRank = attrFloat('aRank');
    const aDist = attrFloat('aDist');
    const sel = step(aRank, this.uBranchFrac);
    const taper = float(1)
      .sub(aWalk.div(aMaxWalk.mul(this.uLenFrac).add(1e-4)))
      .clamp(0, 1)
      .pow(0.7);
    const isBranch = step(1e-5, aRank);
    const tip = mix(
      smoothstep(0.0, 0.16, aDist.min(this.uTotal.sub(aDist))),
      float(1),
      isBranch,
    );
    return { sel, taper, tip };
  }

  /** Blackbody-ish core: dark seam → deep red → orange → white-hot, pulsing along its length. */
  private buildCoreNodes(mat: MeshBasicNodeMaterial): void {
    const aAcross = attrFloat('aAcross');
    const aDist = attrFloat('aDist');
    const aJit = attrFloat('aJit');
    const aSide = attrVec3('aSide');
    const { sel, taper, tip } = this.branchFactors();

    mat.positionNode = positionLocal.add(
      aSide.mul(this.uWidth.mul(0.5).mul(aAcross).mul(aJit)).mul(taper.mul(sel)),
    );

    const openness = smoothstep(0.0, 0.1, this.uGrown.sub(aDist));
    const center = smoothstep(0.12, 1.0, abs(aAcross)).oneMinus();
    const pulse = aDist.mul(7).sub(time.mul(this.uPulse.mul(2.6))).sin().mul(0.28).add(0.72);
    const flicker = time.mul(9).add(aDist.mul(41)).sin().mul(0.08).add(0.94);
    // White flash at the racing crack front (also dimmed into the tips).
    const flash = smoothstep(0.0, 0.22, abs(this.uGrown.sub(aDist))).oneMinus().mul(1.6).mul(tip);
    // Branches run cooler toward their tips; the main crack's light dies into its points.
    const heat = center.mul(pulse).mul(flicker).mul(this.uHeat)
      .mul(taper.mul(0.35).add(0.65))
      .mul(tip.mul(0.85).add(0.15))
      .add(flash);

    const cSeam = vec3(0.02, 0.004, 0.002);
    const cRed = vec3(1.1, 0.1, 0.01);
    const cOrange = vec3(2.6, 0.85, 0.1);
    const cWhite = vec3(4.6, 3.6, 2.4);
    let color = mix(cSeam, cRed, smoothstep(0.0, 0.55, heat));
    color = mix(color, cOrange, smoothstep(0.55, 1.15, heat));
    color = mix(color, cWhite, smoothstep(1.15, 2.1, heat));
    mat.colorNode = color;

    const edge = smoothstep(0.82, 1.0, abs(aAcross)).oneMinus();
    mat.opacityNode = openness.mul(edge).mul(sel);
  }

  /** The wide additive halo that paints radiant orange onto the surrounding surface. */
  private buildUnderglowNodes(mat: MeshBasicNodeMaterial): void {
    const aAcross = attrFloat('aAcross');
    const aDist = attrFloat('aDist');
    const aJit = attrFloat('aJit');
    const aSide = attrVec3('aSide');
    const { sel, taper, tip } = this.branchFactors();

    mat.positionNode = positionLocal.add(
      aSide.mul(this.uGlowWidth.mul(0.5).mul(aAcross).mul(aJit)).mul(taper.mul(sel)),
    );

    const openness = smoothstep(0.0, 0.18, this.uGrown.sub(aDist));
    const falloff = abs(aAcross).oneMinus().max(0).pow(1.6);
    const pulse = aDist.mul(7).sub(time.mul(this.uPulse.mul(2.6))).sin().mul(0.22).add(0.78);
    // The halo fades out entirely at the tips — a glow blob past the point would read as
    // a rounded end and undo the spike.
    const strength = falloff.mul(pulse).mul(this.uHeat).mul(taper.mul(0.5).add(0.5)).mul(tip).mul(0.34);
    mat.colorNode = vec3(1.5, 0.38, 0.05).mul(strength);
    mat.opacityNode = openness.mul(sel);
  }

  // ----- rocks -----

  private scatterRocks(rnd: () => number): void {
    const step = 1 / MAX_ROCKS;
    let next = step * 0.5;
    let flip = 1;
    for (const p of this.path) {
      if (p.dist < next) continue;
      next = p.dist + step * (0.8 + rnd() * 0.4);
      flip = -flip;
      this.rocksByVariant[Math.floor(rnd() * ROCK_VARIANTS)].push({
        variant: 0, // (bucketed already; kept for symmetry)
        anchor: p.pos,
        n: p.normal,
        side: p.side.clone().multiplyScalar(flip),
        tangent: new THREE.Vector3().crossVectors(p.normal, p.side),
        birth: p.dist + rnd() * 0.08,
        cullRnd: rnd(),
        offRnd: rnd(),
        yaw: (rnd() - 0.5) * 0.9,
        sizeRnd: rnd(),
        flatRnd: 0.6 + rnd() * 0.6,
        tint: rnd(),
        visible: true,
        pos: new THREE.Vector3(),
        quat: new THREE.Quaternion(),
        scale: new THREE.Vector3(1, 1, 1),
      });
    }
  }

  // ----- live settings -----

  applySettings(settings: unknown): void {
    const s = settings as FissureSettings;
    this.settings = { ...s };
    this.uWidth.value = s.width;
    this.uGlowWidth.value = s.width * 3.4 + 0.05;
    this.uHeat.value = s.heat;
    this.uPulse.value = s.pulseSpeed;
    this.uBranchFrac.value = s.branchDensity / MAX_BRANCHES;
    this.uLenFrac.value = s.branchLength / MAX_BRANCH_LEN;

    const densityFrac = s.rockDensity / MAX_ROCKS;
    for (let v = 0; v < ROCK_VARIANTS; v++) {
      const list = this.rocksByVariant[v];
      for (const r of list) {
        r.visible = r.cullRnd <= densityFrac;
        const size = s.rockSize * (0.55 + r.sizeRnd * 0.9);
        r.scale.set(size, size * r.flatRnd, size * 0.8);
        // Sit just outside the crack edge, sunk well into the surface so only the top
        // ridge of each chunk breaks through — broken crust, not scattered pebbles.
        r.pos.copy(r.anchor)
          .addScaledVector(r.side, s.width * 0.55 + r.offRnd * s.width * 0.6 + size * 0.15)
          .addScaledVector(r.n, -0.3 * size * r.flatRnd);
        // Long axis along the crack, random yaw, slight outward roll.
        _basis.makeBasis(r.tangent, r.n, new THREE.Vector3().crossVectors(r.tangent, r.n));
        r.quat.setFromRotationMatrix(_basis);
        _q.setFromAxisAngle(r.n, r.yaw);
        r.quat.premultiply(_q);
        _q.setFromAxisAngle(r.tangent, (r.offRnd - 0.5) * 0.35);
        r.quat.premultiply(_q);
      }
    }
    this.rocksDone = false;
    this.poseRocks(true);
  }

  // ----- StrokeInstance -----

  update(dt: number, t: number): void {
    if (this.grown < this.total + ROCK_GROW + 0.4) {
      this.grown += dt * this.settings.growthSpeed;
      this.uGrown.value = this.grown;
    }
    if (!this.rocksDone) this.poseRocks(false);
    this.updateEmbers(dt);
    this.updateLights(t);
  }

  finishGrowth(): void {
    this.grown = this.total + ROCK_GROW + 1;
    this.uGrown.value = this.grown;
    this.poseRocks(true);
  }

  private poseRocks(force: boolean): void {
    let allDone = this.grown >= this.total + ROCK_GROW + 0.3;
    for (let v = 0; v < ROCK_VARIANTS; v++) {
      const list = this.rocksByVariant[v];
      const mesh = this.rockMeshes[v];
      let dirty = force;
      for (let i = 0; i < list.length; i++) {
        const r = list[i];
        if (!r.visible) {
          if (force) mesh.setMatrixAt(i, _zero);
          continue;
        }
        const t = (this.grown - r.birth) / ROCK_GROW;
        if (t <= 0) {
          if (force) mesh.setMatrixAt(i, _zero);
          allDone = false;
          continue;
        }
        const k = t >= 1 ? 1 : easeOutBack(t);
        if (t < 1.2 || force) {
          _s.copy(r.scale).multiplyScalar(k);
          _m.compose(r.pos, r.quat, _s);
          mesh.setMatrixAt(i, _m);
          dirty = true;
          if (t < 1) allDone = false;
        }
      }
      if (dirty) mesh.instanceMatrix.needsUpdate = true;
    }
    if (allDone) this.rocksDone = true;
  }

  // ----- embers -----

  private pathAt(dist: number): PathPoint {
    const i = THREE.MathUtils.clamp(Math.round(dist / PATH_STEP), 0, this.path.length - 1);
    return this.path[i];
  }

  private updateEmbers(dt: number): void {
    const open = Math.min(this.grown, this.total);
    if (open > 0.01) {
      this.emberSpawnDebt += dt * this.settings.emberRate * open;
      while (this.emberSpawnDebt >= 1) {
        this.emberSpawnDebt -= 1;
        const e = this.embers.find((x) => !x.alive);
        if (!e) break;
        // Spawn anywhere on the network — main crack or a LIVE part of a branch
        // (respecting the current density/length sliders and the growth front).
        const p = this.allPts[Math.floor(Math.random() * this.allPts.length)];
        if (
          p.dist > this.grown ||
          p.rank > this.settings.branchDensity / MAX_BRANCHES ||
          p.walked > p.maxWalk * (this.settings.branchLength / MAX_BRANCH_LEN)
        ) continue;
        e.alive = true;
        e.pos.copy(p.pos)
          .addScaledVector(p.side, (Math.random() - 0.5) * this.settings.width * 0.7)
          .addScaledVector(p.normal, 0.01);
        e.vel.copy(p.normal).multiplyScalar(0.16 + Math.random() * 0.2)
          .addScaledVector(p.side, (Math.random() - 0.5) * 0.1);
        e.quat.setFromEuler(new THREE.Euler(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI));
        e.size = 0.016 + Math.random() * 0.02;
        e.maxLife = 0.8 + Math.random() * 1.4;
        e.life = e.maxLife;
        e.heat = Math.random();
      }
    }

    for (let i = 0; i < this.embers.length; i++) {
      const e = this.embers[i];
      if (!e.alive) continue;
      e.life -= dt;
      if (e.life <= 0) {
        e.alive = false;
        this.emberMesh.setMatrixAt(i, _zero);
        continue;
      }
      // Rise, slow down, wander.
      e.vel.multiplyScalar(1 - dt * 0.6);
      e.pos.addScaledVector(e.vel, dt);
      e.pos.x += Math.sin(e.life * 7 + i) * dt * 0.02;
      e.pos.z += Math.cos(e.life * 6 + i * 1.7) * dt * 0.02;

      const f = e.life / e.maxLife;                       // 1 → 0
      _s.setScalar(e.size * (0.5 + f * 0.5));
      _m.compose(e.pos, e.quat, _s);
      this.emberMesh.setMatrixAt(i, _m);
      const b = f * f * (0.9 + e.heat * 0.7);             // brightness decay
      this.emberMesh.setColorAt(i, _color.setRGB(b * 1.5, b * (0.4 + e.heat * 0.5), b * 0.14));
    }
    this.emberMesh.instanceMatrix.needsUpdate = true;
    if (this.emberMesh.instanceColor) this.emberMesh.instanceColor.needsUpdate = true;
  }

  private updateLights(t: number): void {
    for (const { light, dist, phase } of this.lights) {
      if (this.grown <= dist) {
        light.intensity = 0;
        continue;
      }
      const ignite = THREE.MathUtils.clamp((this.grown - dist) / 0.4, 0, 1);
      const flicker = 0.78 + 0.16 * Math.sin(t * 13 + phase) + 0.06 * Math.sin(t * 31 + phase * 2.3);
      light.intensity = this.settings.lightSpill * 1.6 * ignite * flicker;
    }
  }

  dispose(): void {
    this.group.removeFromParent();
    // Ribbon geometry + node materials are per-stroke (their uniforms are).
    this.ribbonGeo.dispose();
    this.coreMat.dispose();
    this.underMat.dispose();
    this.emberMesh.geometry.dispose();
    this.emberMesh.dispose(); // material is shared
    // Rock geometries + material are shared across strokes — only drop instance buffers.
    for (const mesh of this.rockMeshes) mesh.dispose();
  }
}

// ---------- the mode ----------

export const fissureMode: PaintMode<FissureSettings> = {
  id: 'Molten fissures',
  createStroke(samples, seed, settings): StrokeInstance {
    return new FissureStroke(samples, seed, settings);
  },
};
src/modes/mode.ts
파일 저장

import * as THREE from 'three';

/**
 * The mode system: Geometry Painter is a collection of painting modes (crystals today;
 * coral, circuitry, feathers, ... tomorrow). Every mode consumes the same surface strokes
 * and returns a living StrokeInstance the app animates and manages uniformly, so adding a
 * mode never touches the painting/orbit/undo plumbing.
 */

export interface SurfaceSample {
  /** World-space hit — used only for the live stroke preview beads. */
  position: THREE.Vector3;
  normal: THREE.Vector3;
  /**
   * Anchor-space hit, captured at pick time. The canvas sphere floats (bobs and slowly
   * turns), so converting per-sample while painting keeps the stroke pinned to the surface
   * instead of smearing. Painted geometry is parented under the same anchor and rides along.
   */
  local: THREE.Vector3;
  localNormal: THREE.Vector3;
}

/** One painted stroke, alive in the scene: it grows in, animates, and can be disposed. */
export interface StrokeInstance {
  group: THREE.Group;
  /** Advance growth / idle animation. `time` is seconds since app start. */
  update(dt: number, time: number): void;
  /** Snap to fully grown (used when settings change and strokes rebuild in place). */
  finishGrowth(): void;
  /**
   * Re-derive the stroke's look from new settings IN PLACE (no dispose/recreate) —
   * matrices and colors update on the existing instanced meshes. Modes that can't
   * do this omit it and the app falls back to a rebuild.
   */
  applySettings?(settings: unknown): void;
  dispose(): void;
}

export interface PaintMode<S = unknown> {
  readonly id: string;
  /** Build the living geometry for one stroke. Samples are in anchor-local space. */
  createStroke(samples: SurfaceSample[], seed: number, settings: S): StrokeInstance;
}

/** Deterministic per-stroke RNG (mulberry32) shared by all modes. */
export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
src/modes/reef.ts
파일 저장

import * as THREE from 'three';
import { MeshBasicNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu';
import {
  float, hash, instanceIndex, mix, positionLocal, positionWorld, texture, time, uniform, vec3,
} from 'three/tsl';
import { mulberry32, type PaintMode, type StrokeInstance, type SurfaceSample } from './mode';

/**
 * Bioluminescent reef mode. A stroke seeds a living deep-sea colony along the painted
 * path — and then the colony BREATHES:
 *
 *  - CORAL TREES  — recursively branched staghorn colonies built from instanced knobbly
 *    tapered segments. Dark bodies, so the light show owns the frame.
 *  - POLYP TIPS   — a glowing bud at every branch end. Their brightness rides a traveling
 *    pulse wave computed from WORLD position, so bioluminescence ripples across the whole
 *    reef — even across separate strokes — like a signal passing through one organism.
 *    Each polyp also blinks slightly off-phase (hash(instanceIndex)).
 *  - ANEMONES     — clusters of thin tendrils bending in a procedural current (vertex
 *    sway, zero CPU), glow gradients running to their tips on the same colony pulse.
 *  - SEA FANS     — canvas-drawn gorgonian lattices with glowing veins, swaying slowly.
 *  - PLANKTON     — a drifting field of twinkling sparkles around the colony.
 *  - LIGHT SPILL  — teal point lights breathing with slow tides.
 *
 * Live controls follow the house rules: glow/pulse/sway are global shader uniforms;
 * branching depth, density, tendrils and plankton cull generated-at-max instances;
 * colony size re-poses matrices in place. Nothing rebuilds while you drag.
 */

export type ReefPaletteName = 'Abyss' | 'Tropic' | 'Ghost' | 'Toxic';

export interface ReefSettings {
  palette: ReefPaletteName;
  colonySize: number;  // coral tree scale (world units)
  density: number;     // colony clusters per world unit (live-culled up to MAX_DENSITY)
  branching: number;   // 0..1 — how many branch generations survive (live depth cull)
  tendrils: number;    // anemone tendrils per cluster (live-culled up to MAX_TENDRILS)
  glow: number;        // bioluminescence intensity
  pulseSpeed: number;  // traveling colony-pulse speed
  sway: number;        // water current
  plankton: number;    // drifting sparkles (live-culled up to MAX_PLANKTON)
  lightSpill: number;
  growthSpeed: number; // colony sprout speed (world units / second)
}

export const defaultReefSettings: ReefSettings = {
  palette: 'Abyss',
  colonySize: 0.19,
  density: 10,
  branching: 0.85,
  tendrils: 9,
  glow: 1.2,
  pulseSpeed: 1,
  sway: 0.5,
  plankton: 150,
  lightSpill: 1,
  growthSpeed: 1.1,
};

export const MAX_DENSITY = 14;
export const MAX_TENDRILS = 14;
export const MAX_PLANKTON = 220;
const MAX_DEPTH = 3; // branch generations generated; the slider culls them live

interface ReefPalette {
  bodyA: THREE.Color; // coral flesh (dark)
  bodyB: THREE.Color;
  glowA: THREE.Color; // polyp light
  glowB: THREE.Color;
}

const PALETTES: Record<ReefPaletteName, ReefPalette> = {
  Abyss: {
    bodyA: new THREE.Color(0x241a3e),
    bodyB: new THREE.Color(0x3a1f4e),
    glowA: new THREE.Color(0x2ee6d6),
    glowB: new THREE.Color(0x4e8aff),
  },
  Tropic: {
    bodyA: new THREE.Color(0x4e1230),
    bodyB: new THREE.Color(0x6e1a2a),
    glowA: new THREE.Color(0x33ffa8),
    glowB: new THREE.Color(0xff5ea8),
  },
  Ghost: {
    bodyA: new THREE.Color(0x2a3140),
    bodyB: new THREE.Color(0x3a4456),
    glowA: new THREE.Color(0xbfe8ff),
    glowB: new THREE.Color(0x7fb0ff),
  },
  Toxic: {
    bodyA: new THREE.Color(0x14301a),
    bodyB: new THREE.Color(0x1f4020),
    glowA: new THREE.Color(0x8aff2e),
    glowB: new THREE.Color(0xe6ff4e),
  },
};

// ---------- global (mode-wide) uniforms ----------

const uGlow = uniform(1);
const uPulse = uniform(1);
const uSway = uniform(0.5);
const uGlowA = uniform(new THREE.Color(0x2ee6d6));
const uGlowB = uniform(new THREE.Color(0x4e8aff));

/* eslint-disable @typescript-eslint/no-explicit-any */
const colorVec = (u: unknown) => vec3(u as any);
/* eslint-enable @typescript-eslint/no-explicit-any */

/** Live style setter — palette/glow/pulse/sway are shared by every reef stroke. */
export function setReefStyle(s: ReefSettings): void {
  uGlow.value = s.glow;
  uPulse.value = s.pulseSpeed;
  uSway.value = s.sway;
  const p = PALETTES[s.palette];
  (uGlowA.value as THREE.Color).copy(p.glowA);
  (uGlowB.value as THREE.Color).copy(p.glowB);
}

/** The colony heartbeat: a light wave traveling through world space, shared by polyps,
 *  tendril tips and fan veins so the whole reef pulses as one organism. */
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- inferred TSL node types
function colonyPulse() {
  return positionWorld.dot(vec3(1.6, 1.1, 1.35)).mul(2.6)
    .sub(time.mul(uPulse.mul(2.1)))
    .sin().mul(0.5).add(0.5).pow(2.5);
}

// ---------- shared geometries ----------

let coralGeo: THREE.BufferGeometry | null = null;

/** One knobbly tapered branch segment, base at y=0, unit length. */
function getCoralGeometry(): THREE.BufferGeometry {
  if (!coralGeo) {
    const rnd = mulberry32(0xc0a71);
    const geo = new THREE.CylinderGeometry(0.55, 1, 1, 6, 3).toNonIndexed();
    geo.translate(0, 0.5, 0);
    const pos = geo.getAttribute('position') as THREE.BufferAttribute;
    const seen = new Map<string, [number, number, number]>();
    for (let i = 0; i < pos.count; i++) {
      const key = `${pos.getX(i).toFixed(3)},${pos.getY(i).toFixed(3)},${pos.getZ(i).toFixed(3)}`;
      let d = seen.get(key);
      if (!d) {
        d = [(rnd() - 0.5) * 0.3, (rnd() - 0.5) * 0.12, (rnd() - 0.5) * 0.3];
        seen.set(key, d);
      }
      pos.setXYZ(i, pos.getX(i) * (1 + d[0]), pos.getY(i) + d[1] * 0.3, pos.getZ(i) * (1 + d[2]));
    }
    geo.computeVertexNormals();
    coralGeo = geo;
  }
  return coralGeo;
}

let tipGeo: THREE.BufferGeometry | null = null;

function getTipGeometry(): THREE.BufferGeometry {
  if (!tipGeo) tipGeo = new THREE.IcosahedronGeometry(1, 1);
  return tipGeo;
}

let tendrilGeo: THREE.BufferGeometry | null = null;

/** A thin tapering tendril with enough height segments to bend smoothly in the shader. */
function getTendrilGeometry(): THREE.BufferGeometry {
  if (!tendrilGeo) {
    const geo = new THREE.CylinderGeometry(0.06, 1, 1, 5, 6);
    geo.translate(0, 0.5, 0);
    tendrilGeo = geo;
  }
  return tendrilGeo;
}

let fanGeo: THREE.BufferGeometry | null = null;

function getFanGeometry(): THREE.BufferGeometry {
  if (!fanGeo) {
    const geo = new THREE.PlaneGeometry(1.4, 1, 6, 6);
    geo.translate(0, 0.5, 0); // rooted at the base
    fanGeo = geo;
  }
  return fanGeo;
}

// ---------- sea-fan texture: gorgonian lattice, veins bright, membrane faint ----------

function drawFanTexture(): THREE.CanvasTexture {
  const W = 256;
  const H = 256;
  const canvas = document.createElement('canvas');
  canvas.width = W;
  canvas.height = H;
  const ctx = canvas.getContext('2d')!;
  const rnd = mulberry32(0x5eafa);

  // Faint membrane silhouette (a ragged fan) in low alpha.
  ctx.fillStyle = 'rgba(70,70,70,0.28)';
  ctx.beginPath();
  ctx.moveTo(128, 252);
  ctx.bezierCurveTo(20, 210, 4, 120, 30, 40);
  ctx.bezierCurveTo(80, 8, 176, 8, 226, 40);
  ctx.bezierCurveTo(252, 120, 236, 210, 128, 252);
  ctx.closePath();
  ctx.fill();

  // Branching veins: recursive forks from the root, drawn bright (they carry the glow).
  const vein = (x: number, y: number, ang: number, len: number, w: number, depth: number): void => {
    if (depth > 4 || len < 8) return;
    const nx = x + Math.cos(ang) * len;
    const ny = y - Math.sin(ang) * len;
    ctx.strokeStyle = `rgba(235,235,235,${0.95 - depth * 0.12})`;
    ctx.lineWidth = w;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(nx, ny);
    ctx.stroke();
    const kids = depth < 2 ? 3 : 2;
    for (let i = 0; i < kids; i++) {
      vein(nx, ny, ang + (rnd() - 0.5) * 1.1, len * (0.62 + rnd() * 0.2), Math.max(w * 0.62, 0.8), depth + 1);
    }
  };
  for (let i = 0; i < 5; i++) {
    vein(128, 252, Math.PI / 2 + (i - 2) * 0.42 + (rnd() - 0.5) * 0.2, 60 + rnd() * 26, 3.2, 0);
  }

  const tex = new THREE.CanvasTexture(canvas);
  tex.anisotropy = 4;
  return tex;
}

// ---------- shared materials ----------

let coralMaterial: THREE.MeshStandardMaterial | null = null;

function getCoralMaterial(): THREE.MeshStandardMaterial {
  if (!coralMaterial) {
    coralMaterial = new THREE.MeshStandardMaterial({
      color: 0xffffff, // per-instance body tints
      roughness: 0.85,
      metalness: 0.05,
      envMapIntensity: 0.4,
    });
  }
  return coralMaterial;
}

let tipMaterial: MeshBasicNodeMaterial | null = null;

/** Polyp buds: HDR-bright on the pulse crest, ember-dim in the troughs → bloom does the rest. */
function getTipMaterial(): MeshBasicNodeMaterial {
  if (!tipMaterial) {
    const mat = new MeshBasicNodeMaterial();
    const blink = time.mul(0.8).add(hash(instanceIndex).mul(6.283)).sin().mul(0.15).add(0.85);
    const c = mix(colorVec(uGlowA), colorVec(uGlowB), hash(instanceIndex.add(9)));
    mat.colorNode = c.mul(colonyPulse().mul(2.6).add(0.2)).mul(blink).mul(uGlow);
    tipMaterial = mat;
  }
  return tipMaterial;
}

let tendrilMaterial: MeshStandardNodeMaterial | null = null;

/** Anemone arms: dark flesh, glow gradient to the tip, bending in the current. */
function getTendrilMaterial(): MeshStandardNodeMaterial {
  if (!tendrilMaterial) {
    const mat = new MeshStandardNodeMaterial();
    mat.roughness = 0.7;

    const w = positionLocal.y.clamp(0, 1).pow(2);
    const ph = hash(instanceIndex).mul(6.283);
    const bend = vec3(
      time.mul(0.9).add(ph).sin(),
      float(0),
      time.mul(0.7).add(ph.mul(1.6)).sin(),
    ).mul(w).mul(uSway).mul(0.35);
    mat.positionNode = positionLocal.add(bend);

    const c = mix(colorVec(uGlowA), colorVec(uGlowB), hash(instanceIndex.add(5)));
    mat.colorNode = vec3(0.06, 0.05, 0.1);
    mat.emissiveNode = c.mul(positionLocal.y.clamp(0, 1).pow(2.5))
      .mul(colonyPulse().mul(1.6).add(0.25)).mul(uGlow);
    tendrilMaterial = mat;
  }
  return tendrilMaterial;
}

let fanMaterial: MeshStandardNodeMaterial | null = null;

/** Gorgonian fans: the canvas veins glow on the colony pulse; the membrane stays dim. */
function getFanMaterial(): MeshStandardNodeMaterial {
  if (!fanMaterial) {
    const mat = new MeshStandardNodeMaterial();
    mat.side = THREE.DoubleSide;
    mat.roughness = 0.8;
    const map = texture(drawFanTexture());

    const w = positionLocal.y.clamp(0, 1).pow(1.6);
    const ph = hash(instanceIndex).mul(6.283);
    const bend = vec3(time.mul(0.55).add(ph).sin(), float(0), time.mul(0.4).add(ph.mul(1.4)).sin())
      .mul(w).mul(uSway).mul(0.16);
    mat.positionNode = positionLocal.add(bend);

    const c = mix(colorVec(uGlowA), colorVec(uGlowB), hash(instanceIndex.add(3)));
    mat.colorNode = vec3(0.07, 0.06, 0.11);
    mat.emissiveNode = c.mul(map.r).mul(colonyPulse().mul(1.4).add(0.3)).mul(uGlow).mul(0.9);
    mat.opacityNode = map.a;
    // Clip the faint membrane away — only the glowing vein lattice survives, which reads
    // as a delicate gorgonian instead of a solid sheet.
    mat.alphaTestNode = float(0.4);
    fanMaterial = mat;
  }
  return fanMaterial;
}

let planktonMaterial: THREE.MeshBasicMaterial | null = null;

function getPlanktonMaterial(): THREE.MeshBasicMaterial {
  if (!planktonMaterial) {
    const size = 64;
    const canvas = document.createElement('canvas');
    canvas.width = canvas.height = size;
    const ctx = canvas.getContext('2d')!;
    const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
    g.addColorStop(0, 'rgba(255,255,255,1)');
    g.addColorStop(0.3, 'rgba(210,245,255,0.7)');
    g.addColorStop(1, 'rgba(140,220,255,0)');
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, size, size);
    planktonMaterial = new THREE.MeshBasicMaterial({
      map: new THREE.CanvasTexture(canvas),
      transparent: true,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      side: THREE.DoubleSide,
    });
  }
  return planktonMaterial;
}

// ---------- per-stroke data ----------

interface Segment {
  anchor: THREE.Vector3; // colony base on the surface (anchor space)
  pos: THREE.Vector3;    // segment base as a UNIT-space offset from the anchor
  quat: THREE.Quaternion;
  len: number;           // unit length — colonySize scales at pose time
  rad: number;
  depth: number;
  cullRnd: number;       // fractional-depth culling
  clusterRnd: number;    // density culling (whole cluster)
  birth: number;
  bodyMix: number;
  visible: boolean;
}

interface Tip {
  segIndex: number;     // follows its segment's visibility
  offset: THREE.Vector3; // unit offset from segment base (scaled by colonySize at pose)
  size: number;         // relative
  birth: number;
}

interface Tendril {
  pos: THREE.Vector3;
  quat: THREE.Quaternion;
  len: number;
  rank: number;         // tendril-count culling within its anemone
  clusterRnd: number;
  birth: number;
  visible: boolean;
}

interface Fan {
  pos: THREE.Vector3;
  quat: THREE.Quaternion;
  size: number;
  clusterRnd: number;
  birth: number;
  visible: boolean;
}

interface Plankter {
  center: THREE.Vector3;
  up: THREE.Vector3;
  side: THREE.Vector3;
  radius: number;
  height: number;
  speed: number;
  phase: number;
  size: number;
  colorMix: number;
  dist: number;
  quat: THREE.Quaternion;
}

const _m = new THREE.Matrix4();
const _s = new THREE.Vector3();
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _dir = new THREE.Vector3();
const _t1 = new THREE.Vector3();
const _t2 = new THREE.Vector3();
const _zero = new THREE.Matrix4().makeScale(0, 0, 0);
const _color = new THREE.Color();
const _cA = new THREE.Color();
const _cB = new THREE.Color();
const _Y = new THREE.Vector3(0, 1, 0);

function easeOutBack(t: number): number {
  const c1 = 1.20158;
  const c3 = c1 + 1;
  const u = t - 1;
  return 1 + c3 * u * u * u + c1 * u * u;
}

// ---------- the stroke ----------

class ReefStroke implements StrokeInstance {
  readonly group = new THREE.Group();

  private settings: ReefSettings;
  private readonly total: number;
  private grown = 0;
  private structuresDone = false;

  private segments: Segment[] = [];
  private tips: Tip[] = [];
  private tendrils: Tendril[] = [];
  private fans: Fan[] = [];
  private plankton: Plankter[] = [];

  private segMesh!: THREE.InstancedMesh;
  private tipMesh!: THREE.InstancedMesh;
  private tendrilMesh!: THREE.InstancedMesh;
  private fanMesh!: THREE.InstancedMesh;
  private planktonMesh!: THREE.InstancedMesh;

  private lights: { light: THREE.PointLight; dist: number; phase: number }[] = [];

  constructor(samples: SurfaceSample[], seed: number, settings: ReefSettings) {
    this.settings = { ...settings };
    const rnd = mulberry32(seed);
    this.total = this.scatter(samples, rnd);

    const make = (geo: THREE.BufferGeometry, mat: THREE.Material, count: number, shadows: boolean): THREE.InstancedMesh => {
      const mesh = new THREE.InstancedMesh(geo, mat, Math.max(count, 1));
      mesh.castShadow = shadows;
      mesh.receiveShadow = shadows;
      mesh.frustumCulled = false;
      for (let i = 0; i < count; i++) mesh.setMatrixAt(i, _zero);
      mesh.count = Math.max(count, 1);
      mesh.instanceMatrix.needsUpdate = true;
      this.group.add(mesh);
      return mesh;
    };
    this.segMesh = make(getCoralGeometry(), getCoralMaterial(), this.segments.length, true);
    this.tipMesh = make(getTipGeometry(), getTipMaterial(), this.tips.length, false);
    this.tendrilMesh = make(getTendrilGeometry(), getTendrilMaterial(), this.tendrils.length, false);
    this.fanMesh = make(getFanGeometry(), getFanMaterial(), this.fans.length, false);
    this.planktonMesh = make(new THREE.PlaneGeometry(1, 1), getPlanktonMaterial(), MAX_PLANKTON, false);
    this.planktonMesh.renderOrder = 3;

    // Body tints per segment.
    for (let i = 0; i < this.segments.length; i++) {
      const pal = PALETTES[settings.palette];
      _color.copy(pal.bodyA).lerp(pal.bodyB, this.segments[i].bodyMix);
      this.segMesh.setColorAt(i, _color);
    }
    if (this.segMesh.instanceColor) this.segMesh.instanceColor.needsUpdate = true;

    // Light spill: breathing teal lights along the path.
    const nLights = Math.min(3, Math.max(1, Math.round(this.total * 1.2)));
    for (let i = 0; i < nLights; i++) {
      const f = nLights === 1 ? 0.5 : 0.15 + (0.7 * i) / (nLights - 1);
      const idx = Math.floor((samples.length - 1) * f);
      const light = new THREE.PointLight(0x2ee6d6, 0, 1.4, 2);
      light.position.copy(samples[idx].local).addScaledVector(samples[idx].localNormal, 0.12);
      this.group.add(light);
      this.lights.push({ light, dist: this.total * f, phase: rnd() * 20 });
    }

    this.applySettings(settings);
  }

  // ----- generation (at slider maxima; sliders cull live) -----

  private scatter(samples: SurfaceSample[], rnd: () => number): number {
    const spacing = 1 / MAX_DENSITY;
    let travelled = 0;
    let next = spacing * 0.4;
    const tangent = new THREE.Vector3();

    for (let i = 0; i < samples.length; i++) {
      if (i > 0) travelled += samples[i].local.distanceTo(samples[i - 1].local);
      if (travelled < next) continue;
      next = travelled + spacing * (0.8 + rnd() * 0.4);

      const a = samples[Math.max(i - 1, 0)];
      const b = samples[Math.min(i + 1, samples.length - 1)];
      tangent.subVectors(b.local, a.local).normalize();
      const n = samples[i].localNormal.clone().normalize();
      const side = new THREE.Vector3().crossVectors(tangent, n).normalize();
      const clusterRnd = rnd();
      const kind = rnd();

      if (kind < 0.55) {
        this.growCoral(samples[i].local, n, side, travelled, clusterRnd, rnd);
      } else if (kind < 0.8) {
        this.growAnemone(samples[i].local, n, side, travelled, clusterRnd, rnd);
      } else {
        this.growFan(samples[i].local, n, side, tangent, travelled, clusterRnd, rnd);
      }

      // Plankton hovers around every cluster site.
      const motes = 4 + Math.floor(rnd() * 4);
      for (let k = 0; k < motes && this.plankton.length < MAX_PLANKTON; k++) {
        this.plankton.push({
          center: samples[i].local.clone(),
          up: n,
          side,
          radius: 0.06 + rnd() * 0.3,
          height: 0.06 + rnd() * 0.4,
          speed: (0.15 + rnd() * 0.35) * (rnd() < 0.5 ? 1 : -1),
          phase: rnd() * Math.PI * 2,
          size: 0.006 + rnd() * 0.012,
          colorMix: rnd(),
          dist: travelled,
          quat: new THREE.Quaternion().setFromEuler(
            new THREE.Euler(rnd() * Math.PI, rnd() * Math.PI, rnd() * Math.PI),
          ),
        });
      }
    }
    return travelled;
  }

  /** Recursive staghorn: every segment ends in a glowing polyp bud. Segment/tip positions
   *  are UNIT-space offsets from the colony's surface anchor, so the colony-size slider is
   *  a pure re-pose. */
  private growCoral(
    base: THREE.Vector3, n: THREE.Vector3, side: THREE.Vector3,
    dist: number, clusterRnd: number, rnd: () => number,
  ): void {
    const grow = (pos: THREE.Vector3, dir: THREE.Vector3, depth: number, lenMul: number): void => {
      const len = lenMul * (0.85 + rnd() * 0.3);
      const rad = 0.16 * Math.pow(0.62, depth) * (0.8 + rnd() * 0.4);
      const quat = new THREE.Quaternion().setFromUnitVectors(_Y, dir);
      const segIndex = this.segments.length;
      this.segments.push({
        anchor: base,
        pos: pos.clone(), quat, len, rad, depth,
        cullRnd: rnd(), clusterRnd,
        birth: dist + depth * 0.1 + rnd() * 0.05,
        bodyMix: rnd(),
        visible: true,
      });
      const end = pos.clone().addScaledVector(dir, len);
      // Polyps stud the whole branch, not just the end — the beaded-light staghorn look.
      this.tips.push({
        segIndex,
        offset: end.clone(),
        size: 0.11 * Math.pow(0.8, depth) * (0.8 + rnd() * 0.5),
        birth: dist + depth * 0.1 + 0.08,
      });
      for (const f of [0.55, 0.82]) {
        this.tips.push({
          segIndex,
          offset: pos.clone().addScaledVector(dir, len * f),
          size: 0.065 * Math.pow(0.8, depth) * (0.7 + rnd() * 0.5),
          birth: dist + depth * 0.1 + 0.05 + f * 0.05,
        });
      }

      if (depth >= MAX_DEPTH) return;
      const kids = 2 + (rnd() < 0.3 ? 1 : 0);
      for (let k = 0; k < kids; k++) {
        const az = rnd() * Math.PI * 2;
        const tiltAngle = 0.4 + rnd() * 0.55;
        _t1.copy(side);
        _t2.crossVectors(dir, _t1).normalize();
        _dir.copy(dir).multiplyScalar(Math.cos(tiltAngle))
          .addScaledVector(_t1, Math.cos(az) * Math.sin(tiltAngle))
          .addScaledVector(_t2, Math.sin(az) * Math.sin(tiltAngle))
          .normalize();
        grow(end, _dir.clone(), depth + 1, lenMul * 0.68);
      }
    };

    const trunkDir = n.clone();
    _t2.crossVectors(n, side);
    trunkDir.addScaledVector(side, (rnd() - 0.5) * 0.5).addScaledVector(_t2, (rnd() - 0.5) * 0.5).normalize();
    grow(new THREE.Vector3(0, 0, 0), trunkDir, 0, 1);
  }

  private growAnemone(
    base: THREE.Vector3, n: THREE.Vector3, side: THREE.Vector3,
    dist: number, clusterRnd: number, rnd: () => number,
  ): void {
    _t2.crossVectors(n, side);
    for (let k = 0; k < MAX_TENDRILS; k++) {
      const az = rnd() * Math.PI * 2;
      const tilt = 0.15 + rnd() * 0.7;
      _dir.copy(n).multiplyScalar(Math.cos(tilt))
        .addScaledVector(side, Math.cos(az) * Math.sin(tilt))
        .addScaledVector(_t2, Math.sin(az) * Math.sin(tilt))
        .normalize();
      const quat = new THREE.Quaternion().setFromUnitVectors(_Y, _dir);
      _q.setFromAxisAngle(_dir, rnd() * Math.PI * 2);
      quat.premultiply(_q);
      this.tendrils.push({
        pos: base.clone()
          .addScaledVector(side, (rnd() - 0.5) * 0.05)
          .addScaledVector(_t2, (rnd() - 0.5) * 0.05),
        quat,
        len: 0.55 + rnd() * 0.6,
        rank: k,
        clusterRnd,
        birth: dist + rnd() * 0.12,
        visible: true,
      });
    }
  }

  private growFan(
    base: THREE.Vector3, n: THREE.Vector3, side: THREE.Vector3, tangent: THREE.Vector3,
    dist: number, clusterRnd: number, rnd: () => number,
  ): void {
    // The fan plane faces across the current: X along the stroke, Y off the surface.
    const basis = new THREE.Matrix4().makeBasis(
      tangent.clone(),
      n.clone(),
      new THREE.Vector3().crossVectors(tangent, n),
    );
    const quat = new THREE.Quaternion().setFromRotationMatrix(basis);
    _q.setFromAxisAngle(n, (rnd() - 0.5) * 0.8);
    quat.premultiply(_q);
    this.fans.push({
      pos: base.clone(),
      quat,
      size: 1.0 + rnd() * 0.8,
      clusterRnd,
      birth: dist + 0.05,
      visible: true,
    });
  }

  // ----- live settings -----

  applySettings(settings: unknown): void {
    const s = settings as ReefSettings;
    this.settings = { ...s };
    setReefStyle(s);

    const densityFrac = s.density / MAX_DENSITY;
    // Depth cull with a smooth fraction per generation: at branching=1 every generation
    // survives; at 0.5 trees stop at depth 2; the trunk always stays.
    const depthCut = s.branching * (MAX_DEPTH + 1) + 0.5;
    for (const seg of this.segments) {
      seg.visible = seg.clusterRnd <= densityFrac &&
        (seg.depth === 0 || seg.cullRnd < depthCut - seg.depth);
    }
    for (const td of this.tendrils) {
      td.visible = td.clusterRnd <= densityFrac && td.rank < s.tendrils;
    }
    for (const fan of this.fans) {
      fan.visible = fan.clusterRnd <= densityFrac;
    }

    // Retint coral bodies for the palette.
    const pal = PALETTES[s.palette];
    for (let i = 0; i < this.segments.length; i++) {
      _color.copy(pal.bodyA).lerp(pal.bodyB, this.segments[i].bodyMix);
      this.segMesh.setColorAt(i, _color);
    }
    if (this.segMesh.instanceColor) this.segMesh.instanceColor.needsUpdate = true;

    this.structuresDone = false;
    this.pose(true);
  }

  // ----- StrokeInstance -----

  update(dt: number, t: number): void {
    if (this.grown < this.total + 1.2) {
      this.grown += dt * this.settings.growthSpeed;
    }
    if (!this.structuresDone) this.pose(false);
    this.updatePlankton(t);
    this.updateLights(t);
  }

  finishGrowth(): void {
    this.grown = this.total + 2;
    this.pose(true);
  }

  private pose(force: boolean): void {
    const GROW = 0.35;
    const size = this.settings.colonySize;
    let allDone = this.grown >= this.total + GROW + 0.6;

    // Coral segments (positions are unit-space offsets around their anchor).
    let dirty = force;
    for (let i = 0; i < this.segments.length; i++) {
      const seg = this.segments[i];
      if (!seg.visible) {
        if (force) this.segMesh.setMatrixAt(i, _zero);
        continue;
      }
      const t = (this.grown - seg.birth) / GROW;
      if (t <= 0) {
        if (force) this.segMesh.setMatrixAt(i, _zero);
        allDone = false;
        continue;
      }
      const k = t >= 1 ? 1 : easeOutBack(t);
      if (t < 1.2 || force) {
        _p.copy(seg.anchor).addScaledVector(seg.pos, size);
        _s.set(seg.rad * size * (0.7 + 0.3 * k), seg.len * size * k, seg.rad * size * (0.7 + 0.3 * k));
        _m.compose(_p, seg.quat, _s);
        this.segMesh.setMatrixAt(i, _m);
        dirty = true;
        if (t < 1) allDone = false;
      }
    }
    if (dirty) this.segMesh.instanceMatrix.needsUpdate = true;

    // Polyp tips ride their segments (spheres — identity orientation).
    dirty = force;
    for (let i = 0; i < this.tips.length; i++) {
      const tip = this.tips[i];
      const seg = this.segments[tip.segIndex];
      if (!seg.visible) {
        if (force) this.tipMesh.setMatrixAt(i, _zero);
        continue;
      }
      const t = (this.grown - tip.birth) / GROW;
      if (t <= 0) {
        if (force) this.tipMesh.setMatrixAt(i, _zero);
        allDone = false;
        continue;
      }
      const k = t >= 1 ? 1 : easeOutBack(t);
      if (t < 1.2 || force) {
        _p.copy(seg.anchor).addScaledVector(tip.offset, size);
        _s.setScalar(tip.size * size * k);
        _m.compose(_p, _q.identity(), _s);
        this.tipMesh.setMatrixAt(i, _m);
        dirty = true;
        if (t < 1) allDone = false;
      }
    }
    if (dirty) this.tipMesh.instanceMatrix.needsUpdate = true;

    // Tendrils.
    dirty = force;
    for (let i = 0; i < this.tendrils.length; i++) {
      const td = this.tendrils[i];
      if (!td.visible) {
        if (force) this.tendrilMesh.setMatrixAt(i, _zero);
        continue;
      }
      const t = (this.grown - td.birth) / GROW;
      if (t <= 0) {
        if (force) this.tendrilMesh.setMatrixAt(i, _zero);
        allDone = false;
        continue;
      }
      const k = t >= 1 ? 1 : easeOutBack(t);
      if (t < 1.2 || force) {
        const len = td.len * size * 1.4;
        _s.set(0.055 * size, len * k, 0.055 * size);
        _m.compose(td.pos, td.quat, _s);
        this.tendrilMesh.setMatrixAt(i, _m);
        dirty = true;
        if (t < 1) allDone = false;
      }
    }
    if (dirty) this.tendrilMesh.instanceMatrix.needsUpdate = true;

    // Fans.
    dirty = force;
    for (let i = 0; i < this.fans.length; i++) {
      const fan = this.fans[i];
      if (!fan.visible) {
        if (force) this.fanMesh.setMatrixAt(i, _zero);
        continue;
      }
      const t = (this.grown - fan.birth) / GROW;
      if (t <= 0) {
        if (force) this.fanMesh.setMatrixAt(i, _zero);
        allDone = false;
        continue;
      }
      const k = t >= 1 ? 1 : easeOutBack(t);
      if (t < 1.2 || force) {
        _s.setScalar(fan.size * size * k);
        _m.compose(fan.pos, fan.quat, _s);
        this.fanMesh.setMatrixAt(i, _m);
        dirty = true;
        if (t < 1) allDone = false;
      }
    }
    if (dirty) this.fanMesh.instanceMatrix.needsUpdate = true;

    if (allDone) this.structuresDone = true;
  }

  private updatePlankton(t: number): void {
    const s = this.settings;
    const pal = PALETTES[s.palette];
    _cA.copy(pal.glowA);
    _cB.copy(pal.glowB);
    for (let i = 0; i < this.plankton.length; i++) {
      const pk = this.plankton[i];
      if (i >= s.plankton || pk.dist > this.grown) {
        this.planktonMesh.setMatrixAt(i, _zero);
        continue;
      }
      const ang = t * pk.speed + pk.phase;
      _t2.crossVectors(pk.up, pk.side);
      _p.copy(pk.center)
        .addScaledVector(pk.side, Math.cos(ang) * pk.radius)
        .addScaledVector(_t2, Math.sin(ang) * pk.radius)
        .addScaledVector(pk.up, pk.height + Math.sin(t * 0.5 + pk.phase * 2) * 0.04);
      const tw = Math.pow(0.5 + 0.5 * Math.sin(t * (1.2 + pk.phase % 1.5) * 2 + pk.phase), 2.5);
      _s.setScalar(pk.size * (0.7 + tw * 0.6));
      _m.compose(_p, pk.quat, _s);
      this.planktonMesh.setMatrixAt(i, _m);
      _color.copy(_cA).lerp(_cB, pk.colorMix).multiplyScalar((0.2 + tw * 1.2) * s.glow);
      this.planktonMesh.setColorAt(i, _color);
    }
    this.planktonMesh.instanceMatrix.needsUpdate = true;
    if (this.planktonMesh.instanceColor) this.planktonMesh.instanceColor.needsUpdate = true;
  }

  private updateLights(t: number): void {
    const pal = PALETTES[this.settings.palette];
    for (const { light, dist, phase } of this.lights) {
      if (this.grown <= dist) {
        light.intensity = 0;
        continue;
      }
      const ignite = THREE.MathUtils.clamp((this.grown - dist) / 0.5, 0, 1);
      const breathe = 0.7 + 0.3 * Math.sin(t * 0.8 * this.settings.pulseSpeed + phase);
      light.color.copy(pal.glowA);
      light.intensity = this.settings.lightSpill * 1.1 * ignite * breathe;
    }
  }

  dispose(): void {
    this.group.removeFromParent();
    // Geometries + materials are shared; only instance buffers are per-stroke
    // (plankton's quad geometry is per-stroke).
    this.planktonMesh.geometry.dispose();
    for (const m of [this.segMesh, this.tipMesh, this.tendrilMesh, this.fanMesh, this.planktonMesh]) {
      m.dispose();
    }
  }
}

// ---------- the mode ----------

export const reefMode: PaintMode<ReefSettings> = {
  id: 'Bioluminescent reef',
  createStroke(samples, seed, settings): StrokeInstance {
    return new ReefStroke(samples, seed, settings);
  },
};
src/surfacePainter.ts
파일 저장

import * as THREE from 'three';
import { firstHitOnly } from './bvh';
import type { SurfaceSample } from './modes/mode';

const STROKE_COLOR = 0xc9a4ff;
const STROKE_RADIUS = 0.028;
const MAX_BEADS = 4000;

/**
 * Lets the user drag on a mesh to paint a stroke along its surface.
 * Samples (world position + normal, plus anchor-local copies) are collected as the pointer
 * moves and handed to `onStroke` on release.
 *
 * Visual feedback:
 *  - a "brush" ring hovering on the surface under the cursor (where crystals would seed),
 *  - a glowing violet trail tracing the stroke while dragging. A plain Line's width is
 *    ignored by WebGPU, so the trail is an InstancedMesh of overlapping beads at each
 *    sample: one stable geometry, only instance matrices update.
 */
export class SurfacePainter {
  enabled = true;
  minDist = 0.03;
  onStroke: ((samples: SurfaceSample[]) => void) | null = null;
  onActiveChange: ((active: boolean) => void) | null = null;
  /** Fired when the surface is hovered (true) or the cursor leaves it (false), in paint mode. */
  onHoverChange: ((over: boolean) => void) | null = null;

  private raycaster = firstHitOnly(new THREE.Raycaster()); // BVH: smooth picking
  private pointer = new THREE.Vector2();
  private samples: SurfaceSample[] = [];
  private active = false;
  private hovering = false;
  private pulse = 0;

  private group = new THREE.Group();
  private beads: THREE.InstancedMesh;
  private startMarker: THREE.Mesh;
  private brush: THREE.Group;
  private brushRing: THREE.Mesh;
  private brushDot: THREE.Mesh;
  private zAxis = new THREE.Vector3(0, 0, 1);
  private tmpMat = new THREE.Matrix4();
  private tmpScale = new THREE.Vector3(1, 1, 1);
  private tmpQuat = new THREE.Quaternion();
  private invAnchor = new THREE.Matrix4();
  private zeroMat = new THREE.Matrix4().scale(new THREE.Vector3(0, 0, 0));
  private beadHigh = 0; // highest bead index written since the last clear

  constructor(
    private dom: HTMLElement,
    private camera: THREE.PerspectiveCamera,
    scene: THREE.Scene,
    private getTargets: () => THREE.Object3D[],
    /** Painted geometry parents under this (floating) node; samples convert to its space. */
    private anchor: THREE.Object3D,
  ) {
    this.group.renderOrder = 10;
    scene.add(this.group);

    // Unlit, always-on-top so the trail can never be buried by the model or dimmed by lights.
    const glow = (extra: THREE.MeshBasicMaterialParameters = {}): THREE.MeshBasicMaterial =>
      new THREE.MeshBasicMaterial({
        color: STROKE_COLOR,
        transparent: true,
        depthTest: false,
        depthWrite: false,
        toneMapped: false,
        ...extra,
      });

    this.beads = new THREE.InstancedMesh(
      new THREE.SphereGeometry(STROKE_RADIUS, 12, 8),
      glow({ opacity: 1 }),
      MAX_BEADS,
    );
    this.beads.frustumCulled = false;
    this.beads.renderOrder = 11;
    // Collapse every instance to zero scale up front, so any instance we don't explicitly
    // place stays invisible (never a stray dot) regardless of draw count.
    for (let i = 0; i < MAX_BEADS; i++) this.beads.setMatrixAt(i, this.zeroMat);
    this.beads.instanceMatrix.needsUpdate = true;
    this.beads.count = 0;

    this.startMarker = new THREE.Mesh(new THREE.SphereGeometry(STROKE_RADIUS * 1.8, 16, 12), glow());
    this.startMarker.visible = false;
    this.startMarker.renderOrder = 12;

    // Brush: a ring that lies flat on the surface plus a center dot.
    this.brush = new THREE.Group();
    this.brushRing = new THREE.Mesh(
      new THREE.RingGeometry(0.055, 0.078, 40),
      glow({ opacity: 0.9, side: THREE.DoubleSide }),
    );
    this.brushDot = new THREE.Mesh(new THREE.CircleGeometry(0.015, 20), glow({ opacity: 0.95, side: THREE.DoubleSide }));
    this.brush.add(this.brushRing, this.brushDot);
    this.brush.visible = false;
    this.brush.renderOrder = 12;

    this.group.add(this.beads, this.startMarker, this.brush);

    dom.addEventListener('pointerdown', this.onDown);
    window.addEventListener('pointermove', this.onMove);
    window.addEventListener('pointerup', this.onUp);
    dom.addEventListener('pointerleave', this.onLeave);
  }

  /** Called each frame so the brush can gently pulse. */
  update(dt: number): void {
    this.pulse += dt;
    if (this.brush.visible) {
      const s = 1 + Math.sin(this.pulse * 4) * 0.08;
      this.brushRing.scale.setScalar(s);
      (this.brushRing.material as THREE.MeshBasicMaterial).opacity = 0.65 + Math.sin(this.pulse * 4) * 0.2;
    }
  }

  setEnabled(on: boolean): void {
    this.enabled = on;
    if (!on) {
      this.setHovering(false);
      this.brush.visible = false;
    }
  }

  private onDown = (e: PointerEvent): void => {
    if (!this.enabled || e.button !== 0) return;
    const hit = this.pick(e);
    if (!hit) return;
    this.active = true;
    this.samples = [hit];
    this.brush.visible = false;
    this.startMarker.visible = true;
    this.startMarker.position.copy(hit.position).addScaledVector(hit.normal, STROKE_RADIUS);
    this.updatePreview();
    this.onActiveChange?.(true);
  };

  private onMove = (e: PointerEvent): void => {
    if (this.active) {
      const hit = this.pick(e);
      if (!hit) return;
      const last = this.samples[this.samples.length - 1];
      if (hit.position.distanceTo(last.position) < this.minDist) return;
      this.samples.push(hit);
      this.updatePreview();
      return;
    }
    // Not drawing: show the brush where the cursor hovers the surface.
    if (!this.enabled) return;
    const hit = this.pick(e);
    if (hit) {
      this.setHovering(true);
      this.brush.visible = true;
      this.brush.position.copy(hit.position).addScaledVector(hit.normal, STROKE_RADIUS * 0.6);
      this.brush.quaternion.setFromUnitVectors(this.zAxis, hit.normal);
    } else {
      this.brush.visible = false;
      this.setHovering(false);
    }
  };

  private onUp = (): void => {
    if (!this.active) return;
    this.active = false;
    this.startMarker.visible = false;
    this.onActiveChange?.(false);
    if (this.samples.length >= 2) this.onStroke?.(this.samples.slice());
    this.samples = [];
    this.clearPreview();
  };

  private onLeave = (): void => {
    if (this.active) return;
    this.brush.visible = false;
    this.setHovering(false);
  };

  private setHovering(over: boolean): void {
    if (over === this.hovering) return;
    this.hovering = over;
    this.onHoverChange?.(over);
  }

  private pick(e: PointerEvent): SurfaceSample | null {
    const rect = this.dom.getBoundingClientRect();
    this.pointer.set(
      ((e.clientX - rect.left) / rect.width) * 2 - 1,
      -((e.clientY - rect.top) / rect.height) * 2 + 1,
    );
    this.raycaster.setFromCamera(this.pointer, this.camera);
    this.raycaster.far = Infinity;
    const hits = this.raycaster.intersectObjects(this.getTargets(), true);
    for (const h of hits) {
      if (!h.face) continue;
      const normal = h.face.normal.clone().transformDirection(h.object.matrixWorld);
      // Convert to anchor space NOW: the canvas floats, so each sample must be pinned
      // relative to the surface at the instant it was picked.
      this.anchor.updateWorldMatrix(true, false);
      this.invAnchor.copy(this.anchor.matrixWorld).invert();
      return {
        position: h.point.clone(),
        normal,
        local: h.point.clone().applyMatrix4(this.invAnchor),
        localNormal: normal.clone().transformDirection(this.invAnchor),
      };
    }
    return null;
  }

  private updatePreview(): void {
    // Beads at every sample — persistent geometry, only matrices + count change.
    // Overlapping spacing (bead radius >= sample spacing) reads as a continuous line.
    const n = Math.min(this.samples.length, MAX_BEADS);
    for (let i = 0; i < n; i++) {
      const s = this.samples[i];
      const p = s.position.clone().addScaledVector(s.normal, STROKE_RADIUS * 0.8);
      this.tmpMat.compose(p, this.tmpQuat, this.tmpScale);
      this.beads.setMatrixAt(i, this.tmpMat);
    }
    this.beads.count = n;
    this.beadHigh = Math.max(this.beadHigh, n);
    this.beads.instanceMatrix.needsUpdate = true;
  }

  private clearPreview(): void {
    // Zero out (not just hide) every instance this stroke touched, so the buffer returns to
    // the same all-zero state the very first draw started from.
    for (let i = 0; i < this.beadHigh; i++) this.beads.setMatrixAt(i, this.zeroMat);
    this.beadHigh = 0;
    this.beads.count = 0;
    this.beads.instanceMatrix.needsUpdate = true;
  }
}
src/ui.ts
파일 저장

import GUI from 'lil-gui';
import type { App, ModeName } from './app';
import type { CrystalPaletteName } from './modes/crystals';
import type { AuroraPaletteName } from './modes/aurora';
import type { ReefPaletteName } from './modes/reef';

export function buildGui(app: App): GUI {
  const gui = new GUI({ title: 'Geometry Painter' });
  const s = app.settings;
  const c = app.crystal;
  const f = app.fissure;
  const a = app.aurora;
  const r = app.reef;

  // Mode edits update existing strokes IN PLACE (no regeneration) — matrices, colors and
  // shader uniforms recompose on the live objects as you drag.
  const liveCrystal = () => app.updateModeSettings('Crystals');
  const liveFissure = () => app.updateModeSettings('Molten fissures');
  const liveAurora = () => app.updateModeSettings('Aurora silk');
  const liveReef = () => app.updateModeSettings('Bioluminescent reef');

  const crystalFolders: GUI[] = [];
  const fissureFolders: GUI[] = [];
  const auroraFolders: GUI[] = [];
  const reefFolders: GUI[] = [];

  gui
    .add(s, 'mode', ['Crystals', 'Molten fissures', 'Aurora silk', 'Bioluminescent reef'] satisfies ModeName[])
    .name('Painting mode')
    .onChange((m: ModeName) => {
      syncFolders(m);
      app.applyModes(); // refresh the HUD wording
    });

  const fDraw = gui.addFolder('Drawing');
  fDraw.add(s, 'drawMode').name('Paint mode (D)').listen().onChange(() => app.applyModes());
  fDraw.add({ undo: () => app.undoLast() }, 'undo').name('Undo last stroke');
  fDraw.add({ clear: () => app.clearAll() }, 'clear').name('Clear all');

  // ---------- crystals ----------

  const fCrystal = gui.addFolder('Crystals (live)');
  const palettes: CrystalPaletteName[] = ['Amethyst', 'Ice', 'Emerald', 'Citrine', 'Rose', 'Prism'];
  fCrystal.add(c, 'palette', palettes).name('Palette').onChange(liveCrystal);
  fCrystal.add(c, 'clusterDensity', 1, 16).name('Clusters / unit').onChange(liveCrystal);
  fCrystal.add(c, 'crystalSize', 0.06, 0.4).name('Crystal size').onChange(liveCrystal);
  fCrystal.add(c, 'shards', 0, 16, 1).name('Shards / cluster').onChange(liveCrystal);
  fCrystal.add(c, 'spread', 0.3, 2.5).name('Cluster spread').onChange(liveCrystal);
  fCrystal.add(c, 'tilt', 0, 1).name('Lean / wildness').onChange(liveCrystal);
  fCrystal.add(c, 'sizeJitter', 0, 1).name('Size variety').onChange(liveCrystal);
  fCrystal.add(c, 'clearMix', 0, 1).name('Clear crystal mix').onChange(liveCrystal);
  // Glow retints shared materials in place — instant, no regrow.
  fCrystal.add(c, 'glow', 0, 2).name('Inner glow').onChange((v: number) => app.setGlow(v));
  fCrystal.add(c, 'growthSpeed', 0.2, 4).name('Growth speed').onChange(liveCrystal);
  crystalFolders.push(fCrystal);

  // ---------- molten fissures ----------

  const fFissure = gui.addFolder('Molten fissures (live)');
  fFissure.add(f, 'width', 0.02, 0.16).name('Crack width').onChange(liveFissure);
  fFissure.add(f, 'heat', 0.2, 3).name('Heat').onChange(liveFissure);
  fFissure.add(f, 'pulseSpeed', 0, 3).name('Pulse speed').onChange(liveFissure);
  fFissure.add(f, 'branchDensity', 0, 8).name('Branches / unit').onChange(liveFissure);
  fFissure.add(f, 'branchLength', 0.05, 0.6).name('Branch length').onChange(liveFissure);
  fFissure.add(f, 'emberRate', 0, 80).name('Embers').onChange(liveFissure);
  fFissure.add(f, 'rockDensity', 0, 30).name('Rock lips / unit').onChange(liveFissure);
  fFissure.add(f, 'rockSize', 0.03, 0.2).name('Rock size').onChange(liveFissure);
  fFissure.add(f, 'lightSpill', 0, 3).name('Light spill').onChange(liveFissure);
  fFissure.add(f, 'growthSpeed', 0.5, 6).name('Crack speed').onChange(liveFissure);
  fissureFolders.push(fFissure);

  // ---------- aurora silk ----------

  const fAurora = gui.addFolder('Aurora silk (live)');
  const auroraPalettes: AuroraPaletteName[] = ['Borealis', 'Twilight', 'Ember', 'Spectrum'];
  fAurora.add(a, 'palette', auroraPalettes).name('Palette').onChange(liveAurora);
  fAurora.add(a, 'height', 0.15, 1.3).name('Curtain height').onChange(liveAurora);
  fAurora.add(a, 'wave', 0, 1).name('Billow').onChange(liveAurora);
  fAurora.add(a, 'flow', 0, 3).name('Flow speed').onChange(liveAurora);
  fAurora.add(a, 'rays', 0, 1).name('Ray streaks').onChange(liveAurora);
  fAurora.add(a, 'brightness', 0.2, 2.5).name('Brightness').onChange(liveAurora);
  fAurora.add(a, 'sparkles', 0, 240, 1).name('Star motes').onChange(liveAurora);
  fAurora.add(a, 'lightSpill', 0, 3).name('Light spill').onChange(liveAurora);
  fAurora.add(a, 'growthSpeed', 0.3, 4).name('Unfurl speed').onChange(liveAurora);
  auroraFolders.push(fAurora);

  // ---------- bioluminescent reef ----------

  const fReef = gui.addFolder('Bioluminescent reef (live)');
  const reefPalettes: ReefPaletteName[] = ['Abyss', 'Tropic', 'Ghost', 'Toxic'];
  fReef.add(r, 'palette', reefPalettes).name('Palette').onChange(liveReef);
  fReef.add(r, 'colonySize', 0.08, 0.35).name('Colony size').onChange(liveReef);
  fReef.add(r, 'density', 2, 14).name('Colonies / unit').onChange(liveReef);
  fReef.add(r, 'branching', 0, 1).name('Branching').onChange(liveReef);
  fReef.add(r, 'tendrils', 0, 14, 1).name('Anemone arms').onChange(liveReef);
  fReef.add(r, 'glow', 0, 2.5).name('Bioluminescence').onChange(liveReef);
  fReef.add(r, 'pulseSpeed', 0, 3).name('Pulse speed').onChange(liveReef);
  fReef.add(r, 'sway', 0, 1).name('Current sway').onChange(liveReef);
  fReef.add(r, 'plankton', 0, 220, 1).name('Plankton').onChange(liveReef);
  fReef.add(r, 'lightSpill', 0, 3).name('Light spill').onChange(liveReef);
  fReef.add(r, 'growthSpeed', 0.3, 4).name('Bloom speed').onChange(liveReef);
  reefFolders.push(fReef);

  // ---------- shared ----------

  const fLook = gui.addFolder('Light & look (live)');
  fLook.add(s, 'exposure', 0.4, 2.2).name('Exposure').onChange((v: number) => app.setExposure(v));
  fLook.add(s, 'envIntensity', 0, 2.5).name('Studio light').onChange((v: number) => app.setEnvIntensity(v));
  fLook.add(s, 'backlight', 0, 2.5).name('Backlight').onChange((v: number) => app.setBacklight(v));
  fLook.add(s, 'bloomStrength', 0, 1.5).name('Bloom').onChange((v: number) => app.setBloomStrength(v));
  fLook.add(s, 'bloomThreshold', 0.2, 1.5).name('Bloom threshold').onChange((v: number) => app.setBloomThreshold(v));
  // Reseeding genuinely regenerates (new randoms), so it goes through the rebuild path.
  fLook.add(s, 'seed', 0, 999, 1).name('Seed').onChange(() => app.scheduleRegrow('instant'));

  const fGrowth = gui.addFolder('Growth animation');
  fGrowth.add({ replay: () => app.scheduleRegrow('animate') }, 'replay').name('▶ Replay growth');

  function syncFolders(m: ModeName): void {
    for (const g of crystalFolders) (m === 'Crystals' ? g.show() : g.hide());
    for (const g of fissureFolders) (m === 'Molten fissures' ? g.show() : g.hide());
    for (const g of auroraFolders) (m === 'Aurora silk' ? g.show() : g.hide());
    for (const g of reefFolders) (m === 'Bioluminescent reef' ? g.show() : g.hide());
  }
  syncFolders(s.mode);

  return gui;
}
vite.config.ts
파일 저장

import { defineConfig } from 'vite';

/** Every page under /demos is its own entry, so `npm run build` ships them all. */
const DEMOS = [
  'index',
  'picking',
  'anchor-space',
  'resample',
  'cull',
  'growth',
  'ribbon',
  'blackbody',
  'fold-light',
  'colony-pulse',
  'studio',
];

export default defineConfig({
  resolve: {
    // Addons (OrbitControls, tsl display nodes, ...) import from 'three'. Route that to the
    // WebGPU build so the whole app shares a single module instance of three.
    alias: [{ find: /^three$/, replacement: 'three/webgpu' }],
  },
  build: {
    target: 'esnext',
    rollupOptions: {
      input: {
        main: 'index.html',
        ...Object.fromEntries(DEMOS.map((d) => [`demo-${d}`, `demos/${d}.html`])),
      },
    },
  },
});
LICENSE실행 안내·자료
파일 저장

MIT License

Copyright (c) 2026 mohamedachrefelouafi

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Bundled dependency licenses실행 안내·자료
파일 저장

three@0.185.1 — LICENSE
The MIT License

Copyright © 2010-2026 three.js authors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.


three-mesh-bvh@0.9.11 — LICENSE
MIT License

Copyright (c) 2018 Garrett Johnson

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


lil-gui@0.21.0 — LICENSE.md
MIT License

Copyright (c) 2019 George Michael Brower

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.