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 easeOutBack — that 5% overshoot is the ' + 'entire difference between "a mesh appeared" and "a crystal snapped into being".' + '' + '' + '' + '' + '' + '' + '' + '' + '', ); // 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)))); }); }