Motif· VectoJS gallery
WebGPUcore 1.26.0GitHub

Mercury Blobs

Draggable silvery blobs that merge into one liquid surface when close and split apart when pulled away — a classic blur + contrast-ramp 'goo' composite, with the drag hit-testing following each blob's own circle regardless of how merged it currently looks.

import { Scene, Entity, type IRenderer, type VectoJSEvent } from '@vectojs/core';

// Mercury blobs: draggable circles that visually MERGE into one shape when
// close and split apart when pulled away — the classic "goo" trick, not a
// per-pixel metaball/marching-squares implementation. Each blob draws a
// solid circle into a shared offscreen buffer, blurred; a contrast filter
// then snaps the blurred overlap back to a hard edge, so overlapping blurs
// read as one continuous liquid surface instead of two soft shadows. This
// keeps the per-frame cost O(blob count) for the fills plus a FIXED cost for
// the blur+contrast pass (bounded by buffer resolution, not blob count) —
// the honest reason this technique scales to a dozen blobs at 60fps while a
// true per-pixel SDF metaball evaluation would cost O(pixels x blobs).

// Measures the Scene's REAL render cadence, not the display's vsync rate.
// An independent requestAnimationFrame loop fires every vsync tick
// regardless of whether Scene actually rendered that tick — on a 240Hz
// display it samples ~240 ticks/sec even while Scene.loop()'s own maxFPS
// cap renders only every 4th one, so a naive HUD would read "240fps" while
// the visible motion was genuinely capped at 60. Entity.update(dt) is only
// ever called from inside Scene's renderNode() walk, which is skipped
// entirely on a throttled/skipped tick — so a probe entity's own update()
// calls are a direct measurement of frames Scene actually rendered.
class FrameProbe extends Entity {
  frameTimes: number[] = [];
  isPointInside() {
    return false;
  }
  render() {}
  update(dt: number) {
    this.frameTimes.push(dt);
    // A window this small (~330ms at 60fps) still smooths ordinary
    // frame-to-frame noise, but doesn't let the average lag behind a real
    // fps transition — a larger window held onto stale throttled-fps
    // samples for roughly a full second after Scene un-throttled following
    // interaction, making the HUD under-report for far longer than the
    // actual slowdown lasted.
    if (this.frameTimes.length > 20) this.frameTimes.shift();
  }
  avgFrameTime() {
    if (this.frameTimes.length === 0) return 0;
    return this.frameTimes.reduce((s, v) => s + v, 0) / this.frameTimes.length;
  }
}

const app = document.getElementById('app')!;
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const hud = document.getElementById('hud')!;

const SILVER = '#b9c2c9';
const SILVER_DARK = '#7d868c';
// Fraction of a CSS pixel per buffer pixel — deliberately LOW-res, not
// scaled to devicePixelRatio. Measured directly with per-pass
// performance.now() timers around GooLayer.render(): ctx.filter =
// 'blur(14px)' costs ~0ms at the fill() call site itself, but the browser
// evidently defers the actual blur computation until something reads the
// buffer's pixels back out — the LATER drawImage(buffer, ...) call (in a
// completely separate destination canvas, filter-free) was where ~30ms/
// frame actually landed at BUF_SCALE 1, on both headless swiftshader and
// (via the sandboxed-iframe vs standalone-page comparison that ruled out
// the iframe sandbox as a cause) real browser rendering. Since blur cost
// scales with pixel count and a blur is inherently forgiving of source
// resolution — the softness itself hides the lower detail — rendering the
// fill+blur pass at BUF_SCALE 0.5 (a quarter the pixels of 1.0) cut that
// same cost to ~6.5ms and restored a steady 60fps, confirmed clean at
// BUF_SCALE 0.5 with a side-by-side screenshot (no visible blockiness; the
// blur radius, defined in this buffer's own coordinate space, ends up
// relatively LARGER at low-res, if anything reading slightly softer).
const BUF_SCALE = 0.5;

class Blob extends Entity {
  radius: number;
  vx: number;
  vy: number;
  dragging = false;
  private _a11yRoundPatched = false;

  constructor(radius: number) {
    super();
    this.radius = radius;
    // width/height define the a11y shadow element's DOM box (Scene.syncA11y
    // sizes it from these, not from isPointInside/render) — leaving them at
    // the Entity default of 0 meant the projected click target was a 0x0
    // element, so pointerdown never reached this entity no matter how
    // correct isPointInside's circle math was.
    this.width = radius * 2;
    this.height = radius * 2;
    this.vx = (Math.random() - 0.5) * 24;
    this.vy = (Math.random() - 0.5) * 24;
    this.interactive = true;
  }

  override isPointInside(gx: number, gy: number) {
    const p = this.worldToLocal(gx, gy);
    if (!p) return false;
    const dx = p.x - this.radius;
    const dy = p.y - this.radius;
    return dx * dx + dy * dy <= this.radius * this.radius;
  }

  // Blobs drift perpetually — without this override, Scene's own idle
  // detection (default hasPendingAnimations() returns false) never sees
  // motion in flight, so the renderMode:'always' auto-throttle drops the
  // whole demo to ~2fps despite blobs visibly moving. Same root cause as
  // Constellation Lines' Point class (found in the same investigation).
  override hasPendingAnimations() {
    return true;
  }

  override update(dt: number) {
    // The a11y shadow element Scene projects for hit-testing/hover is a
    // RECTANGLE sized from width/height (entity.x,y to entity.x+width,
    // y+height) — the full bounding square of this circle, not the circle
    // itself. isPointInside()'s circle math only gates VectoJS's own
    // internal hit-test path; it does nothing for the real DOM element a
    // browser's native pointer events actually hit-test against. Without
    // this fix, the ~21.5% of the square OUTSIDE the inscribed circle (the
    // four corners) was clickable/hoverable even though nothing is drawn
    // there. CSS border-radius on the shadow element itself changes what
    // area the BROWSER considers "on" the element — a one-time patch right
    // after Scene creates it (idempotent via the dataset flag) rather than
    // every frame, since the box never changes shape after construction.
    if (!this._a11yRoundPatched && this.scene) {
      const el = this.scene.getA11yElement(this.id);
      if (el) {
        el.style.borderRadius = '50%';
        this._a11yRoundPatched = true;
      }
    }
    if (this.dragging) return;
    const step = Math.min(dt, 32) / 1000;
    const w = this.scene?.width ?? 0;
    const h = this.scene?.height ?? 0;
    let nx = this.x + this.vx * step;
    let ny = this.y + this.vy * step;
    const r = this.radius;
    if (nx < r) {
      nx = r;
      this.vx = Math.abs(this.vx);
    } else if (nx > w - r) {
      nx = w - r;
      this.vx = -Math.abs(this.vx);
    }
    if (ny < r) {
      ny = r;
      this.vy = Math.abs(this.vy);
    } else if (ny > h - r) {
      ny = h - r;
      this.vy = -Math.abs(this.vy);
    }
    this.x = nx;
    this.y = ny;
  }

  render() {
    // Never called: GooLayer draws every blob's pixels in one composited
    // pass so the blur/contrast filters apply across blob boundaries.
  }
}

// Owns the shared offscreen composition buffer. Runs AFTER the blobs in the
// tree (added last) is wrong for z-order here — instead this is added FIRST
// and reads live blob.x/y/radius each frame, so blob drag updates (handled
// by pointer listeners below, not by this entity) are reflected immediately.
class GooLayer extends Entity {
  blobs: Blob[];
  buffer = document.createElement('canvas');
  ctx: CanvasRenderingContext2D;
  // Contrast pass output: a SEPARATE canvas, not the same buffer drawn
  // onto itself. Measured directly: ctx.drawImage(buffer, ...) where
  // buffer is ctx's OWN canvas, with an active ctx.filter, cost ~27ms per
  // frame (nearly the entire 60fps budget) even at BUF_SCALE 1 — drawing
  // a canvas onto itself with a filter active appears to block whatever
  // fast compositing path the browser would otherwise take, forcing a
  // full software re-rasterization. Drawing into a distinct destination
  // canvas instead dropped that same pass to well under 1ms.
  contrastBuffer = document.createElement('canvas');
  contrastCtx: CanvasRenderingContext2D;

  constructor(blobs: Blob[]) {
    super('GooLayer');
    this.blobs = blobs;
    this.ctx = this.buffer.getContext('2d')!;
    this.contrastCtx = this.contrastBuffer.getContext('2d')!;
  }

  resize(width: number, height: number) {
    this.width = width;
    this.height = height;
    const bw = Math.max(1, Math.round(width * BUF_SCALE));
    const bh = Math.max(1, Math.round(height * BUF_SCALE));
    this.buffer.width = bw;
    this.buffer.height = bh;
    this.contrastBuffer.width = bw;
    this.contrastBuffer.height = bh;
  }

  isPointInside() {
    return false;
  }

  render(r: IRenderer) {
    const { ctx, buffer, contrastCtx, contrastBuffer } = this;
    if (buffer.width <= 1) return;
    const s = BUF_SCALE;
    ctx.setTransform(s, 0, 0, s, 0, 0);
    ctx.clearRect(0, 0, this.width, this.height);

    // Pass 1: solid fills, heavily blurred — this is what makes nearby
    // blobs' blurred halos overlap and merge.
    ctx.filter = 'blur(14px)';
    ctx.fillStyle = SILVER;
    for (const b of this.blobs) {
      ctx.beginPath();
      ctx.arc(b.x + b.radius, b.y + b.radius, b.radius * 0.82, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.filter = 'none';

    // Pass 2: a steep contrast ramp snaps the blur back to a near-hard
    // edge — this is the actual "goo" step. Values above ~30% opacity in
    // the blurred buffer become fully opaque; below, fully transparent.
    // Copies FROM buffer INTO contrastBuffer, a distinct canvas — measured
    // directly with per-pass timers while diagnosing a real jank report:
    // ctx.filter='blur(14px)' during the Pass 1 fills costs ~0ms at the
    // fill() call site, but the actual blur computation is evidently
    // DEFERRED until something reads the buffer's pixels back out — this
    // drawImage(buffer, ...) call was where the cost actually landed
    // (~30ms/frame at BUF_SCALE 1, independent of any ctx.filter on THIS
    // call, and independent of drawing to a separate vs the same canvas —
    // both ruled out by isolated measurement). BUF_SCALE 0.5 above is what
    // actually fixes this: a quarter the pixels for the blur to flatten
    // cuts this same pass to ~6.5ms and restores 60fps.
    contrastCtx.setTransform(1, 0, 0, 1, 0, 0);
    contrastCtx.clearRect(0, 0, contrastBuffer.width, contrastBuffer.height);
    contrastCtx.filter = 'contrast(28) brightness(0.94)';
    contrastCtx.drawImage(buffer, 0, 0);
    contrastCtx.filter = 'none';

    // Metallic shading: a radial highlight per blob, masked to the merged
    // silhouette so it reads as one liquid surface catching light, not N
    // separate spheres. Drawn onto contrastBuffer (the now-authoritative
    // silhouette) via the same "source-atop" masking trick as the original.
    contrastCtx.setTransform(s, 0, 0, s, 0, 0);
    contrastCtx.globalCompositeOperation = 'source-atop';
    for (const b of this.blobs) {
      const cx = b.x + b.radius;
      const cy = b.y + b.radius;
      const g = contrastCtx.createRadialGradient(
        cx - b.radius * 0.3,
        cy - b.radius * 0.35,
        0,
        cx,
        cy,
        b.radius * 1.1,
      );
      g.addColorStop(0, 'rgba(255, 255, 255, 0.9)');
      g.addColorStop(0.35, 'rgba(255, 255, 255, 0.15)');
      g.addColorStop(0.7, `${SILVER_DARK}00`);
      g.addColorStop(1, `${SILVER_DARK}55`);
      contrastCtx.fillStyle = g;
      contrastCtx.beginPath();
      contrastCtx.arc(b.x + b.radius, b.y + b.radius, b.radius * 1.1, 0, Math.PI * 2);
      contrastCtx.fill();
    }
    contrastCtx.globalCompositeOperation = 'source-over';

    r.drawImage(contrastBuffer, 0, 0, this.width, this.height);
  }
}

const scene = new Scene(canvas, {
  // 'always' is Scene's default renderMode; blobs drift continuously.
  maxFPS: 60,
  disableWindowResize: true,
  maxDPR: 2,
});

const frameProbe = new FrameProbe();
scene.add(frameProbe);

let blobs: Blob[] = [];
let goo: GooLayer | null = null;

// --- Drag: window-level pointermove so a fast drag that outruns the blob
// still tracks (same pattern as Liquid Glass's card drag). Shared across
// spawnBlobs() calls (button clicks respawn an entirely new blob array) so
// there is exactly one place wiring pointerdown, not two copies that could
// drift apart.
let grabbed: Blob | null = null;
let grabDX = 0;
let grabDY = 0;
function wireDrag(b: Blob) {
  b.on('pointerdown', (e: VectoJSEvent) => {
    grabbed = b;
    b.dragging = true;
    grabDX = (e.sceneX ?? b.x) - b.x;
    grabDY = (e.sceneY ?? b.y) - b.y;
  });
}

function spawnBlobs(count: number) {
  for (const b of blobs) scene.remove(b);
  if (goo) scene.remove(goo);
  grabbed = null;
  const w = app.clientWidth || 800;
  const h = app.clientHeight || 600;
  const baseR = count <= 3 ? 70 : count <= 6 ? 50 : 34;
  blobs = Array.from({ length: count }, () => {
    const b = new Blob(baseR * (0.75 + Math.random() * 0.5));
    b.x = b.radius + Math.random() * (w - b.radius * 2);
    b.y = b.radius + Math.random() * (h - b.radius * 2);
    return b;
  });
  goo = new GooLayer(blobs);
  goo.resize(w, h);
  // GooLayer first so blobs (interactive hit targets) sit "on top" for
  // pointer routing even though GooLayer draws all the visible pixels.
  scene.add(goo);
  for (const b of blobs) {
    scene.add(b);
    wireDrag(b);
  }
}

spawnBlobs(6);

// Tracks the canvas size fit() last saw, so a resize can rescale existing
// blob positions proportionally instead of leaving them exactly where they
// were. scene.resize() only changes the canvas/backing-store dimensions —
// it does not touch entity positions — so shrinking the canvas can push a
// blob outside the new bounds (Blob.update()'s edge-bounce logic then
// re-clamps it, but only to the nearest edge, not to a proportional
// position) and growing back leaves every blob still confined to whatever
// sub-region it had bounced into at the smaller size, clustering them
// instead of spreading across the newly available area.
let lastW = 0;
let lastH = 0;

function fit() {
  const w = app.clientWidth;
  const h = app.clientHeight;
  if (w === 0 || h === 0) return;
  scene.resize(w, h);
  goo?.resize(w, h);
  if (lastW > 0 && lastH > 0 && (lastW !== w || lastH !== h)) {
    const sx = w / lastW;
    const sy = h / lastH;
    for (const b of blobs) {
      b.x *= sx;
      b.y *= sy;
    }
  }
  lastW = w;
  lastH = h;
}
const observer = new ResizeObserver(fit);
observer.observe(app);

scene.start();

window.addEventListener('pointermove', (e) => {
  if (!grabbed) return;
  const rect = canvas.getBoundingClientRect();
  grabbed.x = e.clientX - rect.left - grabDX;
  grabbed.y = e.clientY - rect.top - grabDY;
});
window.addEventListener('pointerup', () => {
  if (grabbed) grabbed.dragging = false;
  grabbed = null;
});

// --- Blob-count buttons ---
const countButtons: Record<string, number> = {
  'btn-count-3': 3,
  'btn-count-6': 6,
  'btn-count-12': 12,
};
for (const [id, count] of Object.entries(countButtons)) {
  document.getElementById(id)!.addEventListener('click', () => {
    for (const other of Object.keys(countButtons))
      document.getElementById(other)!.setAttribute('aria-pressed', String(other === id));
    spawnBlobs(count); // wires drag on the new blobs itself
  });
}

function updateHud() {
  const avg = frameProbe.avgFrameTime();
  if (avg > 0) {
    const fps = 1000 / avg;
    hud.textContent =
      `${blobs.length} blobs · goo blur+contrast pass\n` +
      `frame ${avg.toFixed(1)}ms · ${fps.toFixed(0)} fps\n` +
      `drag any blob`;
  }
  setTimeout(updateHud, 250);
}
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>Mercury Blobs — 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;600;700&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: #f7f4ee;
      }
      #app {
        position: relative;
        width: 100%;
        height: 100%;
      }
      canvas {
        display: block;
        width: 100%;
        height: 100%;
      }
      #controls {
        position: absolute;
        top: 14px;
        left: 14px;
        right: 14px;
        display: flex;
        flex-wrap: wrap;
        gap: 8px;
        font:
          500 12px "Inter",
          sans-serif;
        z-index: 10;
      }
      #controls button {
        border: 1px solid rgba(42, 39, 35, 0.16);
        background: rgba(255, 255, 255, 0.7);
        color: #2a2723;
        border-radius: 7px;
        padding: 6px 10px;
        cursor: pointer;
        font: inherit;
      }
      #controls button[aria-pressed="true"] {
        background: #2a2723;
        color: #f7f4ee;
        border-color: #2a2723;
      }
      #hud {
        position: absolute;
        top: 60px;
        left: 14px;
        font:
          500 12px "JetBrains Mono",
          monospace;
        color: #2a2723;
        background: rgba(255, 255, 255, 0.7);
        border: 1px solid rgba(42, 39, 35, 0.16);
        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="controls">
        <button id="btn-count-3" aria-pressed="false">3 blobs</button>
        <button id="btn-count-6" aria-pressed="true">6 blobs</button>
        <button id="btn-count-12" aria-pressed="false">12 blobs</button>
      </div>
      <div id="hud">measuring…</div>
    </div>
    <script type="module" src="./demo.js"></script>
  </body>
</html>
liveesm.sh@vectojs/coreMIT — copy freely