▶ 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
GeoScenebasics (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.
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:
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:
const cfg = buildAgentConfig(dropletModel());
cfg.emitter.lifetime = [5, 11]; // finite life (s)
cfg.emitter.spawnRate = POP / 8; // ≈ population / avg-life → steady countTo 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):
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:
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.