Taos Engine · Agent Tutorials

Agent Tutorials · Step 11

Runoff on Real Terrain (3D agents + geo)

▶ Live demo: run this tutorial — built from src/11_runoff_on_terrain.ts. Distilled from samples/agent_droplets_3d.ts.

Tutorial 10 put a herd on the globe. Here the agents are governed by the 3D terrain itself: thousands of rain droplets read the streamed elevation, flow down the steepest descent of the real Santa Fe, NM watershed, and pool in its canyons — a NetLogo "Runoff / Grand Canyon" model on actual photo-textured terrain. Keyless (AWS Terrarium elevation + Esri aerial imagery; no account). Read tutorial 10 first — this reuses its geo scaffold and the GeoScene basics (anchor, terrain, attach).

The geo scaffold (recap)#

Same setup as tutorial 10: anchor a GeoScene at a lat/lon, stream a keyless raster-DEM terrain draped with aerial imagery, and add the agents just before tonemap. The scene's world frame is a local East-North-Up tangent plane — the same flat XZ plane the agent sim runs in.

TypeScript
deferredPreset({ sky: { kind: 'color', color: [0.62, 0.74, 0.92, 1] }, ao: 'ssao' })(engine);
const geo = new GeoScene(engine.ctx.device, GeoFrame.atLonLat(LON, LAT, ALTITUDE));
const terrain = geo.addRasterDemTerrain(AWS_TERRARIUM, { imagery: ESRI_WORLD_IMAGERY }).tileset;
geo.attach(engine, { cameraGO });

const particles = new ParticleFeature({ config: cfg, worldTransform });   // worldTransform: see tut 10
engine.addFeatureBefore(particles, 'TonemapFeature');

And the terrain surface streams into a patch field, exactly as before (GeoScene.sampleHeightGrid → setPatchField, re-sampled a few times as deeper LODs arrive). What's new is what the step program does with that height field.

Idea 1 — Steepest-descent flow#

Tutorial 10's herd steered (turned gradually toward a target). Water can't do that: a droplet that merely turns toward the low side, while moving forward at a constant speed, coasts straight up the far wall of a canyon before it can come around — water appearing to run uphill. So instead of steering, read the terrain gradient directly from the height field (central differences) and point the droplet down it every step, with a speed scaled by the local slope:

TypeScript
const pos = agent.position();
const E = 60;   // gradient half-step (~2 height-field cells), in meters
const h = (dx, dz) => expr.patchSample(HEIGHT_FIELD, expr.add(pos, expr.vec3(dx, 0, dz)), 'bilinear');
const dhdx  = expr.div(expr.sub(h(E, 0), h(-E, 0)), 2 * E);
const dhdz  = expr.div(expr.sub(h(0, E), h(0, -E)), 2 * E);
const slope = expr.length(expr.vec2(dhdx, dhdz));            // |∇h|, dimensionless
const downhill = expr.sub(pos, expr.vec3(dhdx, 0, dhdz));    // a point down the slope
const speed = expr.mul(40, expr.saturate(expr.mul(slope, 6)));

step: [
  agent.face(downhill),                 // heading reset straight downhill every frame
  agent.turn(agent.randomTurn(0.05)),   // a hair of wander → braided rivulets
  agent.moveForward(speed),             // fast on steep ground, zero on flat
  /* … fade + terrain-follow … */
]

Because the heading is reset downhill each step and speed → 0 as slope → 0, a droplet physically cannot climb: it races down steep canyon walls, trickles on gentle ground, and stops dead where the terrain flattens — pooling in the drainages.

Idea 2 — A born → flow → die lifecycle#

Agents have ~infinite life by default (buildAgentConfig sets lifetime: [1e9, 1e9]), so a one-time fill drains into the basins once and the canyons then sit still. Real rivers need a steady supply. Give each droplet a finite lifetime and a steady emission rate so the population is perpetually born, flows, and dies — continuous rainfall feeding the network:

TypeScript
const cfg = buildAgentConfig(dropletModel());
cfg.emitter.lifetime  = [5, 11];                       // finite life (s)
cfg.emitter.spawnRate = POP / 8;                       // ≈ population / avg-life → steady count

To keep that recycling from popping in and out, fade each bead by its own age so its size reaches 0 exactly at death (and birth):

TypeScript
const na = expr.attribute('normalized_age');           // 0 at birth → 1 at death
const fade = expr.mul(
  expr.saturate(expr.div(na, 0.06)),                   // ramp in over first 6% of life
  expr.saturate(expr.div(expr.sub(1, na), 0.22)));     // ramp out over last 22%
// …in the step: action.set('size', expr.mul(BASE_SIZE, fade))

No teleport, no hard cutoff — a droplet eases in upstream, flows down, and fades out wherever its life ends, while fresh rain keeps falling. The terrain-follow line is unchanged from tutorial 10 (action.set('position', vec3(x, sense(HEIGHT)+offset, z))), so every bead rides the real surface.

Seeing the network#

geo.setVisible(terrain, false) hides the photo terrain while it keeps streaming — so the height field stays live and the droplets keep flowing — and dropping the sky to near-black makes the additive rivers glow as a bare drainage network. The demo binds this to L:

TypeScript
const sky = engine.getFeature<ConstantColorSkyFeature>('ConstantColorSkyFeature');
geo.setVisible(terrain, landscape);
sky.color = landscape ? [0.62, 0.74, 0.92, 1] : [0.01, 0.012, 0.02, 1];

Where to go next#

You've now driven agents by real 3D terrain — gradient flow, slope-scaled motion, and a continuous lifecycle, all on keyless streamed geo. The same recipe (sample a geo field into patches, drive the step from it) extends to wind, temperature, or land-cover data. The module guide is the reference, and samples/agent_droplets_3d.ts — with a live control panel for droplet count, speed, and size — is the ground truth.