Particle Morph
Hundreds of particles automatically cycling between geometric shapes — circle, star, hexagon, diamond, heart
Hundreds of particles automatically cycling between geometric shapes — circle, star, hexagon, diamond, heart
import { Scene, Entity, ComputeParticleEntity } from '@vectojs/core';
/* ---- shape samplers (filled interior with perimeter bias) ---- */
function sampleCircle(cx: number, cy: number, r: number, n: number): Float32Array {
const pts = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const a = ((i + Math.random() * 0.5) / n) * Math.PI * 2;
const rad = r * (0.25 + Math.random() * 0.75);
pts[i * 2] = cx + Math.cos(a) * rad;
pts[i * 2 + 1] = cy + Math.sin(a) * rad;
}
return pts;
}
function starRad(a: number, r: number): number {
return r * (0.675 + 0.325 * Math.cos(5 * (a + Math.PI / 2)));
}
function sampleStar(cx: number, cy: number, r: number, n: number): Float32Array {
const pts = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const a = ((i + Math.random() * 0.5) / n) * Math.PI * 2;
const rad = starRad(a, r) * (0.4 + Math.random() * 0.6);
pts[i * 2] = cx + Math.cos(a) * rad;
pts[i * 2 + 1] = cy + Math.sin(a) * rad;
}
return pts;
}
function sampleHexagon(cx: number, cy: number, r: number, n: number): Float32Array {
const pts = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const a = ((i + Math.random() * 0.5) / n) * Math.PI * 2;
const snap = Math.round(a / (Math.PI / 3)) * (Math.PI / 3);
const rad = (r * (0.3 + Math.random() * 0.7)) / Math.cos((a - snap) * 0.6);
const clampedRad = Math.min(rad, r);
pts[i * 2] = cx + Math.cos(a) * clampedRad;
pts[i * 2 + 1] = cy + Math.sin(a) * clampedRad;
}
return pts;
}
function sampleDiamond(cx: number, cy: number, r: number, n: number): Float32Array {
const pts = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const t = (i + Math.random() * 0.5) / n;
const a = t * Math.PI * 2;
const d =
(r * (0.3 + Math.random() * 0.7)) / (Math.abs(Math.cos(a)) + Math.abs(Math.sin(a)) || 1);
pts[i * 2] = cx + Math.cos(a) * d;
pts[i * 2 + 1] = cy + Math.sin(a) * d;
}
return pts;
}
function sampleHeart(cx: number, cy: number, r: number, n: number): Float32Array {
const pts = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const a = ((i + Math.random() * 0.5) / n) * Math.PI * 2;
const scale = (r / 18) * (0.25 + Math.random() * 0.75);
const x = 16 * Math.pow(Math.sin(a), 3);
const y = 13 * Math.cos(a) - 5 * Math.cos(2 * a) - 2 * Math.cos(3 * a) - Math.cos(4 * a);
pts[i * 2] = cx + x * scale;
pts[i * 2 + 1] = cy - y * scale;
}
return pts;
}
function sampleSpiral(cx: number, cy: number, r: number, n: number): Float32Array {
const pts = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const t = (i + Math.random() * 0.5) / n;
const a = t * Math.PI * 6;
const rad = r * t * (0.5 + Math.random() * 0.5);
pts[i * 2] = cx + Math.cos(a) * rad;
pts[i * 2 + 1] = cy + Math.sin(a) * rad;
}
return pts;
}
function sampleTriangle(cx: number, cy: number, r: number, n: number): Float32Array {
const pts = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const a = ((i + Math.random() * 0.5) / n) * Math.PI * 2 + Math.PI / 2;
const snap = Math.round(a / ((Math.PI * 2) / 3)) * ((Math.PI * 2) / 3);
const rad = (r * (0.3 + Math.random() * 0.7)) / (Math.cos((a - snap) * 0.7) || 1);
const clampedRad = Math.min(rad, r);
pts[i * 2] = cx + Math.cos(a) * clampedRad;
pts[i * 2 + 1] = cy + Math.sin(a) * clampedRad;
}
return pts;
}
/* ---- end shape samplers ---- */
type ShapeFn = (cx: number, cy: number, r: number, n: number) => Float32Array;
const SHAPES: { name: string; fn: ShapeFn }[] = [
{ name: 'Circle', fn: sampleCircle },
{ name: 'Star', fn: sampleStar },
{ name: 'Hexagon', fn: sampleHexagon },
{ name: 'Diamond', fn: sampleDiamond },
{ name: 'Heart', fn: sampleHeart },
{ name: 'Spiral', fn: sampleSpiral },
{ name: 'Triangle', fn: sampleTriangle },
];
type MorphState = 'display' | 'explode' | 'morph';
class MorphController extends Entity {
bucket: ComputeParticleEntity | null = null;
shapeIdx = 0;
state: MorphState = 'display'; // display → explode → morph → display
timer = 0;
speed = 1;
total = 1800;
isPointInside() {
return false;
}
hasPendingAnimations() {
return true;
}
render() {}
update(dt: number) {
this.timer += dt;
const W = this.scene.width;
const H = this.scene.height;
if (W === 0 || H === 0) return;
const cx = W / 2;
const cy = H / 2;
const r = Math.min(W, H) * 0.28;
if (this.state === 'display' && this.timer > 3500 / this.speed) {
this.state = 'explode';
this.timer = 0;
if (this.bucket) {
this.bucket.triggerExplosion(cx, cy, 50000);
this.scene.markDirty();
}
} else if (this.state === 'explode' && this.timer > 350 / this.speed) {
this.state = 'morph';
this.timer = 0;
this.shapeIdx = (this.shapeIdx + 1) % SHAPES.length;
if (this.bucket) {
const points = SHAPES[this.shapeIdx].fn(cx, cy, r, this.total);
this.bucket.setOrigins(points, false);
this.scene.markDirty();
}
} else if (this.state === 'morph' && this.timer > 700 / this.speed) {
this.state = 'display';
this.timer = 0;
}
}
rebuild(count: number) {
this.total = count;
const W = this.scene.width || 900;
const H = this.scene.height || 500;
const cx = W / 2;
const cy = H / 2;
const r = Math.min(W, H) * 0.28;
if (this.bucket) {
this.scene.remove(this.bucket);
}
// Tuning note (2026-07-19, revised): the morph window is 700ms / speed
// (MorphController's "morph" state, see update() above). An
// under-damped pair (springK=7.25/damping=0.975) closed the position
// gap by the end of the window but left velocity around -160px/s —
// particles were still flying at speed and shot past their target,
// reading as an "explosion"/snap at every shape transition rather than
// a settle. springK=7.05/damping=0.944 is chosen to be over-damped
// instead: position never crosses (overshoots) the target at any frame
// rate from 24-144fps (verified by simulation), so motion always reads
// as a continuous decelerating glide into the next shape. The tradeoff
// is a larger residual distance right at 700ms (~29% at 60fps) that
// keeps closing smoothly into the following "display" hold; springK is
// still clamped to 10 by the engine, so the fastest speed setting
// (700ms/2.0 = 350ms window) converges even less — an inherent tradeoff
// of a physically-modeled spring, not a bug.
this.bucket = new ComputeParticleEntity({
maxParticles: count,
size: 2.8,
color: '#d97757',
springK: 7.05,
damping: 0.944,
bounceDamping: 0.3,
maxVelocity: 800,
});
this.scene.add(this.bucket);
this.bucket.initRandomParticles(W, H);
const points = SHAPES[this.shapeIdx].fn(cx, cy, r, count);
this.bucket.setOrigins(points, true);
this.state = 'display';
this.timer = 0;
}
}
const app = document.getElementById('app')!;
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const hud = document.getElementById('hud')!;
const inputCount = document.getElementById('input-count') as HTMLInputElement;
const inputSpeed = document.getElementById('input-speed') as HTMLInputElement;
const inputColor = document.getElementById('input-color') as HTMLInputElement;
const scene = new Scene(canvas, {
// 'always' is Scene's default renderMode; particles integrate continuously.
maxFPS: 60,
disableWindowResize: true,
maxDPR: 2,
});
const controller = new MorphController();
scene.add(controller);
scene.start();
function rebuildScene() {
const count = Number(inputCount.value);
const W = app.clientWidth || 900;
const H = app.clientHeight || 500;
// Resize BEFORE rebuild: rebuild()'s shape geometry reads
// this.scene.width/height for centering/radius — resizing after would
// leave the initial shape computed against the stale (or 900x500
// fallback) size, clustering all particles in a small sub-region until
// the next resize event happens to fire.
if (W > 0 && H > 0) scene.resize(W, H);
controller.rebuild(count);
}
inputCount.addEventListener('input', () => {
document.getElementById('value-count')!.textContent = inputCount.value;
});
inputCount.addEventListener('change', rebuildScene);
inputSpeed.addEventListener('input', () => {
controller.speed = Number(inputSpeed.value) / 5;
document.getElementById('value-speed')!.textContent = controller.speed.toFixed(1);
scene.markDirty();
});
inputColor.addEventListener('input', () => {
if (controller.bucket) {
controller.bucket.baseColor = inputColor.value;
scene.markDirty();
}
});
const observer = new ResizeObserver(() => {
const w = app.clientWidth;
const h = app.clientHeight;
if (w > 0 && h > 0) {
scene.resize(w, h);
if (controller.bucket) {
controller.bucket.width = w;
controller.bucket.height = h;
}
}
});
observer.observe(app);
rebuildScene();
function updateHud() {
const shapeName = SHAPES[controller.shapeIdx].name;
const stateIcon =
({ display: '◉', explode: '✦', morph: '◈' } as Record<MorphState, string>)[controller.state] ||
'●';
hud.textContent =
`${controller.total} particles · ${shapeName} ${stateIcon}\n` +
`auto-cycling every ${(3.5 / controller.speed).toFixed(1)}s · speed ${controller.speed.toFixed(1)}`;
setTimeout(updateHud, 300);
}
updateHud();
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/no-ff-webgpu.js"></script>
<title>Particle Morph — Motif</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&family=JetBrains+Mono:wght@500&display=swap"
rel="stylesheet"
/>
<style>
* {
margin: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: #1a1a1e;
}
#app {
position: relative;
width: 100%;
height: 100%;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#panel {
position: absolute;
top: 14px;
left: 14px;
right: 14px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font:
500 12px "Inter",
sans-serif;
z-index: 10;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 10px 12px;
color: #e8e4de;
}
#panel .field {
display: flex;
align-items: center;
gap: 6px;
}
#panel label {
color: #a09888;
font-size: 11px;
white-space: nowrap;
}
#panel input[type="range"] {
width: 72px;
accent-color: #d97757;
}
#panel input[type="color"] {
width: 26px;
height: 26px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
cursor: pointer;
background: none;
}
#panel .value {
font:
500 11px "JetBrains Mono",
monospace;
color: #a09888;
min-width: 28px;
}
#panel .divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.14);
}
#hud {
position: absolute;
top: 68px;
left: 14px;
font:
500 12px "JetBrains Mono",
monospace;
color: #e8e4de;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 7px;
padding: 8px 12px;
line-height: 1.6;
z-index: 10;
white-space: pre;
}
</style>
<script type="importmap">
{
"imports": {
"@vectojs/core": "https://esm.sh/@vectojs/core@1.26.0"
}
}
</script>
</head>
<body>
<div id="app">
<canvas id="canvas"></canvas>
<div id="panel">
<div class="field">
<label for="input-count">Particles</label>
<input
id="input-count"
type="range"
min="500"
max="4000"
step="100"
value="1800"
/>
<span class="value" id="value-count">1800</span>
</div>
<div class="field">
<label for="input-speed">Speed</label>
<input
id="input-speed"
type="range"
min="1"
max="10"
step="1"
value="5"
/>
<span class="value" id="value-speed">1.0</span>
</div>
<div class="divider"></div>
<div class="field">
<label for="input-color">Color</label>
<input id="input-color" type="color" value="#d97757" />
</div>
</div>
<div id="hud">measuring…</div>
</div>
<script type="module" src="./demo.js"></script>
</body>
</html>