b3d-crowd

Many animated figures, one draw call. The Babylon half of vertex-animation — a baker, a material plugin that replays the bake in the vertex shader, and thin instances so the CPU does nothing per figure per frame.

Demo — the bench

Drag figures up and watch GPU worst, not the wall clock. The slider is LOGARITHMIC and goes to 200,000 on purpose: the first version stopped at 4000 and the answer came back "flat, 18ms at any number", which is what you measure when the load never bends anything and vsync is doing the talking.

⚠️ No GPU timer in Safari. WebKit has never shipped EXT_disjoint_timer_query — it is a timing-attack surface — so the GPU line reads there and no amount of asking will change it. Chrome gives it to you. Where it is missing the only reading available is the wall clock, which means the bench can see cost only ONCE YOU ARE OVER BUDGET: under ~16.7ms it can tell you that you fitted and nothing else. That is often enough (200,000 figures at 33ms is a real measurement) and it is worth knowing the floor is blind.

⚠️ wall is not a cost. It is the gap between frames, and with vsync on it reads ~16.7ms however little work you do. A flat 18ms means "we never missed a frame" — excellent news, and no information about the crowd. GPU is the number: EXT_disjoint_timer_query asking the hardware how long it actually took. Where the extension is missing it says so rather than reporting zero.

The reading is in the Perf Stats panel under Crowd — figures, draw calls, this frame, the worst since you last moved the slider, and the budget it is being judged against. reset worst is a button because in a headset there is no console to clear, and because the worst you care about is the worst since the last thing you changed.

import { b3d, b3dSun, b3dSkybox, b3dLight, b3dCrowd, slider3d, label3d, toggle3d } from 'tosijs-3d'
import { orbitCam } from 'tosijs-3d/demo-utils'
import { tosi } from 'tosijs'

// BOUND, not literal. The panel is REBUILT whenever it reopens — maximising the
// demo does it — and a control whose `value` is a literal comes back holding
// that literal, so everything you had set is silently lost. Tonio: "the
// settings for the panel aren't properly bound so on refresh the current values
// get lost."
//
// A tosijs leaf passed as `value` is a boxed proxy the control reads and writes
// through, so the panel can be rebuilt any number of times and still show the
// truth. It is also what keeps the flat and in-VR panels agreeing, since both
// bind the same leaf.
//
// (Line comments. A block comment in a fence closes the enclosing doc comment —
// the third time this file has taught me that.)
// ONE number, used by both. The bound default and the element's `count` are the
// same fact, and writing it twice is how the panel came up saying 2000 over a
// crowd of 200 — a control that lies about the thing it controls, and no way to
// tell except by counting figures.
const FIGURES = 400
const demo = tosi({ crowdBench: { figures: FIGURES, interp: true, skinned: 0 } })
const s = demo.crowdBench

let crowd = null
let scene = null

const panel = () => [
  label3d({ text: 'Crowd bench' }),
  slider3d({
    label: 'figures', value: s.figures, min: 1, max: 200000, scale: 'log', showValue: 'always',
    handleChange: (v) => { if (crowd) crowd.count = Math.round(v) },
  }),
  toggle3d({
    label: 'interpolate frames', value: s.interp,
    handleChange: (v) => { if (crowd) crowd.interpolate = v ? 'on' : 'off' },
  }),
  slider3d({
    label: 'skinned baseline', value: s.skinned, min: 0, max: 400, step: 1, showValue: 'always',
    handleChange: (v) => { if (crowd) crowd.skinned = Math.round(v) },
  }),
  label3d({ text: 'Perf Stats → Crowd for the numbers', muted: true }),
]

crowd = b3dCrowd({ count: FIGURES, spread: 80, bakeFps: 10 })

scene = b3d(
  {
    style: 'width:100%;height:100%',
    scenePanelOpen: true,
    scenePanel: panel,
    sceneCreated(el) {
      orbitCam(el, { alpha: -1.1, beta: 1.12, radius: 85, target: [0, 2, 0] })
    },
  },
  b3dSun({}),
  b3dSkybox({ timeOfDay: 10 }),
  b3dLight({ intensity: 0.45 }),
  crowd
)

preview.append(scene)
.preview { height: 100%; }

Does it actually work?

The shader is the part that fails silently: a VAT that will not compile leaves a black canvas, which looks exactly like a camera pointing the wrong way. So the page checks itself.

⚠️ The block below goes EMPTY on purpose, and that is the check passing. It builds a crowd, waits for the material to become ready, and then removes its scene — because it is the page's second WebGL context and Safari counts those much more tightly than Chrome. An empty box here means the assertion ran; look at the test badge, not at the box.

import { b3d, b3dLight, b3dCrowd } from 'tosijs-3d'
import { orbitCam } from 'tosijs-3d/demo-utils'

test('the crowd builds, and its shader COMPILES', async () => {
  const crowd = b3dCrowd({ count: 32, spread: 6, bakeFps: 8 })
  const scene = b3d(
    {
      style: 'width:320px;height:200px',
      // A camera, or this is a black box in the docs even when it passes — and
      // a black box beside the words "does it actually work?" answers itself
      // wrongly.
      sceneCreated: (el) => orbitCam(el, { alpha: -1.2, beta: 1.15, radius: 14, target: [0, 1, 0] }),
    },
    b3dLight({ intensity: 0.9 }),
    crowd
  )
  preview.append(scene)

  // The scene mounts on its own schedule; poll rather than guess a delay.
  const until = async (why, fn) => {
    for (let i = 0; i < 200; i++) {
      if (fn()) return
      await new Promise((r) => setTimeout(r, 50))
    }
    throw new Error(why)
  }

  await until('scene never came up', () => scene.scene != null)
  let mesh = null
  await until('no crowd mesh', () => {
    mesh = scene.scene.meshes.find((m) => m.name === 'crowd-figure')
    return mesh != null
  })

  // One draw call for all of them — the whole claim.
  expect(mesh.thinInstanceCount).toBe(32)

  // `isReady` is the assertion that matters. A material whose vertex shader
  // failed to compile never becomes ready, and nothing else here would notice —
  // the canvas simply stays black, which is indistinguishable from a camera
  // pointing at nothing.
  //
  // (LINE comments, not a block one: a close-comment token inside a fence ends
  // the enclosing doc comment, and every line after it becomes TypeScript. That
  // is tosijs-ui#142's third trap — and note this warning cannot SPELL the
  // token either, which is the same joke the original report made about itself.)
  await until('the VAT shader never compiled', () => mesh.material.isReady(mesh))
  expect(mesh.material.isReady(mesh)).toBe(true)

  // HAND THE CONTEXT BACK. This scene is the page's SECOND WebGL context, and
  // Safari caps contexts far more tightly than Chrome — a test that keeps one
  // for the life of the page leaves a black rectangle under the words "does it
  // actually work?", which answers them wrongly. Removing the element disposes
  // the engine (see tosi-b3d's teardown), so the check costs a context for a
  // few seconds rather than for the session.
  scene.remove()
})

Where it sits: the third rung of the ambient ladder

Three tiers of "many things", and they are not competitors:

what it draws cost for
b3d-ambient camera-facing billboards — motes, rain, bubbles almost nothing dressing you look past
ambient-leaves tumbling two-sided quads, a SolidParticleSystem small things needing a 3-D attitude
this animated MESHES, one draw call a texture and two fetches things that must be ALIVE

A bird is not a billboard: it flaps, it banks, and its silhouette changes. That is the gap this fills — and b3d-ambient's existing budget allocator is the right thing to route it through, because it already knows how to switch an effect OFF rather than thin it, which is the correct answer when a flock will not fit.

The target, and what it means that we cleared it

The game this was built to answer for is a virtual miniatures battle. Tonio's original ran on an Amiga 500 — 7MHz, 320×200, 16 colours, about 10fps — where a unit of regulars was 15 figures and one of irregulars 7, and an army was three to nine units laid out three wide. So:

largest army 9 units × 15 = 135 figures
a whole battle ~270
measured here 200,000 at 33ms
headroom ~740×

That is not "we can do it". That is the constraint having moved somewhere else entirely, which is the outcome worth acting on rather than celebrating.

So figure COUNT should stop shaping the design. The Amiga's answer to 270 figures was two or three animation states and 4-bit sprites; ours does not have to be, and the budget freed should go where this project's north star says it goes — agents and reactions, not vertices (AI-DESIGN.md). At 270 figures every one of them can afford a real sensorium, its own equipment via sockets, and a bake rate high enough that clip blending is not a luxury reserved for wildlife.

And it reopens a question the bench was built to close. If 270 is this far inside the envelope, the honest next question is whether the vertex-animated path is needed for this game at all — a skinned b3d-biped may handle 270 perfectly well, in which case VAT is the tool for background fauna and for scenes an order of magnitude larger, not for the battle. That is exactly what the skinned baseline below is for, and it is now the only number this bench still owes.

⚠️ Rendering is not the expensive part, and this bench only measures rendering

Tonio: "I imagine things like collision detection and so on could vastly outweigh the animation costs." Almost certainly, and it is worth being explicit that this bench cannot see any of it. One draw call is a claim about the GPU; collision, steering and AI are CPU work per figure per frame, and that is the cost that does not get instanced away. A result of 200,000 at 33ms says the drawing is free. It says nothing about the thinking.

The original game already had the answer

"In the original game the figures were walking on a virtual game board and basically offset within a square, so basically collision detection was just were you trying to enter an occupied square."

That is not a concession to a 7MHz 68000 — it is the right design, and it should be copied rather than out-grown:

grid occupancy continuous collision
cost of a move O(1) — is that cell taken? broadphase + narrowphase against neighbours
cost at N figures O(N) O(N·k), and k grows with density
formations fall out — a rank IS a row of cells emergent, and fight the solver
"can I stand there?" a lookup a query with a tolerance you tune forever

A figure being offset within its square is what buys the look back: the occupancy is discrete and the pose is continuous, so it reads as a crowd rather than as a chessboard. That separation is the whole trick, and it is worth writing down before anyone reaches for a physics engine.

world-topology.ts already carries the coordinate-free half of this idea (places, portals, containment), and terrain-grid.ts the tile maths. A battle grid is closer to those than it is to b3d-collisions.

The baseline is the point of comparison

skinned spawns N clones of a real rigged GLB, each with its own skeleton and AnimationGroup — which is what a b3d-biped does and what the vertex-animated path deliberately does not. Tonio: "the old omnidude mesh had a modest vertex count. That might let you test a skinned mesh without much effort."

Run the two side by side at the same count and the RATIO is the answer to whether "actors and crowd" is a real boundary or an unnecessary one. Its slider stops at 400 rather than 200,000, which is itself part of the finding.

Attributes

Attribute Default Description
count 200 How many figures. One draw call whatever it is
spread 60 Metres across the field they scatter over
bakeFps 10 Frames baked per second of clip — the memory knob
interpolate 'on' 'off' snaps to the nearest frame: cheaper, jerkier
skinned 0 How many SKINNED clones to spawn alongside, as the baseline
skinnedUrl '/omnidude.glb' The GLB the baseline clones

Why a plugin and not a ShaderMaterial

A bench that skips lighting measures the wrong thing. MaterialPluginBase injects into the STANDARD material, so these figures are lit, fogged and shadowed exactly like everything else and the number means something. Same route biome-plugin takes, for the same reason.

What the shader does per vertex

Two texture fetches and a mix. Everything else — which clip, how far through it, how fast — arrives as a per-instance attribute, and phase is derived from a single vatTime uniform rather than written per figure per frame. That is the whole trick: the CPU touches nothing once the crowd is built.

The bench is deliberately synthetic

The figure is generated, not loaded, and its animation is a procedural walk. That is on purpose: the question is what the RENDERING architecture costs at N, and loading real assets would fold an asset pipeline into a measurement that is not about one. Baking from a real skinned GLB is the next step, and the layout it bakes into is already fixed and tested.