Taos Engine ▦ Taos: Building a Modern WebGPU Game Engine

Geo Fox in the Rain — Physics, Particles & Decals on Streamed Terrain

Run the sample · geo_fox_rain.ts

A fox runs across the real Grand Canyon rim in a moonlit downpour. The rain collides with the terrain and with a giant Luxo-style ball, bursting into splashes on impact; the fox kicks up splashes at its feet and leaves a fading trail of water-ripple decals; and the ball — pushable by the fox — bounces down the canyon under physics. Every surface here is streamed geospatial terrain (AWS Terrarium elevation draped with Esri aerial imagery), not authored geometry.

This sample is mostly integration: it bridges four subsystems that were each built in isolation and never designed to meet. The interesting work is in the seams between them.

OSM raster-DEM terrain  (geo_osm_terrain)  ─┐
fox + atmosphere/fog/stars (standalone/geo_fox) ─┤
rain + on_death splash  (mountain_fox)      ─┼──►  geo_fox_rain
Jolt terrain colliders  (geo_physics)       ─┘

The floating-origin world#

The scene is anchored at Mather Point (lon −112.1071°, lat 36.0608°, rim ≈ 2100 m) via a GeoFrame, so world-space coordinates stay small (f32-safe) even though the underlying positions are ECEF metres in the millions. The origin is pinned — we never re-anchor — because the Jolt terrain colliders are baked into the current frame, and re-anchoring would slide the terrain out from under them. The canyon play area is local, so f32 stays precise across it.

Terrain streams through a GeoScene with a RasterDemTileset: the app calls geo.update(...) each frame with the camera's ECEF position and cull frustum, and the returned draw items are mirrored into the deferred geometry pass. The fox (skinned) and ball (static mesh) are appended to the same draw lists, so they share the terrain's lighting, shadows, AO, atmosphere and tonemap.

Seam 1 — Rain that collides with terrain and a moving ball#

mountain_fox's rain bakes its collision heightmap on the GPU from the terrain's virtual-texture atlas. Geospatial terrain has no such atlas, so that path can't be reused. Instead, geo_fox_rain.ts defines a sample-local GeoRainFeature that bakes the heightmap on the CPU each frame: it walks a grid around the camera, and for each cell converts world XZ → lon/lat → GeoScene.heightAt → ellipsoid height → world Y (the same geodetic↔ECEF↔world path the rendered mesh was baked with), then uploads it via ParticlePass.updateHeightmap. The rain config's block_collision modifier (kill-on-contact) and its on_death splash split then "just work" against the streamed canyon surface.

A movable particle sphere collider (engine change)#

The ball is a moving sphere — the heightmap (single-valued, terrain-shaped) can't represent it. The particle system already had a sphere_collision modifier, but it baked the centre and radius as WGSL constants, so the collider couldn't move. This sample motivated a small engine addition:

  • A dynamic? flag on the sphere_collision modifier (particle_types.ts).
  • When set, particle_builder.ts emits uniforms.sphere_center / uniforms.sphere_radius reads instead of literals.
  • The per-frame ComputeUniforms struct grew from 80 → 96 bytes to carry those two fields, and ParticlePass gained setSphereCollider(x, y, z, radius) (radius 0 = disabled), packed into the uniform each frame.

GeoRainFeature pushes the ball's world centre + radius every frame, so rain drops that strike the ball die and spawn the same on_death splash burst as terrain hits. Because the collider is a uniform, not baked geometry, it tracks the ball as it rolls — at zero extra draw cost.

Seam 2 — A physics fox & camera on streamed colliders#

Following geo_physics, each rendered quantized-mesh terrain tile is fed to Jolt as a static triangle-mesh collider via TerrainContent.sampler.collisionGeometry(...), added and removed as tiles stream in and out of the rendered set (capped to a few per frame to avoid hitches). See the engine's PhysicsWorld.

The fox is a kinematically-driven dynamic sphere: each frame we set its horizontal velocity from camera-relative input and let gravity + the colliders resolve the vertical, then read the body position back to place the GameObject. It rolls under the hood; the visible fox is yaw-oriented from its movement direction and animated (idle/walk/run gaits via AnimatedModel). The camera ground-clamps with a downward PhysicsWorld.rayCast against the same colliders.

Three subtleties were worth solving:

  • Don't tunnel through streamed colliders. A tile's collider may not exist yet when the fox arrives. Gravity is kept at 1× (a heavier factor lets the body outrun collider streaming and fall through the thin, one-sided mesh), and a heightAt-based floor both spawns the fox on the real ground and rescues it if it ever drops below the queried surface.
  • The collider lives at the head. A single body-centred sphere can't reach the snout without ballooning sideways, so the body sphere is offset forward along the facing (HEAD_FWD) and the model is drawn trailing it — so the nose contacts the ball before it visually intersects.
  • Foot height is radius-independent. Because the body rests at groundY + radius and we draw the fox at body.y − radius, the collider size never lifts the fox off the ground — handy for tuning.

Seam 3 — Waiting for the world to settle#

Streamed terrain changes shape as finer LODs arrive. A ball placed on a coarse tile would roll off when that tile re-resolves a metre lower. So the ball is held by a world-load gate: it stays pinned on the ledge (showing "Waiting for the world to load…") until a minimum time has passed and the collider set has been stable for a beat and a ground height under it is available — then it's released to the simulation exactly once. syncTerrainColliders reports whether the collider set changed this frame, which drives the stability timer.

Because the fox and ball are both dynamic bodies in Jolt's moving layer, the fox can shove the ball around with no extra code — the contact solver does it.

The trimmings#

  • Foot splashes. A standalone ParticleEmitterFeature (a minimal, collision-free ParticlePass wrapper) positions its emitter at the fox's feet and sets its spawn rate from the fox's speed, so puffs of water kick up only while running.
  • Footstep ripple decals. A recycled pool of downward-facing Projector components (rect-ortho, alpha-blended, a procedural concentric-ripple texture) drops one ripple every stride of travel, alternating slightly left/right, each fading out over its lifetime. They composite after lighting via the unlit ProjectorFeature.
  • Look & feel. A dusk time-of-day drives a sun→moon key-light swap, with the geo_fox aerial-fog tuning, a procedural starfield, GTAO and TAA — all from the stock engine features, registered in dependency order around the geo draws.
  • HUD. An FPS readout, the data-source network log (toggle with L), and a P hotkey that prints the current fox pose so a good opening view can be locked into the start constants.

Known follow-ups#

  • The raster-DEM decode + bake runs on the main thread — there's no worker pool for it (unlike the quantized-mesh path's terrain_bake_pool). Moving it off-thread is the largest remaining win for load smoothness.
  • Projector decals can't write G-buffer normals, so the ripples are flat — they can't catch the moonlight as wet, bumped surfaces. Normal-mapped lit decals would need the projector pass to blend a second (normal) MRT.