Taos Engine

Terrain & Worlds

Grassy Hills — Technical Deep Dive

Grassy Hills is an infinite, procedurally generated terrain demo you roam as a third-person fox. A noise-driven landscape of rolling grassy hills streams in and out around the fox, decorated with an instanced tree forest and a GPU-animated grass field, all lit by a full deferred + HDR render graph with a long-day / short-night day cycle, dynamic-sky IBL, volumetric clouds, godrays, TAA, bloom, GTAO, auto-exposure and crafty's weather model. It is the largest sample built purely on the engine's render graph and exercises nearly every pass class in src/renderer/render_graph/passes/.

Overview#

You drop in onto a green, rolling plain at 08:30 in-game. You drive the fox with WASD (Shift to run), orbit the camera with mouse-drag, and zoom with the wheel; on touch devices a virtual joystick, RUN toggle and pinch-zoom appear. The fox is a skinned glTF model that swaps Run / Walk / Survey animation clips by gait and stays glued to the terrain surface. As you move, terrain tiles, trees and grass stream around you; the sun arcs overhead, weather rolls through (clear, fog, overcast, rain, snow), and a HUD shows FPS, time of day, gait and loaded-tile / tree / grass counts.

High-level systems:

Architecture#

FileResponsibility
grassy_hills.htmlPage shell: full-bleed <canvas>, "generating world…" loading overlay, loads grassy_hills.ts and the shared lib/source_viewer.ts.
grassy_hills.tsmain(): creates the RenderContext, all persistent passes, scene objects, UI; runs the per-frame frame() loop that updates simulation/weather/day-cycle and wires the render graph.
terrain/terrain.tsTerrainWorld — streams terrain tiles, builds tile meshes, scatters trees and grass, owns the global instance buffers. Also exports the terrainHeight / terrainNormal functions reused by the fox controller.
terrain/day_cycle.tsPure day/night math — sun-angle skew, hour/angle conversion, computeSun(). No GPU state.
terrain/fox_controller.tsloadFox() + FoxController — loads fox.glb, WASD/orbit control, terrain-following, gait-driven animation.
terrain/fox_touch_controls.tssetupFoxTouchControls() — lazily-created mobile overlay (joystick, RUN button, drag-orbit, pinch-zoom) feeding the FoxController.
../../src/renderer/render_graph/passes/grass_pass.tsGrassPass — engine render-graph pass that draws the grass field into the G-Buffer and into the cascade shadow map. Promoted from this sample; crafty uses it too.
../../src/shaders/grass.wgslGrass vertex/fragment shaders — wind sway, displacer-flattening (the fox here), the depth-only shadow variant.
terrain/instanced_geometry_pass.tsInstancedGeometryPass — draws instanced mesh batches (trunks, cones) into the G-Buffer and shadow map.
terrain/shadow_instanced.wgslDepth-only instanced shadow shader for the tree forest.

The sample owns one sample-local pass (InstancedGeometryPass) and one streaming system (TerrainWorld); everything else (ShadowPass, GeometryPass, SkinnedGeometryPass, GrassPass, GTAOPass, AtmospherePass, DeferredLightingPass, GodrayPass, CloudPass, ParticlePass, TAAPass, BloomPass, AutoExposurePass, CompositePass) is a stock engine pass from src/renderer/render_graph/passes/.

Infinite terrain (terrain/terrain.ts)#

Chunking#

The world is a grid of square tiles indexed by integer (ix, iz). Key constants:

ConstantValueMeaning
TILE_SIZE38World-space edge length of one tile.
TILE_RES28Quad subdivisions along a tile edge (29×29 vertices, 28×28×2 triangles).
VIEW_RADIUS_TILES3Chebyshev radius of the streaming window — a 7×7 candidate block.
VIEW_DIST(3 + 0.5) × 38 = 133Circular cull radius; tiles whose center is beyond this are unloaded.
GEN_BUDGET_PER_FRAME2Tiles built per update() call so streaming never hitches.

TerrainWorld.update(focusX, focusZ, budget?) runs each frame with the fox's XZ position as the focus. It:

  1. Computes the focus tile (ctx, ctz) and collects the set of needed tile keys — every tile in the 7×7 Chebyshev block whose center falls within the circular VIEW_DIST (the corners of the square block are culled, giving a roughly disc-shaped loaded set).
  2. Unloads any loaded tile not in needed, calling tile.mesh.destroy().
  3. Queues missing tiles, sorts them by squared distance to the focus (closest first), and builds at most budget of them this frame.
  4. Marks the world dirty if the loaded set changed and calls _rebuild().

The initial ring is primed synchronously in main() with world.update(0, 0, Infinity) — an unlimited budget so the first frame already has a full tile ring while the loading overlay is still up.

Height field#

Terrain height is a pure function of world XZ — there is no LOD; every tile is built at the same TILE_RES. terrainHeight(x, z) sums three octaves of perlinFbmNoise3 (from src/math/noise.ts) at decreasing wavelength and amplitude:

TypeScript
export function terrainHeight(x: number, z: number): number {
  let h = 0;
  h += perlinFbmNoise3(x * 0.0062, 0, z * 0.0062, 2.0, 0.5, 4) * 17.0; // broad hills
  h += perlinFbmNoise3(x * 0.027,  0, z * 0.027,  2.0, 0.5, 3) *  2.4; // bumps
  h += perlinFbmNoise3(x * 0.105,  0, z * 0.105,  2.0, 0.5, 2) *  0.55;// texture
  return h;
}

A broad ±17-unit layer shapes the rolling hills; two finer layers add bumps and surface texture. All layers are continuous (no thresholding), so the surface has slopes but no vertical cliffs — important because the fox glues to the surface and would jump discontinuously across a cliff.

terrainNormal(x, z) derives the surface normal by central differences with a d = 0.5 step, returning normalize(-dh/dx, 1, -dh/dz). Both functions are exported and reused by FoxController to follow the ground.

Mesh building#

buildTileMesh(device, ix, iz) builds a tile's mesh in absolute world-space coordinates (not local space) — every tile's model matrix is the shared identity. It walks a (TILE_RES+1)² vertex grid, sampling terrainHeight and terrainNormal per vertex, and packs the engine's 12-float interleaved vertex layout: position(3), normal(3), uv(2) (UV is world × 0.08), tangent(4). Indices form two triangles per quad. The mesh is uploaded via Mesh.fromData(device, vertices, indices).

Terrain material#

The terrain is a single flat PbrMaterial — albedo [0.30, 0.43, 0.21, 1] (muted grass green), roughness 0.95, metallic 0 — shared by every tile. Trunks and cones get their own flat PBR materials (brown, dark green). All three are bound once in the constructor.

Streaming the draw lists#

_rebuild() regenerates the per-frame draw lists whenever the tile set changes:

Because tile meshes are world-space, all share Mat4.identity() as both model and normal matrix — the renderer never has to transform a tile.

Vegetation — the grass field (grass_pass.ts + grass.wgsl)#

Placement (CPU, in terrain.ts)#

placeGrass(ix, iz, densityBias) scatters blades on a jittered candidate grid of GRASS_GRID = 125 cells per tile edge — up to 15 625 candidates per tile. For each candidate:

The global grass buffer (MAX_GRASS = 800 000 blades capacity) is the concatenation of every loaded tile's packed array — sized to hold the full 49-tile candidate set at the slider's max density. Changing the density slider re-scatters all loaded tiles via _grassRegenQueue, drained a couple of tiles per frame so a slider drag never hitches.

GPU instancing & the blade mesh#

GrassPass builds one tiny tapered blade mesh: GRASS_SEGMENTS = 4 height segments, a left/right vertex pair per row plus a single tip vertex. Each vertex is just vec2(heightFrac, side) — side ∈ {-1, +1} (tip is 0). The blade mesh is instanced once per blade; per-blade data is read from the caller-owned storage buffer (TerrainWorld.grassInstanceBuffer) indexed by @builtin(instance_index).

Per-tile culling#

The grass field is world-sized but only a slice is ever on screen, so the pass culls per terrain tile. TerrainWorld._rebuildGrass() records a GrassTile for each tile — its slice of the global buffer (firstInstance + count) and the disc/sphere that bounds its blades. Each frame grassPass.update() rebuilds two sets of draw runs from the camera:

Because tiles are listed in instance-buffer order, consecutive survivors coalesce into a single drawIndexed(bladeIndexCount, count, 0, 0, firstInstance), so the visible field still draws in a handful of calls. The per-frame CPU work is one 144-byte uniform upload plus a cheap sphere test per tile.

Wind & fox-flattening (vertex shader)#

solveBlade(v, iid) in grass.wgsl does all the motion; it's shared by the lit entry point (vs_main) and the shadow entry point (vs_shadow) so visible and cast-shadow blades deform identically. Per blade:

The frame loop feeds these via grassPass.update() with strength 0.34, freq 0.05, speed 0.32, flutter 0.13, wind direction cloudWind, and a fox push of radius 1.5, strength 0.95, drop 0.7.

Shading & shadows#

fs_main writes the G-Buffer directly (albedo/roughness, normal/metallic, emissive), shading the blade darker at the root and brighter at the tip and biasing the normal strongly upward so the double-sided flat blades catch soft light from both faces. The grass pipeline uses cullMode: 'none'.

Because the grass writes the G-Buffer it already receives the scene's shadows. GrassPass.addShadowToGraph() makes it cast them too: a depth-only pipeline (vs_shadow) rasterizes the same swaying blades into the existing shadow map (depthLoadOp: 'load'), reusing the same wind/fox uniforms. Grass is fine detail with no reach, so it casts into cascade 0 only (GRASS_SHADOW_CASCADES) and only for the distance-culled near tiles — rasterizing the whole field into the wider far cascades cost far more than it showed, especially on tile-based mobile GPUs. Its depthBias 2 / depthBiasSlopeScale 2.5 match ShadowPass so blade shadows fuse with terrain/tree shadows, and it requests unclippedDepth when depth-clip-control is available so a blade clipped by the cascade's near plane still occludes rather than vanishing.

Instanced geometry & tree shadows (instanced_geometry_pass.ts)#

Tree placement (CPU)#

placeTrees(ix, iz) scatters trees per tile, deterministically via a mulberry32 PRNG seeded from the tile coordinates:

A tree is two meshes: a trunk (unit createCylinder with caps, 10 segments) and foliage (Mesh.createCone, 12 segments). The trunk gets an independent 1ז2× height multiplier so some trees stand on a tall bare bole.

One forest, two draws#

TerrainWorld owns two global instance buffers (MAX_TREES = 2600 capacity each — one for trunks, one for cones). _rebuild() repacks them whenever tiles stream: each tree writes one INSTANCE_STRIDE = 144-byte InstanceData record (mat4 model + mat4 normalMatrix + vec4 colorTint) into each buffer via writeInstance(). treeBatches() returns two InstanceBatches — all trunks, then all cones.

InstancedGeometryPass.addToGraph() issues one drawIndexed per batch: the trunk mesh instanced treeCount times, then the cone mesh instanced treeCount times — the entire forest in two draw calls. The pass reuses the engine's PBR shader compiled with the HAS_INSTANCING define / PBR_HAS_INSTANCING variant mask, so the instance-buffer layout matches geometry.wgsl's instancing variant. Pipelines are cached per (shaderId, variantMask).

Tree shadows#

InstancedGeometryPass.addShadowToGraph() rasterizes the same instance buffers depth-only into each shadow cascade using shadow_instanced.wgsl — a minimal shader that reads the per-instance model matrix from the storage buffer (indexed by instance_index), transforms the vertex position, and projects it by the cascade's lightViewProj. No color output; depth is written automatically. It shares depthBias/unclippedDepth settings with the grass and ShadowPass so all shadows fuse. The same instance buffer therefore feeds both the G-Buffer fill and the shadow pass — no separate caster geometry.

The fox (fox_controller.ts)#

Loading#

loadFox(device, url) loads fox.glb via GltfLoader.load, asserts it has a skin, and wraps it in an AnimatedModel animator. The glb ships three clips: Run, Walk, Survey. In main() each mesh's material bind group is primed before the first frame.

Movement & terrain-following#

FoxController.update(dt) each frame:

  1. Input — keyboard WASD/arrows contribute ±1 per axis; the touch joystick adds analog inputForward / inputStrafe in [-1, 1]. Input is expressed in camera-relative ground directions (derived from _camYaw).
  2. Movement — the move vector is normalized, with a throttle from its pre-normalized length so a partial joystick push walks slower. Speed is WALK_SPEED = 4.2 or RUN_SPEED = 10.0 (Shift, or the touch RUN toggle via inputRun).
  3. Facing — _foxYaw slews toward the heading atan2(mx, mz) at TURN_RATE = 9.0 rad/s via approachAngle (shortest-path, capped).
  4. Terrain-follow — the fox's Y is set hard to terrainHeight(x, z) every frame. There is no physics — it simply rides the surface; the cliff-free height field keeps that smooth.
  5. Animation — the gait is Survey when idle, else Walk / Run. When the gait changes, the matching clip plays looped; the Run clip plays at RUN_ANIM_SPEED = 1.8 to keep paw-plants tracking the faster ground speed (reduces foot-slip). animator.update(dt) advances the joint palette.

The fox's gait is exposed via controller.state for the HUD and used as the focus point for terrain streaming.

Follow camera#

An orbiting third-person camera: _camYaw / _camPitch (clamped -0.20 … 1.15) / _camDist (4 … 22). The camera aims at a focus FOCUS_HEIGHT = 1.7 above the fox's feet and sits _camDist back along the inverted look direction. Two niceties:

Mobile input (fox_touch_controls.ts)#

setupFoxTouchControls() defers all overlay creation until the device actually fires a touchstart (more reliable than navigator.maxTouchPoints on hybrid laptops) — a desktop session pays nothing. The FoxTouchControls overlay adds:

applyLookDelta and adjustZoom are shared with the mouse handlers, so touch and desktop drive identical controller paths.

Day/night cycle (day_cycle.ts)#

A single linear sunAngle accumulates at SUN_ANGLE_RATE = 0.022 rad/s of real time (paused while the bottom-bar pause button or time slider is engaged). A skew curve redistributes it so the sun lingers above the horizon: DAY_FRACTION = 0.82 of each cycle is daytime — a long day and a short night, matching the crafty game's feel.

In grassy_hills.ts the day cycle drives:

Frame flow (grassy_hills.ts)#

Each frame():

  1. ctx.update(); dt clamped to 0.066 s; smoothed FPS.
  2. Day/night — advance sunAngle, computeSun(), update sun & moon lights, pick keyLight.
  3. Weather — crafty's weather model on a timer (getWeatherChangeInterval); cloud coverage/density/bounds and fog near/far all ease toward the new weather's targets so transitions read as the sky slowly turning over. Weather is locked to BiomeType.GrassyPlains (the only biome the terrain produces).
  4. Simulation — controller.update(dt), then world.update(foxX, foxZ) streams tiles around the fox.
  5. Camera — camera.updateRender(ctx); re-bake the dynamic sky if the sun moved enough; taaPass.updateCamera(ctx) applies the sub-pixel jitter before any geometry pass reads the camera.
  6. Draw lists — skinned fox draw items (sharing the live joint palette so the cast shadow tracks the animation), terrain shadow/draw items, tree batches, grass instances; keyLight.computeCascadeMatrices(camera, 170) computes the cascade matrices (170-unit shadow far).
  7. Per-pass uniforms — setDrawItems / setBatches / setInstances and the update* calls upload this frame's data.
  8. Build the render graph and execute it (below).
  9. HUD — FPS, time of day, gait, loaded tile / tree / grass counts.

Render-graph wiring#

A fresh RenderGraph is built every frame (passes persist; the graph is cheap). Ordering, by addToGraph call:

Shadows:   ShadowPass (terrain + skinned fox)
        →  InstancedGeometryPass.addShadowToGraph (tree forest)
        →  GrassPass.addShadowToGraph (grass blades)        [if grass enabled]
           — each loads onto the cascade depth the prior pass wrote.

G-Buffer:  GeometryPass (terrain — clears the G-Buffer)
        →  SkinnedGeometryPass (the fox)
        →  InstancedGeometryPass (trees)
        →  GrassPass (grass)                                [if grass enabled]

Lighting:  GTAOPass (normal + depth → AO; strength 0 still keeps the handle)
           AtmospherePass (clears the HDR target with sky)
           DeferredLightingPass (G-Buffer + shadows + AO + IBL → lit HDR)

HDR post:  GodrayPass     [if godrays enabled and sun.intensity > 0.02]
           CloudPass      [if clouds enabled — volumetric overlay]
           ParticlePass   [rain or snow, if the weather has precipitation]
           AutoExposurePass (meters the HDR)
           TAAPass        [if enabled]
           BloomPass      [if enabled]

Output:    CompositePass (tonemap + depth fog + stars → backbuffer 'canvas')

GeometryPass is the only pass that clears the G-Buffer; the skinned, instanced and grass passes all load onto it. AtmospherePass clears the HDR target; DeferredLightingPass composites the lit scene on top. The effect toggles (grass, clouds, weather, godrays, gtao, taa, bloom) live in a collapsed popup behind the top-left ☰ EFFECTS hamburger button, so they don't clutter the view by default; flipping one simply skips the corresponding addToGraph call or neutralizes the pass's parameters.

Weather particles#

Rain and snow are stock ParticlePass instances configured with RAIN_CONFIG / SNOW_CONFIG. The emitter is a wide box (halfExtents ≈ 34) re-centered over the fox every frame (20–26 units up) so precipitation always surrounds the player. The rain pool is sized maxParticles 200 000 — large enough that even HeavyRain's 50 000/s spawn rate doesn't lap the ring buffer before a drop reaches the ground.

Notable techniques & gotchas#