A GPU agent-based-modeling layer in the spirit of NetLogo and AgentScript — "turtles + patches + links" — built on top of the particle system. An agent is a particle, so a model of hundreds of thousands of agents runs entirely on the GPU at interactive rates.
Source lives in src/agents/. One import:
import { agent, buildAgentConfig, type AgentModelConfig } from 'taos/agents/index.js';buildAgentConfig(model) lowers a declarative AgentModelConfig to a
ParticleGraphConfig that runs on the stock ParticlePass — no new pass, no new
shader plumbing. You feed the result to a ParticleFeature like any other
particle system.
Quick start#
A wandering swarm — the "hello world" of agent modeling. runAgentModel is the
one-call harness: give it your model and it wires the engine, a camera, the render
stack, and the frame loop. The page needs a <canvas id="canvas">.
import { agent } from 'taos/agents/index.js';
import { runAgentModel } from 'taos/agents/agent_app.js';
runAgentModel({
params: {},
model: () => ({
world: { min: [-50, -50], size: [100, 100], topology: 'torus' },
population: 100_000,
spawn: { color: [0.3, 0.8, 1.0, 1.0], size: 0.4 },
step: [
agent.turn(agent.randomTurn(0.3)), // jitter the heading
agent.moveForward(8), // step forward at 8 units/s
],
render: { shape: 'soft', blendMode: 'additive' },
}),
camera: 'tilted',
});That's a complete app: 100k agents wandering on a wrapping plane, all stepping in
one GPU dispatch per frame. The model callback returns an AgentModelConfig;
everything else the demo needs (control panel, patch-field ground views, stats,
seeding) is optional and covered in the run harness
below.
Doing it by hand.
runAgentModelis sugar over the engine. If you need full control (a custom render pipeline, embedding agents in a larger scene), lower the model yourself withbuildAgentConfigand wire the features:TypeScriptimport { buildAgentConfig } from 'taos/agents/index.js'; import { ForwardLitFeature, ParticleFeature, TonemapFeature } from 'taos/engine/index.js'; engine.addFeature(new ForwardLitFeature()); engine.addFeature(new ParticleFeature({ config: buildAgentConfig(myModel) })); engine.addFeature(new TonemapFeature({ aces: true })); engine.run();
The run harness — runAgentModel#
runAgentModel(options) (in taos/agents/agent_app.js) wires the engine, a dim
sun, a top-down orbit camera with touch/WASD controls, the
forward-lit → particle → tonemap render stack, optional patch-field ground views,
the frame loop, a per-frame stats line, and a DOM error catch — the boilerplate
every agent demo otherwise repeats. It looks the page up by element id
(#canvas, #stats, #controls by default) and returns the live
AgentApp handle (you usually ignore the return value and
work through the onReady/controls callbacks instead).
Options#
| Field | Type | What it does |
|---|---|---|
params | P (any) | Your mutable, live-tunable parameters. Mutate a field then call app.rebuild() to recompile the model. |
model | (params: P) => AgentModelConfig | Builds the model from the current params — your buildModel. Called on first build and on every rebuild. |
camera | 'tilted' | 'topdown' | CameraPreset | A named preset, or a partial override merged onto the tilted base: { position, pitch, yaw, fov, near, far, speed, sensitivity }. |
patchViews | PatchViewOptions[] | Patch fields to draw as ground planes under the agents (see below). |
extraFeatures | (app) => RenderFeature[] | Extra HDR features spliced in after the agents but before tonemap — e.g. an AgentLinkFeature drawing graph edges. The factory gets the live app so a feature's source closure can read app.particles.pass lazily. |
tuneConfig | (cfg: ParticleGraphConfig) => void | Escape hatch to mutate the lowered config for a knob AgentModelConfig doesn't expose (e.g. cfg.integrateVelocity = false, or an emitter spawnRate). Runs on build + every rebuild. |
controls | (panel, app) => void | Build the control panel (the #controls element is passed in). Pair with taos/html/control_panel.js (addSlider, addSelect, …). |
stats | (ctx, params) => string | The text shown in #stats each frame. |
onReady | (app) => void | Runs once after the engine + passes exist. Seed patch fields / agents / links here. |
rebuildDelay | number | Debounce (ms) for app.rebuild(). Default 150. |
canvasId / statsId / controlsId | string | Override the looked-up element ids. |
The AgentApp handle#
Passed to onReady and controls:
engine,ctx,particles— the underlyingEngine,RenderContext, andParticleFeature(escape hatch to anything not wrapped, e.g.app.engine.beforeFrame(...)for a periodic readback, orapp.particles.pass).params— the same object you passed in.rebuild(after?)— recompile the model from the currentparamsand hot-swap it (debounced). A structural rebuild discards patch-grid / agent / link state, so re-seed in theaftercallback:app.rebuild(() => app.seedPatch(0, grid)).seedPatch,seedAgents,setLinks,setScratch,readAgents,readScratch— upload/read GPU state (see next).
Patch ground views — patchViews and patchView()#
A model's patches are a GPU grid the agents sense and deposit into; on their own
they're invisible. A patch view draws one field as a colored ground plane
under the agents. Add one per field you want to see — the harness wires its GPU
buffer source and world rectangle for you (you only describe the look):
patchViews: [
// field 0 drawn with the default teal ramp
{ field: 0, intensity: 0.15 },
// field 1 drawn as a SOLID red (for a discrete state field, not a ramp)
{ field: 1, intensity: 1.8, tint: [1.0, 0.13, 0.05], flat: true },
]PatchViewOptions: field, tint (RGB recolor), intensity (value→color
scale), flat (solid tint×value instead of the ramp — for discrete state
fields), opaque (draw as solid ground under the agents instead of additive),
y (ground height), plus the two-field compare/field2/tint2/intensity2
mode and source markers (a nest/food square). Give two views of the same field
index distinct names to disambiguate them.
Call app.patchView(field) to get the live PatchFieldFeature back and tweak
it from a control — e.g. a glow slider or a show/hide toggle:
controls: (panel, app) => {
addSlider(panel, 'Glow', 0.2, 2, 0.05, 0.9, (v) => {
const view = app.patchView(0);
if (view) {
view.intensity = v;
}
});
},Seeding GPU state — seedPatch / seedAgents / setLinks / setScratch#
These upload CPU data into the freshly created pass. Call them from onReady (and
again inside a rebuild callback, since a rebuild recreates the pass and clears
its state).
app.seedPatch(field, data) — the AgentScript patches.importDataSet. data
is a resolution² row-major Float32Array: the cell at grid (x, z) lives at
index z * resolution + x, and field indexes the model's patches.fields. Use
it to set a grid's starting state — a Game-of-Life pattern, a forest, a wall map
for a flood-fill distance field:
const RES = 256;
function randomGrid(p: number): Float32Array {
const g = new Float32Array(RES * RES);
for (let i = 0; i < g.length; i++) {
g[i] = Math.random() < p ? 1 : 0;
}
return g;
}
runAgentModel({
params: { density: 0.3 },
model: () => ({
world: { min: [-50, -50], size: [100, 100], topology: 'torus' },
population: 1, // Life has no agents; one hidden particle drives the pass
patches: { resolution: RES, worldMin: [-50, -50], worldSize: [100, 100],
fields: [{ diffuse: 0, evaporate: 1, update: patch.gameOfLife(0) }] },
render: { blendMode: 'alpha' },
}),
camera: 'topdown',
patchViews: [{ field: 0, intensity: 0.9, tint: [0.45, 1.0, 0.65] }],
onReady: (app) => { app.seedPatch(0, randomGrid(app.params.density)); },
controls: (panel, app) => {
addButton(panel, 'Randomize', () => app.seedPatch(0, randomGrid(app.params.density)));
},
});The other three mirror it for the non-grid state:
app.seedAgents(data, count)— seed the initial population directly (forreproductionmodels, whose emitter is off).dataiscountpacked Particle records, 16 floats each.app.setLinks(data)— upload per-agent link slots (graph topology), anInt32Arrayofpopulation × linksPerAgentlinked indices (or-1).app.setScratch(data)— upload the per-agent scratch arrays, aFloat32Arrayofpopulation × slotsPerAgent(e.g. one tour per TSP traveler).
To measure a running model, app.readAgents(count?), app.readScratch(),
and app.readPatch(field?) (the read counterpart of seedPatch) copy the
buffers back (async — they synchronize the GPU, so call a few times a second, not
per frame). See Measurement & plots.
Agents on the globe (geospatial)#
The agent system runs on the geospatial stack — the AgentScript Leaflet/MapLibre
equivalent, but over real 3D terrain. The key: a GeoScene's world frame is a
local East-North-Up tangent plane (Y-up) at its origin, which is exactly the
flat XZ plane the agent sim already runs in. So anchoring the GeoFrame at a
lat/lon (at the flock's altitude) places the whole agent world on the globe with
no transform — agents compute in local XZ, render in world space, and terrain
streams below them. Insert the ParticleFeature into the deferred pipeline right
before tonemap (deferred lighting writes frame.hdr; the agents blend on top),
and geo.attach() streams the tiles. Keep the camera near the flock so the
floating-origin auto-reanchor doesn't fire (which would shift the local frame).
deferredPreset({ sky, ao })(engine);
const geo = new GeoScene(device, GeoFrame.atLonLat(lon, lat, altitude));
geo.addTerrain(...); geo.add3DTiles(...);
geo.attach(engine, { cameraGO });
const particles = new ParticleFeature({ config: buildAgentConfig(flock) });
engine.addFeatureBefore(particles, 'TonemapFeature'); // before tonemap, after lightingFor a sim that survives camera flight (the floating origin reanchors as you move),
pass ParticleFeature.worldTransform — a per-frame Mat4 mapping the sim's local
ENU plane to the live world frame, e.g.
worldTransform: () => mat4dToEngine(geo.frame.rigidFromSnapshot(snap)) (snapshot
the frame once at setup). It's applied in the particle render shader (the same
mat4dToEngine(rigidFromSnapshot) the geo tiles use), so the flock tracks the
globe exactly as the terrain does. The sim still computes in local space.
See samples/geo_agents.ts — boids flocking over
streamed Manhattan terrain + OSM buildings, depth-occluded by the city, reanchor-
robust via worldTransform.
Walking on the terrain surface#
To make agents follow the ground instead of flying at a fixed altitude, sample the
terrain into a patch field and have each turtle set its Y from it.
GeoScene.sampleHeightGrid(worldMin, worldSize, res) reads the terrain tileset over
the local ENU grid and returns local-Y offsets (curvature-corrected ground
height relative to the y = 0 plane), which you load into a static patch field:
// once terrain has streamed (poll geo.heightAt(anchorLon, anchorLat) !== null):
const grid = geo.sampleHeightGrid([-HALF, -HALF], [WORLD, WORLD], GRID);
particles.pass?.setPatchField(HEIGHT_FIELD, grid); // a static field: diffuse 0, evaporate 1Then the turtle step drops Y onto the sensed surface (after moving in XZ):
step: [
agent.face(cohesionTarget), agent.turn(agent.randomTurn(wander)), agent.moveForward(speed),
action.set('position', expr.vec3(
expr.swizzle(agent.position(), 'x'),
agent.sense(HEIGHT_FIELD, 'bilinear'), // ground height at this XZ
expr.swizzle(agent.position(), 'z'),
)),
],Re-sample the grid a few times as deeper terrain LODs stream in for a sharper
surface. See samples/geo_agents_ground.ts —
a herd flocking across the Grand Canyon, draped on the streamed terrain.
To make the flat arrows lie on the slope (instead of horizontal), compute the
terrain normal in the step — central differences of the height field — and store it
in the velocity field, then set render.tiltToSurface: true. Because that
repurposes velocity as a render channel, also set integrateVelocity: false on
the lowered config so the stored normal isn't treated as motion:
const normal = expr.normalize(expr.vec3(expr.neg(dhdx), 1, expr.neg(dhdz)));
// ...in step, after moving: action.set('velocity', normal)
// render: { shape: 'arrow', tiltToSurface: true }
const cfg = buildAgentConfig(model);
cfg.integrateVelocity = false; // velocity now holds the normal, not motionA diffusing field draped on the terrain#
A diffusing patch field (a pheromone trail) can be drawn following the ground, not
on a flat plane: give PatchFieldFeature a drape block. It draws a tessellated
grid mesh whose per-vertex Y comes from a height field and is placed by the same ENU
worldTransform, depth-tested against the terrain:
new PatchFieldFeature({
source: () => ({ dataBuffer: particles.pass?.patchBuffer ?? null,
uniformBuffer: particles.pass?.patchUniformBuffer ?? null }),
worldMin: [-HALF, -HALF], worldSize: [WORLD, WORLD],
field: TRAIL_FIELD, intensity: 0.06, tint: [0.5, 1, 0.7],
drape: { resolution: GRID, heightField: HEIGHT_FIELD, surfaceOffset: 2, worldTransform },
});A 2D top-down map (Leaflet / MapLibre)#
For the slippy-map view, point an orthographic camera straight down at the geo
scene; terrain drapes aerial imagery by default, so it reads as a basemap with the
agents as a flat overlay. See samples/geo_agents_map.ts
(drag pan, wheel zoom, P toggles 2D↔3D):
const cam = cameraGO.addComponent(Camera.createOrthographic(WORLD, 1, 200_000, aspect));
// ...look straight down; wheel: cam.orthoSize *= factor;
// toggle: cam.projectionType = CameraProjection.Orthographic | PerspectiveThe big idea: agent = particle, behavior = one compute shader#
The mapping onto the particle system is direct:
| Agent-modeling concept | Particle-system reality |
|---|---|
| agent / turtle | a particle |
| position | particle position (XZ ground plane) |
| heading (radians, 0 = +X) | particle rotation |
| species / state | usually color.a (also the sprite opacity) |
| per-turtle scalar (e.g. energy) | a free attribute (velocity models reuse rotation) |
| patches (a grid of values) | the patch layer: a diffusing/evaporating atomic-i32 grid |
neighbors (other turtles in-radius) | a GPU spatial hash |
| links | per-agent fixed-capacity link slots |
hatch / die | the GPU reproduction free-list |
The important difference from CPU AgentScript is how behavior is expressed. AgentScript runs imperative JavaScript per turtle every tick:
// AgentScript (CPU): a JS closure runs once per turtle, serially.
turtles.ask(t => {
t.right(util.randomCentered(0.2))
t.forward(1)
})Taos Engine instead builds a small expression graph with turtle-verb sugar, which is compiled once to a single WGSL compute shader and run for every agent in parallel:
// Taos (GPU): authored declaratively; compiles to one compute kernel.
step: [
agent.turn(agent.randomTurn(0.2)),
agent.moveForward(1),
]So you don't write per-agent loops — you describe the per-agent program, and the
GPU runs it across the whole population each frame. The verbs (forward, turn,
face, sense, deposit, …) are pure sugar over the particle expression IR
(expr/action), so a model composes freely with raw expressions when a verb
doesn't exist for what you need.
Intro tutorial#
This walks the same progression as AgentScript's
Snippets tutorial — create agents,
move them, give them a grid to sense, then make them interact — but using the
Taos Engine declarative API. Each step is a complete AgentModelConfig you can drop into
the Quick-start scaffold above (return it from the model callback).
1. Create agents#
Agents are seeded by the particle emitter from a region of the world. Default is the whole world; here we start them in a small central box.
{
world: { min: [-50, -50], size: [100, 100] },
population: 5_000,
spawn: { region: { kind: 'box', halfExtents: [5, 0, 5] }, color: [1, 1, 1, 1], size: 0.6 },
// no `step` yet — they just sit where they spawned
}Position-movement models get a random heading on spawn automatically, so they're ready to walk.
2. Move forward#
agent.moveForward(speed) advances each agent along its heading at speed
world-units per second (it scales by delta-time for you). For a raw
discrete jump use agent.forward(distance).
step: [ agent.moveForward(10) ]With topology: 'torus' agents that leave one edge reappear on the opposite one;
with topology: 'bounded' they clamp to the world rectangle. The wrap/clamp is
appended to your step program automatically.
3. Turn — wander#
agent.turn(angle) adds to the heading; agent.randomTurn(spread) is a fresh
random angle in [-spread, spread] each frame. Together they make a random walk:
step: [
agent.turn(agent.randomTurn(0.3)),
agent.moveForward(10),
]4. Head toward a target#
agent.face(targetVec3) snaps the heading to point at a world position;
agent.towards(target) returns that angle if you'd rather steer gradually.
import { expr } from 'taos/agents/index.js';
step: [
agent.face(expr.constVec3([0, 0, 0])), // turn to face the origin
agent.moveForward(10), // … and march toward it
]5. Add a patch grid (the environment)#
Patches are a grid of float cells the agents can read and write — pheromone,
food, temperature, terrain. Declare one or more fields with per-field diffuse
(spread to neighbors each frame, 0 = none) and evaporate (decay multiplier,
1 = none):
{
world: { min: [-50, -50], size: [100, 100], topology: 'torus' },
population: 100_000,
patches: {
resolution: 512, // 512×512 cells
worldMin: [-50, -50], worldSize: [100, 100],
fields: [{ diffuse: 0.2, evaporate: 0.97 }],
depositScale: 1024, wrap: true,
},
step: [
agent.turn(agent.randomTurn(0.3)),
agent.moveForward(10),
agent.deposit(0, 1.0), // drop 1.0 into field 0 at my cell
],
}agent.deposit(field, amount) adds to the cell under the agent (atomic). The
grid diffuses + evaporates on the GPU each frame — no extra code.
6. Visualize the grid#
Render a patch field as a colored ground plane under the agents by adding a
patchViews entry to the harness call — the buffer source and world rectangle are
wired for you:
runAgentModel({
params: {},
model: () => model, // the AgentModelConfig with `patches`
camera: 'tilted',
patchViews: [{ field: 0, intensity: 0.12 }],
});(Wiring it by hand instead — outside the harness — means a PatchFieldFeature
whose source returns particles.pass.patchBuffer / patchUniformBuffer; see
patchViews.)
7. Sense and steer (stigmergy)#
Reading the grid is what makes emergence happen. agent.sense(field) samples at
the agent; agent.senseAhead(field, dist) samples ahead along the heading. The
combined verb agent.steer(field, opts) does the classic three-sensor
("left / ahead / right") uphill steering and returns a turn delta:
step: [
// turn toward the strongest pheromone ahead, plus a little wander
agent.turn(expr.add(agent.steer(0, { dist: 3, angle: 0.45, turn: 0.6 }),
agent.randomTurn(0.08))),
agent.moveForward(14),
agent.deposit(0, 1.0),
]This is the full Physarum ("slime mold") model — see
samples/agent_slime.ts. Trail networks
self-organize from nothing but sense → steer → deposit.
8. Per-agent state#
Give agents a discrete state by writing color.a (alias alpha) and branching
on it with action.if_. Here ants flip between searching and carrying:
import { action } from 'taos/agents/index.js';
const carrying = expr.gt(expr.attribute('alpha'), 0.7);
step: [
action.if_(carrying,
[ agent.deposit(0, 5), /* … head home … */ ], // CARRYING branch
[ agent.deposit(1, 5), /* … seek food … */ ], // SEARCHING branch
),
agent.moveForward(11),
]See samples/agent_ants.ts (two pheromone fields)
and samples/agent_termites.ts (chip piling).
9. Neighbors — flocking#
Agent-to-agent behavior needs a spatial hash (neighbors) so each agent can
query others in a radius. The built-in boids behavior reads it. Velocity
models let the particle integrator do the moving, so they set
movement: 'velocity' and seed a velocity on spawn:
{
world: { min: [-60, -60], size: [120, 120] },
population: 16_000,
movement: 'velocity',
spawn: {
color: [0.2, 0.7, 1.2, 1.0], size: 0.5,
actions: [
action.set('rotation', expr.random({ mode: 'perSpawn', min: 0, max: Math.PI * 2 })),
action.set('velocity', expr.mul(agent.headingDir(), 13)),
],
},
neighbors: { resolution: 20, worldMin: [-60, -60], worldSize: [120, 120], capacity: 96 },
behaviors: [{
type: 'boids', perception: 6, separation: 2.5,
cohesionWeight: 1.0, alignWeight: 1.4, separationWeight: 1.2,
maxSpeed: 22, minSpeed: 10, worldHalf: 60, edgeMargin: 10, edgeForce: 55,
}],
render: { billboard: 'velocity', shape: 'soft', blendMode: 'additive' },
}See samples/agent_boids.ts,
agent_predator_prey.ts, and
agent_schelling.ts.
10. Links — a force-directed graph#
links gives each agent a fixed number of link slots; you upload the topology
once with ParticlePass.setLinks. The link_springs behavior pulls linked
agents together while the hash repels everyone, laying out a graph:
const particles = new ParticleFeature({ config: buildAgentConfig({
/* …world, population, movement:'velocity', neighbors… */
links: { linksPerAgent: 8 },
behaviors: [{ type: 'link_springs', restLength: 3, stiffness: 5,
repulsion: 9, repelRadius: 5, damping: 0.85,
maxSpeed: 45, worldHalf: 60, edgeMargin: 8, edgeForce: 90 }],
}) });
engine.addFeature(particles);
particles.pass?.setLinks(ctx, linkData); // Int32Array, population × linksPerAgent, slot = index or -1To draw the links as edges (not just lay out the nodes), add an
AgentLinkFeature. It reads the same particle + link buffers and renders one
flat ground-plane ribbon per undirected edge into the HDR target, under the
nodes:
import { AgentLinkFeature } from 'taos/renderer/features/agent_link_feature.js';
engine.addFeature(new AgentLinkFeature({
source: () => ({
particleBuffer: particles.pass?.particleBuffer ?? null,
linkBuffer: particles.pass?.linkBuffer ?? null,
linksPerAgent: particles.pass?.linksPerAgent ?? 0,
count: particles.pass?.maxParticles ?? 0,
}),
color: [0.35, 0.55, 1.0], intensity: 0.5, width: 0.25,
}));edges.width / edges.intensity are live setters, and edges.enabled toggles
the layer. See samples/agent_links.ts.
11. Births and deaths#
For populations that grow and shrink, set reproduction: true. The emitter turns
off; you seed the starting population directly with ParticlePass.seedAgents, and
the GPU manages a free-list so behaviors can spawn (hatch) and kill (die)
agents. The lotka_volterra behavior uses this for a true predator-prey cycle:
const particles = new ParticleFeature({ config: buildAgentConfig({
world: { min: [-60, -60], size: [120, 120] },
population: 50_000, // carrying capacity = free-list size
movement: 'velocity',
reproduction: true,
neighbors: { resolution: 17, worldMin: [-60, -60], worldSize: [120, 120], capacity: 96 },
behaviors: [{ type: 'lotka_volterra', /* …vital rates… */ }],
}) });
engine.addFeature(particles);
particles.pass?.seedAgents(ctx, seedData, seedCount); // Float32Array, 16 floats/agentSee samples/agent_lotka.ts — populations boom
and bust in the classic oscillation.
Visual authoring (node editor)#
A model's per-step turtle program can also be built visually in the editor's
particle graph editor instead of in code. The turtle verbs are registered as
expression-graph nodes (in the shared NODE_KINDS registry), so they appear in
the Add-Node menu under Expression ▸ Agent (turtles):
- Sources (value nodes): Agent Heading, Agent Forward Dir, Agent Sense, Agent Sense Ahead, Agent Steer, Agent Random Turn, Agent Heading To, Sample Patch.
- Effects (run in order): Agent Forward, Agent Move Forward, Agent Turn, Agent Set Heading, Agent Face, Agent Deposit, Deposit Patch.
These compile to exactly the same WGSL as the code-side agent.* verbs (e.g.
Agent Steer → the nested three-sensor select). Wire Agent Steer → Agent
Turn, add Agent Move Forward and Agent Deposit, and you've drawn the Physarum
step loop. The model-level configuration (world, patches, neighbors, links,
reproduction, behaviors) is still authored in code via AgentModelConfig for now.
Standalone editor: the samples/agent_graph_editor.html sample mounts the
same editor widget in the browser, configured for agents: an agent template set
(Wander / Seek / Slime), an orbit camera that frames the turtle field, no
ground plane or bloom, and an auto-provided diffusing patch grid (the agent
"world") so patch models run. It's a thin host over the editor's
createParticleEditor widget (via options: templateSet: 'agent',
cameraMode: 'orbit', showGround/showBloom: false, agentWorld: true), so
there's no duplicated editor code. The toolbar's Mouse: Edit / Navigate toggle
switches the mouse between editing the node graph and orbiting the 3D view
(drag = orbit, wheel = zoom; WASD always flies). The patch grid is also visualized as a colormapped ground plane, so the Slime
template's pheromone trails show under the turtles.
Templates cover all 8 samples in-editor: Wander / Seek / Slime (movement +
patches), Boids / Predator-Prey / Schelling (neighbor behaviors), Termites /
Ants (per-agent state via when-guards), Links (force-directed graph) and
Lotka-Volterra (births + deaths). The agent editor supplies whatever each
needs: a spatial hash for neighbor behaviors, a link buffer + community topology
for link_springs, and the reproduction free-list + a seeded two-species
population for lotka_volterra. (Links draws its edges as ground-plane
ribbons under the nodes. Lotka is extinction-prone — its vital rates are editable
on the node.)
The agent world provides three patch fields: two diffusing (pheromone trails — teal field 0, blue field 1) and one static (termite chips — amber field 2), all shown as colormapped ground planes. Link nodes are colored by community and their edges drawn as ground ribbons.
Neighbor queries use a counting-sort spatial hash (count → prefix-sum →
scatter into a compact sorted array), so there's no per-cell capacity cap —
dense clusters never drop neighbors, and the memory is O(cells + agents). The
capacity field on SpatialHashConfig is legacy/ignored.
Per-agent state / branching is done with the conditional when input on
effect nodes: wire a bool (from Greater Than / And / Not etc.) into an
effect's when port and it runs only when true — that's how the Termites template
flips carrying↔searching. Unwired, an effect always runs.
The node canvas supports marquee select (left-drag empty to rubber-band; Shift-drag adds; Shift-click toggles a node), and selected nodes move and delete as a group.
API reference#
AgentModelConfig#
interface AgentModelConfig {
world: { min: [number, number]; size: [number, number];
topology?: 'torus' | 'bounded' | 'bounce' | 'die' };
population: number; // max agents (sizes the buffer)
movement?: 'position' | 'velocity'; // default 'position'
spawn?: AgentSpawn;
patches?: PatchConfig; // a diffusing grid to sense/deposit
neighbors?: SpatialHashConfig; // enables agent-to-agent behaviors
links?: LinksConfig; // per-agent link slots
reproduction?: boolean; // GPU births/deaths
step?: Action[]; // per-agent per-frame program
behaviors?: ModifierNode[]; // built-in neighbor behaviors
render?: { shape?; blendMode?; billboard?; sizeMul?; emit?; stretch?; thickness? };
}movement: 'position'— agents move byforward/moveForward; the world edge behavior is appended tostepautomatically:toruswraps,boundedclamps,bouncereflects the heading off the walls,dieexpires agents that leave (the emitter refills them).movement: 'velocity'— the particle integrator moves agents by theirvelocity; a behavior's edge force keeps them in bounds. Default render billboard isvelocity(sprites stretch along travel).
Breeds (N named turtle types)#
Give each agent a breed — a NetLogo/AgentScript named type with its own
behavior — by storing a breed id in color.a and branching per breed. Under
additive arrow rendering color.a is free (the fragment outputs color.rgb * emit), so put the breed's visible color in color.rgb:
spawn: { actions: [
action.set('alpha', breedId), // breed id → color.a
action.if_(agent.isBreed(0), [action.set('rgb', expr.vec3(0.3, 1, 0.45))]),
// ...one per breed
] },
step: [
action.if_(agent.isBreed(0), [ agent.face(center), agent.moveForward(10) ]),
action.if_(agent.isBreed(1), [ agent.turn(agent.randomTurn(0.35)), agent.moveForward(9) ]),
],agent.breed() reads the id, agent.isBreed(k) tests it, agent.setBreed(k)
writes it. See samples/agent_breeds.ts — three
breeds bouncing in a box. Distance sugar (agent.distance(target) /
agent.distanceXY(x, z)) gates behavior by proximity.
AgentSpawn#
interface AgentSpawn {
region?: SpawnShape; // default: a box over the whole world
color?: [r, g, b, a]; // initial color (a often = state/species)
size?: number; // sprite size
actions?: Action[]; // custom on-spawn program
}If actions is omitted, position models get a random heading; supply actions
to assign species, seed velocity, etc. (Ignored when reproduction is set — seed
those with seedAgents.)
Turtle verbs — agent#
| Verb | Meaning |
|---|---|
agent.position() | this agent's position (vec3) |
agent.heading() | heading angle (radians) |
agent.headingDir() | unit forward vector (cos h, 0, sin h) |
agent.forward(dist) | move dist units along the heading |
agent.moveForward(speed) | move at speed units/second (×dt) |
agent.turn(angle) | add angle to the heading |
agent.setHeading(angle) | set the heading absolutely |
agent.towards(target) | angle pointing toward target (vec3) |
agent.face(target) | turn to face target immediately |
agent.sense(field, filter?) | sample a patch field at this agent |
agent.senseAhead(field, dist, filter?) | sample dist ahead along the heading |
agent.senseAt(field, dist, angleOffset, filter?) | sample at an offset angle (an antenna) |
agent.deposit(field, amount) | atomic-add into a patch field at this cell |
agent.randomTurn(spread, seed?) | random angle in [-spread, spread] per frame |
agent.steer(field, opts) | three-sensor uphill steering → turn delta |
agent.neighborCount(r) | number of other agents within r (f32) |
agent.neighborCenter(r) | center of mass of neighbors within r (vec3) — cohesion |
agent.neighborVelocity(r) | average velocity of neighbors within r (vec3) — alignment |
agent.neighborSeparation(r) | sum of normalized away-vectors (vec3) — separation |
agent.nearest(r) | offset (vec3) to the nearest neighbor within r (zero if none) |
agent.neighborsAhead(r, halfAngle) | count of neighbors within r and the forward vision cone (in-cone) |
The neighbor queries are the NetLogo/AgentScript in-radius family — generic
reductions over the agents within a radius, evaluated against the spatial hash, so
you can hand-author flocking/segregation/etc. in the step program instead of
using a built-in behaviors modifier. They require neighbors on the model and
run in the update step only. See samples/agent_flock.ts
— a full flock authored from neighborCenter + neighborSeparation, no boids
behavior.
Every verb returns plain Expr/Action nodes, so you mix them with the raw
expression IR (expr.add, action.if_, action.set, expr.select, …) freely.
Turtle looks (sprite shapes)#
render.shape picks how each agent draws. For the NetLogo/AgentScript "turtle"
look use 'arrow' — a filled chevron that lies flat on the ground and points
along the agent's heading (its rotation, or its velocity for velocity-driven
models). It's self-orienting, so it ignores render.billboard; it reads best in
the top-down agent views. Arrows are small by default — bump render.sizeMul
(≈3×) so the heading is legible:
render: { shape: 'arrow', sizeMul: 3.0, blendMode: 'additive' }Other useful shapes: 'soft' (radial puff, the default — good for dense trail
fields like slime/ants), 'pixel' (solid square), 'line' (a velocity-stretched
streak; pair with billboard: 'velocity'), and the VFX shapes 'ember' /
'fire' / 'smoke'. samples/agent_boids.ts and agent_slime.ts expose a live
Sprite selector so you can compare.
Lowering#
buildAgentConfig(model) returns a ParticleGraphConfig. Models with:
linksupload topology viaParticlePass.setLinks(ctx, Int32Array).reproductionseed the initial population viaParticlePass.seedAgents(ctx, Float32Array, count).patchescan be seeded from a CPU raster viaParticlePass.setPatchField(ctx, field, Float32Array)— the AgentScriptimportDataSet. Load a heightmap / density / cost grid into a field (give itdiffuse: 0, evaporate: 1to keep it static) and the agentssense/steeron it. Seesamples/agent_data_field.ts.
Reach the pass through the feature: particles.pass?.setLinks(...).
Measurement & plots (reading state back)#
The model side of AgentScript's exportDataSet / live plots — read GPU state
back to the CPU to count, measure, or plot it:
ParticleFeature.readPatchField(field) → Promise<Float32Array>— the current patch field as a row-majorresolution²grid (in field units; the AgentScriptpatches.exportDataSet). The measurement counterpart ofsetPatchField.ParticleFeature.readAgents(count?) → Promise<Float32Array>— a snapshot of the agent records, 16 floats each:[0..2]position,[3]life (< 0= dead slot — skip when counting),[4..6]velocity,[7]max_life,[8..11]color (color.acarries species/state),[12]size,[13]rotation (heading, or energy inlotka_volterra),[14]flags,[15]pad.
Both are async and synchronizing — they copy to a staging buffer and map it,
so call them a few times a second for measurement, never every frame. (The
underlying ParticlePass.readPatchField / readAgents take the RenderContext.)
samples/agent_lotka.ts draws a live predator/prey
population plot this way: every ~0.4 s it readAgents(), counts live agents by
species, and scrolls a time-series — the classic Lotka-Volterra boom-bust, measured.
const agents = await particles.readAgents();
let prey = 0, pred = 0;
for (let i = 0; i < agents.length; i += 16) {
if (agents[i + 3] >= 0 && agents[i + 12] > 0) { // alive & active
if (agents[i + 11] > 0.7) { pred++; } else { prey++; } // color.a = species
}
}AgentScript parity#
| AgentScript / NetLogo | Taos Engine equivalent |
|---|---|
turtles / breeds | the particle population; breeds via color.a |
turtle.forward(d) / fd | agent.forward(d) / agent.moveForward(speed) |
turtle.right/left(a) | agent.turn(±a) |
turtle.face(agent) | agent.face(target) |
turtle.towards(agent) | agent.towards(target) |
patches / patch.sprout | the patches grid + agent.deposit |
patch.diffuse / decay | per-field diffuse / evaporate |
turtle.patchAhead(d) | agent.senseAhead(field, d) |
other turtles in-radius r | neighbors (spatial hash) + a behaviors modifier |
links / link-neighbors | links + the link_springs behavior |
turtle.hatch / die | reproduction + the free-list (lotka_volterra) |
ask turtles [ … ] | the step program (runs for all agents in one dispatch) |
world.torus / box topology | world.topology: 'torus' | 'bounded' |
patches.exportDataSet / plots | readPatchField(field) / readAgents() (measurement layer) |
The headline difference: AgentScript's ask runs imperative JS per turtle on the
CPU; Taos Engine compiles the equivalent program to one GPU kernel, trading
arbitrary-JS-per-turtle flexibility for ~1000× the population at interactive
frame rates.
Defining your own model#
The AgentModelConfig is the user-facing surface for a custom model — there's
no separate plugin or registration step. You write a function that returns a
config (so live UI can rebuild it), exactly as the samples do:
function buildMyModel(p: MyParams): AgentModelConfig {
return {
world: { min: [-50, -50], size: [100, 100], topology: 'torus' },
population: p.count,
patches: { /* … if you need an environment … */ },
step: [ /* … your turtle program, authored with `agent` + `expr`/`action` … */ ],
render: { shape: 'soft', blendMode: 'additive' },
};
}Three levers cover most models:
step— the per-agent program. Composeagentverbs and theexpr/actionIR. Branch on state withaction.if_; store state incolor.aand a spare attribute. This is yourask turtles [ … ].patches— the shared environment agents read and write (stigmergy).neighbors/links/reproduction— opt into the heavier machinery when agents must see each other, be connected, or be born/killed. The agent-to-agent logic for those currently lives in the built-inbehaviors(see below); the pure-expressionstepcannot itself iterate neighbors.
If your interaction rule isn't covered by an existing behavior, add a new
ModifierNode variant in src/particles/particle_types.ts
and its WGSL in src/particles/particle_builder.ts,
following boids / predator_prey as templates — it then becomes available to
every model via behaviors.
Built-in behaviors#
Neighbor/structure behaviors that don't fit the pure-expression step:
| Behavior | Requires | Does |
|---|---|---|
boids | neighbors | cohesion + alignment + separation flocking |
predator_prey | neighbors | two species (flag in color.a): chase / flee / herd |
schelling | neighbors | segregation: relocate unhappy agents |
link_springs | neighbors + links | spring layout of a graph |
lotka_volterra | neighbors + reproduction | predator-prey with births + deaths |
Constraints & gotchas#
- WebGPU 4-bind-group limit. Data + uniforms take two groups, leaving room
for at most two of
{patches, neighbors, links, reproduction, heightmap, sdf}at once.buildUpdateShaderthrows if you exceed it. - Heading lives in
rotation. Velocity-billboard models reuserotationto store a scalar (e.g. energy inlotka_volterra) since the billboard derives orientation from velocity, not rotation. stepcan't iterate neighbors. Per-agent expressions can sense the patch grid but not loop over other agents — that's whatbehaviors(the hash) are for.- Patch deposits are integer-quantized. Floats are scaled by
depositScale(default 1024) into atomic i32 cells. Raise it if small deposits vanish. - Determinism.
expr.random({ mode: 'perSpawn', seed })returns the same value for a given agent at every reference, so you can derive a species once and reuse it across severalsetactions without a shared variable.
Samples & tests#
Eight runnable samples under samples/, each with a live
control panel:
| Sample | Primitives exercised |
|---|---|
agent_slime | patches, sense/steer/deposit (Physarum) |
agent_ants | per-agent state, two pheromone fields |
agent_termites | state + static-grid aggregation (piling) |
agent_boids | neighbors (spatial hash), flocking |
agent_predator_prey | neighbors, two species |
agent_schelling | neighbors, position-movement relocation |
agent_links | links + spring layout + edge rendering (AgentLinkFeature) |
agent_lotka | reproduction (free-list), full predator-prey cycle |
Codegen unit tests: tests/agents/agent_model.test.ts.
On-GPU numerical tests (npm run test:gpu): tests/gpu/ —
runs a model for N steps in a real browser, reads buffers back via async map, and
checks the results numerically (patch mass conservation, hash bin counts, turtle
forward displacement).
See TODO/gpu-agent-based-modeling.md
for the design + roadmap.