Motif· VectoJS gallery
WebGPUcore 1.11.3GitHub

Particle Morph

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, cy, r, n) {
  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, r) {
  return r * (0.675 + 0.325 * Math.cos(5 * (a + Math.PI / 2)));
}

function sampleStar(cx, cy, r, n) {
  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, cy, r, n) {
  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, cy, r, n) {
  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, cy, r, n) {
  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, cy, r, n) {
  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, cy, r, n) {
  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 ---- */

const SHAPES = [
  { 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 },
];

class MorphController extends Entity {
  bucket = null;
  shapeIdx = 0;
  state = "display"; // display → explode → morph → display
  timer = 0;
  speed = 1;
  total = 1800;

  isPointInside() {
    return false;
  }
  hasPendingAnimations() {
    return true;
  }
  render() {}

  update(dt) {
    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) {
    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): the morph window is 700ms / speed
    // (MorphController's "morph" state, see update() above). The original
    // springK=0.08/damping=0.7 left ~99.8% of the scatter distance
    // uncrossed by the end of that window, so shapes visually "morphed"
    // into a still-mostly-scattered cloud instead of the next target shape.
    // springK=7.25/damping=0.975 converges to ~0% remaining distance by
    // 700ms at the engine's 60fps target with negligible overshoot.
    // springK is clamped to 10 by the engine, so the fastest speed setting
    // (700ms/2.0 = 350ms window) can't fully converge either — an inherent
    // tradeoff of a physically-modeled spring, not a bug.
    this.bucket = new ComputeParticleEntity({
      maxParticles: count,
      size: 2.8,
      color: "#d97757",
      springK: 7.25,
      damping: 0.975,
      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");
const hud = document.getElementById("hud");

const scene = new Scene(canvas, {
  renderMode: "always",
  maxFPS: 60,
  disableWindowResize: true,
  maxDPR: 2,
});

const controller = new MorphController();
scene.add(controller);
scene.start();

function rebuildScene() {
  const count = Number(document.getElementById("input-count").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);
}

document.getElementById("input-count").addEventListener("input", () => {
  document.getElementById("value-count").textContent =
    document.getElementById("input-count").value;
});
document.getElementById("input-count").addEventListener("change", rebuildScene);

document.getElementById("input-speed").addEventListener("input", () => {
  controller.speed = Number(document.getElementById("input-speed").value) / 5;
  document.getElementById("value-speed").textContent =
    controller.speed.toFixed(1);
  scene.markDirty();
});

document.getElementById("input-color").addEventListener("input", () => {
  if (controller.bucket) {
    controller.bucket.baseColor = document.getElementById("input-color").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: "◈" }[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.11.3"
        }
      }
    </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>
liveesm.sh@vectojs/coreMIT — copy freely