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 thesphere_collisionmodifier (particle_types.ts). - When set,
particle_builder.tsemitsuniforms.sphere_center/uniforms.sphere_radiusreads instead of literals. - The per-frame
ComputeUniformsstruct grew from 80 → 96 bytes to carry those two fields, andParticlePassgainedsetSphereCollider(x, y, z, radius)(radius0= 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 + radiusand we draw the fox atbody.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-freeParticlePasswrapper) 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
Projectorcomponents (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 unlitProjectorFeature. - Look & feel. A dusk time-of-day drives a sun→moon key-light swap, with the
geo_foxaerial-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 aPhotkey 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.