Skip to content
07 / 103D7 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.

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 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 aren't destroyed. They're simply not visited.

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 / 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. 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 staggers them along the curve, and the shipped component does the same 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 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. It reads as a bug in the animation, when it's actually a property of the spline.

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;
Everything this component will ever allocate, allocated once: the five-slot parameter array, the curve, the tube, the pulse sphere, two materials, 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
    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), []);

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;
    }
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 aren't removed. They 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