Skip to content
07 / 103D8 min read

Swarm

Five packets crawl the cable across the floor of the hero, and between them they cost one draw call and five floats. An InstancedMesh, a Catmull-Rom curve, and a matrix buffer the CPU rewrites every frame.

Used on this site

The cable running across the floor from the rack to the desk in the hero diorama. The faint green pulses crawling along it are the packet feed — watch one for a few seconds and you will see it swell as it passes the middle of its run.

Paused
Push Bodies to four hundred and watch what does not happen: no allocation, no per-body object, no garbage. The buffer readout is the entire state of the swarm.

Five pulses travel the cable in the hero, and between them they own five numbers, one geometry and one draw call.

Drawing five small glowing spheres is not difficult. Drawing them as five meshes is merely careless: every mesh is its own draw call, its own matrix upload and its own material bind, and a room that already contains a rack, a desk, a monitor and a floor does not need five more of each so that a cable can look busy.

An InstancedMesh collapses the five into one — one sphere, one material, one buffer of five matrices that the CPU rewrites each frame. What makes that tractable is the data layout rather than the renderer. Each body owns exactly one number, its parameter along the curve between 0 and 1, and position, scale and colour are all derived from that number every frame.

One number per body

The demo runs the room’s bookkeeping on a 2D canvas so the arithmetic is visible. There is one Float32Array of four hundred floats, filled once during setup, and the Bodies slider neither grows nor shrinks it — it moves the index the loop stops at. Bodies above the count are not destroyed; they are simply not visited.

SwarmDemo.tsx
const MAX = 400;
// ONE allocation for the whole demo. Bodies are added and removed by
// moving a count, never by growing an array.
const t = new Float32Array(MAX);
for (let i = 0; i < MAX; i++) t[i] = i / MAX;

// the control points of the cable — a Catmull-Rom through five knots
const knots: [number, number][] = [
  [0.06, 0.82],
  [0.26, 0.34],
  [0.5, 0.72],
  [0.74, 0.26],
  [0.94, 0.6],
].map(([x, y]) => [x * W, y * H]);
Four hundred bodies, sixteen hundred bytes, one allocation — and five knots for them to travel.

Advancing a body is its parameter plus a step, % 1, written straight back into the slot it came from — and that modulo is the whole lifecycle: a pulse that reaches the desk reappears at the rack on the next frame, with nothing constructed and nothing destroyed. The initial fill of i / MAX is what staggers them across the cable, and the shipped component does the same thing with i / PULSE_MAX so its five never leave the rack in a clump.

The curve is the state space

A parameter is only useful if something can turn it into a position. Catmull-Rom is the right spline for a cable because it interpolates its control points rather than merely approaching them: the path is authored in scene.config.ts as eight positions — five hugging the floor, then three climbing up onto the desk — and the tube passes through all eight rather than through a smoothed suggestion of them.

SwarmDemo.tsx
/** Catmull-Rom, centripetal-ish: interpolates its control points rather
 *  than merely approaching them, which is why a cable authored as a list
 *  of positions actually passes through those positions. */
const at = (u: number): [number, number] => {
  const n = knots.length - 1;
  const f = Math.max(0, Math.min(0.9999, u)) * n;
  const i = Math.floor(f);
  const s = f - i;
  const p0 = knots[Math.max(0, i - 1)];
  const p1 = knots[i];
  const p2 = knots[Math.min(n, i + 1)];
  const p3 = knots[Math.min(n, i + 2)];
  const s2 = s * s;
  const s3 = s2 * s;
  const h = (a: number, b: number, c: number, d: number) =>
    0.5 * (2 * b + (-a + c) * s + (2 * a - 5 * b + 4 * c - d) * s2 + (-a + 3 * b - 3 * c + d) * s3);
  return [h(p0[0], p1[0], p2[0], p3[0]), h(p0[1], p1[1], p2[1], p3[1])];
};
The uniform basis written out. The room hands the same job to three’s CatmullRomCurve3.

The room’s curve is built with a tension of 0.35 against the library default of 0.5. Tension scales the tangents — three computes them as tension * (p2 - p0) — so a lower number shortens them and takes the overshoot out of the bends. A cable lying on a floor should not bulge off it between two knots that are both on the floor.

The other difference matters more. The frame loop calls getPointAt rather than getPoint, and they are not the same function. getPoint is parameterised by the spline’s own t, which is not uniform in distance; getPointAt remaps through an arc-length table that three builds once at two hundred samples, caches, and thereafter binary-searches. On getPoint a pulse sprints down the long straight run across the floor and then crawls through the three short knots climbing onto the desk, which reads as a bug in the animation rather than as a property of the spline.

Nothing is allocated in the loop

CableSnake.tsx
const PULSE_MAX = 5;
const ACCENT = new THREE.Color(PALETTE.accent);

/** Coiled cable snake running from the base of the rack across the floor to
 *  the desk. `packetActivity` drives faint phosphor pulses travelling along
 *  it — the visible heartbeat of the distributed system. */
export default function CableSnake() {
  const { motionOK, clockRef, data } = useSceneCtx();
  const pulsesRef = useRef<THREE.InstancedMesh>(null);
  // per-pulse param along the curve, staggered so they never bunch up
  const params = useRef(Array.from({ length: PULSE_MAX }, (_, i) => i / PULSE_MAX));

  const [curve, tubeGeo, tubeMat, pulseGeo, pulseMat] = useMemo(() => {
    const c = new THREE.CatmullRomCurve3(
      CABLE_PATH.map(([x, y, z]) => new THREE.Vector3(x, y, z)),
      false,
      "catmullrom",
      0.35,
    );
    const tube = new THREE.TubeGeometry(c, 48, 0.045, 6, false);
    const tMat = new THREE.MeshStandardMaterial({ color: PALETTE.black, flatShading: true, roughness: 0.9 });
    const pGeo = new THREE.SphereGeometry(0.075, 8, 6);
    const pMat = new THREE.MeshBasicMaterial({ color: "#ffffff", toneMapped: false });
    return [c, tube, tMat, pGeo, pMat] as const;
  }, []);

  const tmpM = useMemo(() => new THREE.Matrix4(), []);
  const tmpV = useMemo(() => new THREE.Vector3(), []);
  const tmpC = useMemo(() => new THREE.Color(), []);
  const zero = useMemo(() => new THREE.Matrix4().makeScale(0, 0, 0), []);
Everything this component will ever allocate, allocated once: the accent colour, the five-slot parameter array, the curve, the tube, the pulse sphere, two materials, three scratch objects and one zero matrix.

tmpM, tmpV and tmpC are the load-bearing part. curve.getPointAt(p, tmpV) passes a target, so the curve writes into a vector that already exists instead of returning a fresh one; the matrix and the colour are likewise rebuilt in place for every instance. Skip that and five pulses at sixty frames a second are nine hundred short-lived objects a second, every one of them garbage before the frame ends. The cost is not the allocation — it is the collection, and it arrives as a dropped frame at a moment nobody chose.

Five, always five

CableSnake.tsx
useFrame((_, delta) => {
  const mesh = pulsesRef.current;
  if (!mesh) return;
  const packet = data.packetRef.current;
  const active = Math.round(1 + packet * (PULSE_MAX - 1));
  const dt = motionOK ? Math.min(delta, 0.1) : 0;
  for (let i = 0; i < PULSE_MAX; i++) {
    if (i >= active) {
      mesh.setMatrixAt(i, zero);
      continue;
    }
    params.current[i] = (params.current[i] + dt * (0.1 + packet * 0.3)) % 1;
    const p = params.current[i];
    curve.getPointAt(p, tmpV);
    const s = 0.7 + 0.3 * Math.sin(p * Math.PI); // swell mid-run
    tmpM.makeScale(s, s, s).setPosition(tmpV.x, tmpV.y + 0.01, tmpV.z);
    mesh.setMatrixAt(i, tmpM);
    // faint — these are packets, not lamps; brightness rides activity
    tmpC.copy(ACCENT).multiplyScalar(0.35 + packet * 1.0 + 0.15 * Math.sin(clockRef.current * 6 + i * 2.3));
    mesh.setColorAt(i, tmpC);
  }
  mesh.instanceMatrix.needsUpdate = true;
  if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
});
The entire per-frame cost of the packet feed. Twenty-four lines, no allocations, two buffer flags raised at the end.

PULSE_MAX is 5 and the instance count never moves off it. Activity decides how many should be visible — Math.round(1 + packet * 4), so between one and five — and the rest are not removed but handed zero, a makeScale(0, 0, 0) matrix built once at mount, which rasterises nothing. Resizing the mesh instead would mean a new buffer, a new upload and a new bounding volume every time traffic crossed a threshold, which is a lot of ceremony for a dot. The clamp on delta at the top of the loop is the unglamorous defence, and the one that actually bites: return to a tab that has been in the background for a minute and delta is a minute, so without Math.min(delta, 0.1) every pulse jumps to an arbitrary place on the cable in the instant you look at it. Under reduced motion dt is 0 instead — the loop still runs and still writes, and the pulses simply hold station.

ConstantDefault
PULSE_MAX5instances allocated, and the draw’s count for the life of the page. Inactive ones are scaled to zero, never removed
active1 + packet × 4how many are visible. Rounded, so it steps rather than fades — one pulse is the floor, never none
speed0.1 + packet × 0.3curve parameter per second. A full run takes 10 s at rest and about 2.5 s flat out
dt clamp0.1 sthe largest step one frame may take. Without it, a tab returning from the background teleports every pulse
swell0.7 + 0.3 sin(πp)scale along the run: 0.7 at both ends, 1.0 at the midpoint
tension0.35under three’s 0.5 default — shorter tangents, so the spline does not bow off the floor at the bends
brightness0.35 + packet + 0.15 sin(…)the accent multiplier. The middle term is the data; the third is a per-instance flicker offset by i * 2.3

The swell is 0.7 + 0.3 * Math.sin(p * Math.PI) — seven tenths at both ends of the run, full size at the middle. A dot of constant size moving along a fixed path reads as a texture offset, because nothing about the shape is changing and the eye files it as a pattern scrolling. Giving it an arc of scale gives the motion a beginning, a middle and an end, and it starts reading as one thing travelling.

Colour is the accent — #38FFB7, which the palette reserves for things that are alive — scaled by 0.35 + packet + 0.15 * sin(clock * 6 + i * 2.3). The material underneath is plain white with toneMapped: false, so the per-instance colour arrives untinted and the phosphor is not quietly graded away. The base keeps a quiet cable visible, the flicker is offset by i * 2.3 so five pulses never blink in unison, and the middle term is the one that means anything: brightness rides packetRef, not a clock.

WebGL · Instancing ·
← Back to the lab
Creative Web Designer & Developer25.2048°N · 55.2708°E

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E