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'll 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.

The first version of this cable floated off the floor between its knots, and the fix had nothing to do with instancing.

Drawing five small glowing spheres is not hard. 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 doesn't need five more of each so a cable can look busy.

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

One number per body

The demo runs the room's bookkeeping on a 2D canvas so you can see the arithmetic. There's 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 just sit in the buffer until the slider comes back for them.

SwarmDemo.tsx
const MAX = 400;
// one allocation for the whole demo. the count moves, the array never does
const t = new Float32Array(MAX);
for (let i = 0; i < MAX; i++) t[i] = (i * 0.618034) % 1; // golden-ratio stagger

// 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. 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 steps each body on by 0.618034, the fractional part of the golden ratio, wrapping at 1, so however many the Bodies slider lets through are already spread along the whole curve. Plain i / MAX would put the default twenty-four in the first six percent of it, as one dark clump. The shipped component can use i / PULSE_MAX because it only has five slots, each a fifth of the curve from the next, so its pulses 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 and three climbing onto the desk, and the tube passes through all eight rather than through a smoothed suggestion of them.

SwarmDemo.tsx
// uniform Catmull-Rom (tau 0.5): interpolates its control points,
// so a path authored as positions actually passes through them
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 first version did exactly that, and it looked like the cable was levitating.

The other difference matters more. The frame loop calls getPointAt, not 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 binary-searches after that. 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. Anyone watching would file it as an animation bug, and the fix is two letters on the end of a method name.

Nothing is allocated in the loop

CableSnake.tsx
const { matFlat, 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, pulseGeo, pulseMat] = useMemo(() => {
  const c = new THREE.CatmullRomCurve3(
    CABLE_PATH.map(([x, y, z]) => new THREE.Vector3(x, y, z)),
    false,
    "catmullrom",
    0.35,
  );
  // 0.026, not 0.045. At the room's scale 0.045 is a 22mm cable — thicker
  // than mains flex and thick enough that the robot vacuum climbing over it
  // read as the machine driving over a pipe. This is a patch lead.
  const tube = new THREE.TubeGeometry(c, 48, 0.026, 6, false);
  // vertex-coloured so it can wear the room's shared material rather than a
  // near-identical one of its own — one fewer shader program, no visual change
  const n = tube.getAttribute("position").count;
  const bc = new THREE.Color(PALETTE.black);
  const col = new Float32Array(n * 3);
  for (let i = 0; i < n; i++) {
    col[i * 3] = bc.r;
    col[i * 3 + 1] = bc.g;
    col[i * 3 + 2] = bc.b;
  }
  tube.setAttribute("color", new THREE.BufferAttribute(col, 3));
  const pGeo = new THREE.SphereGeometry(0.05, 8, 6);
  const pMat = new THREE.MeshBasicMaterial({ color: "#ffffff", toneMapped: false });
  return [c, tube, 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 five-slot parameter array, the curve, the tube and its vertex-colour buffer, the pulse sphere and its one material (the tube wears the room's shared one), three scratch objects and one zero matrix. PULSE_MAX and the accent colour are module constants a few lines above.

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 get rebuilt in place the same way, for every instance. Skip that and five pulses at sixty frames a second are nine hundred short-lived objects per second, every one garbage before its frame ends. The cost isn't the allocation. It's 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 get 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. 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's been backgrounded for a minute and delta is a minute, so without Math.min(delta, 0.1) every pulse teleports the instant you look at it. Under reduced motion dt is 0 instead. The loop still runs and still writes, and the pulses 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. Why bother? Because a dot of constant size moving along a fixed path reads as a texture offset. Nothing about the shape changes, so the eye files it as a pattern scrolling. Give it an arc of scale and the motion gets a beginning, a middle and an end, and it starts reading as one thing travelling.

Colour is the accent, #38FFB7, the green 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 doesn't get 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.

P.S. This one is running on the site right now.

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

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E