Taos Engine ▦ Taos: Building a Modern WebGPU Game Engine

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:

  • Infinite terrainterrain/terrain.ts: a streaming grid of noise-based mesh tiles, with per-tile procedural trees and grass.
  • Vegetationterrain/grass_pass.ts + terrain/grass.wgsl: a GPU-instanced, wind-animated grass field that both receives and casts shadows.
  • Instanced propsterrain/instanced_geometry_pass.ts + terrain/shadow_instanced.wgsl: the whole tree forest in two instanced draws.
  • The foxterrain/fox_controller.ts + terrain/fox_touch_controls.ts: movement, terrain-following, animation, follow-camera, mobile input.
  • Day cycleterrain/day_cycle.ts: sun angle → direction / color / intensity with a skewed long-day curve.
  • Frame orchestrationgrassy_hills.ts: builds the render graph, drives weather, day/night and UI.

Architecture#

File Responsibility
grassy_hills.html Page shell: full-bleed <canvas>, "generating world…" loading overlay, loads grassy_hills.ts and the shared lib/source_viewer.ts.
grassy_hills.ts main(): 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.ts TerrainWorld — 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.ts Pure day/night math — sun-angle skew, hour/angle conversion, computeSun(). No GPU state.
terrain/fox_controller.ts loadFox() + FoxController — loads fox.glb, WASD/orbit control, terrain-following, gait-driven animation.
terrain/fox_touch_controls.ts setupFoxTouchControls() — lazily-created mobile overlay (joystick, RUN button, drag-orbit, pinch-zoom) feeding the FoxController.
terrain/grass_pass.ts GrassPass — render-graph pass that draws the grass field into the G-Buffer and into the cascade shadow map.
terrain/grass.wgsl Grass vertex/fragment shaders — wind sway, fox-flattening, the depth-only shadow variant.
terrain/instanced_geometry_pass.ts InstancedGeometryPass — draws instanced mesh batches (trunks, cones) into the G-Buffer and shadow map.
terrain/shadow_instanced.wgsl Depth-only instanced shadow shader for the tree forest.

The sample owns three sample-local passes (InstancedGeometryPass, GrassPass) and one streaming system (TerrainWorld); everything else (ShadowPass, GeometryPass, SkinnedGeometryPass, 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:

Constant Value Meaning
TILE_SIZE 38 World-space edge length of one tile.
TILE_RES 28 Quad subdivisions along a tile edge (29×29 vertices, 28×28×2 triangles).
VIEW_RADIUS_TILES 3 Chebyshev radius of the streaming window — a 7×7 candidate block.
VIEW_DIST (3 + 0.5) × 38 = 133 Circular cull radius; tiles whose center is beyond this are unloaded.
GEN_BUDGET_PER_FRAME 2 Tiles 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:

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 PbrMaterialalbedo [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:

  • _drawItems — one DrawItem per tile for the deferred GeometryPass.
  • _shadowItems — one ShadowMeshDraw per tile for the ShadowPass.
  • The two tree instance buffers are repacked (see below).
  • _rebuildGrass() concatenates every tile's packed grass into the global grass buffer.

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:

  • A jittered XZ position is computed inside its grid cell (deterministic per tile via a mulberry32 PRNG seeded from ix/iz).
  • A low-frequency patch noise field decides keep/cull: a candidate survives only when rng() <= patch + densityBias. densityBias (the UI slider, range -0.25 … 1.3, default DEFAULT_GRASS_DENSITY = 0.05) shifts that threshold — higher fills bare ground, lower thins the field.
  • Blade height comes from a separate, lower-frequency noise (tall), squared to bias toward short stubble with occasional tall swards, plus per-blade jitter.
  • Each blade is packed as GRASS_FLOATS = GRASS_STRIDE/4 = 12 floats — three vec4s matching grass.wgsl's Blade struct: base (root XYZ + facing angle), spec (height / width / phase / stiffness), tint (RGB).

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:

  • Lit draw — keeps tiles whose bounding sphere intersects the camera frustum (6 Gribb–Hartmann planes extracted from the view-projection).
  • Shadow draw — keeps tiles within GRASS_MAX_DISTANCE (85 m) of 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:

  • Wind — a two-octave value-noise gust field (windNoise) scrolls along the wind direction at wind.x * wind.w, scaled by wind.y (strength); a per-blade sin flutter keeps neighbors out of lockstep. The combined sway is divided by stiffness and applied as bend = sway * t * t, so the blade hinges from its root and leans most at the tip. A -bend*bend*height*0.5 term fakes arc-length so the bent blade doesn't visibly stretch.
  • Fox-flatteningsmoothstep of distance from the fox pushes nearby blades radially outward (fox.w strength) and presses them down (misc.x drop), again weighted by t*t so the root stays put.
  • Distance fade — the blade's whole offset from its root is scaled by 1 - smoothstep(0.65·maxDist, maxDist, camDist), so blades shrink toward their root approaching GRASS_MAX_DISTANCE and collapse to a zero-area (zero-fragment) point past it. This gives the per-tile cull a soft edge instead of a hard pop.

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 slow density noise field (perlinFbmNoise3 at frequency 0.0035) decides how forested a tile is — target = round(density^1.6 × MAX_TREES_PER_TILE) with MAX_TREES_PER_TILE = 30. Some tiles are dense forest, some near-empty.
  • An 8×8 jittered candidate grid, inset by a margin = 3.0 so cross-tile trees rarely collide, is shuffled so the accepted subset spreads across the tile.
  • Candidates are accepted until target is met, rejecting overlapping crowns (a circle test on cone radius). Each accepted tree gets randomized trunk height/radius, cone radius/height, yaw and a foliage tint.

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:

  • Auto-trail — when you're moving and haven't touched the camera for 350 ms, _camYaw slowly slews to sit behind the fox at CAM_TRAIL_RATE = 1.7 rad/s.
  • Ground clamp — the camera position is lifted to at least terrainHeight(camX, camZ) + 0.8 so it never clips through a hill.

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:

  • Virtual joystick (bottom-left) — a 58 px-radius ring with a 26 px knob. Drag deflection maps to inputStrafe / inputForward (-fz, since pushing up means camera-forward), with a JOY_DEAD_ZONE = 0.16 dead zone.
  • RUN button (bottom-right) — toggles controller.inputRun.
  • Camera region — the canvas itself: one tracked touch orbits via controller.applyLookDelta() (LOOK_SENS = 1.25); two touches switch to pinch-zoom via controller.adjustZoom() (PINCH_ZOOM = 0.035 units per pixel of spread change). While pinching, neither touch rotates the camera.

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.

  • skewSunAngle() maps the linear [0, 2π) so 0 = sunrise, π/2 = noon, π = sunset, 3π/2 = midnight; unskewSunAngle() inverts it.
  • sunAngleToHours() / hoursToSunAngle() convert to a 24-hour clock (sunrise 06:00, noon 12:00, sunset 18:00) for the HUD and time slider.
  • computeSun(sunAngle) returns a SunState: a light-travel direction normalize(0.32, -elevation, sweep) — the constant 0.32 X tilt keeps shadows off the world axes; a color that is warm at the horizon and white near noon; an intensity smoothstep(-0.05, 0.24, elevation) × 4.6 that fades through twilight to 0 at night; and the raw elevation (sine of the skewed angle).

In grassy_hills.ts the day cycle drives:

  • Sun light — direction/color/intensity copied to the DirectionalLight.
  • Moon light — a second cool, dim DirectionalLight riding opposite the sun. moonLevel ramps 0→1 just after the sun sets; the moon takes over as the scene's key light (deferred lighting + cascade shadows) the instant the sun's intensity hits 0, so there is no shadow-direction snap.
  • Dynamic skydynamicSky.bake() re-renders the sky panorama and its IBL cubes, but only when sunAngle has moved more than 0.03 since the last bake (a coarse cadence — re-baking every frame is wasteful).
  • Cloud / fog tint — cloud ambient color and CompositePass.fogColor are lerped between day and night palettes from sunState.elevation.

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. Simulationcontroller.update(dt), then world.update(foxX, foxZ) streams tiles around the fox.
  5. Cameracamera.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 uniformssetDrawItems / 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#

  • World-space tile meshes. Tile meshes bake absolute world coordinates into their vertices and use the shared identity model matrix. This sidesteps any per-tile transform, but means a tile mesh is only valid at its (ix, iz) — it can't be relocated, only destroyed and rebuilt.
  • Deterministic procedural content. Every tile's trees and grass come from a mulberry32 PRNG seeded purely from (ix, iz) (via integer hashes), so a tile that streams out and back in regenerates bit-identically — no popping layout changes.
  • No LOD, bounded streaming. Every tile is full-resolution; the world only streams a 3-tile radius out, so it never reaches the horizon. CompositePass sets blackLowerHemisphere = true to hide the lower sky a downward camera look would otherwise expose past the terrain edge.
  • Coalesced instanced draws. The entire tree forest is two drawIndexed calls; the grass field is one instanced draw per run of frustum-visible tiles (typically a handful), keeping off-screen and far blades off the GPU. All animation is GPU-side in the vertex shader — per-frame CPU cost is a few small uniform uploads plus a per-tile sphere test.
  • Shared deform for visible & cast shadows. grass.wgsl's solveBlade is used by both the lit and the depth-only entry points, so a blade's shadow always matches its swaying, fox-flattened silhouette. Likewise the tree instance buffer feeds both the G-Buffer fill and the instanced shadow pass.
  • Key-light handoff. Swapping the scene's key light from sun to moon exactly when the sun's intensity reaches 0 avoids a visible shadow-direction snap; the sun and moon cloud-lighting terms are also arranged never to overlap.
  • Coarse sky re-bake. The dynamic sky panorama + IBL only re-bakes when the sun has moved > 0.03 rad, not every frame — IBL convolution is expensive and the lighting barely changes frame to frame.
  • Skinned shadow casting. The fox casts its shadow through the skinned caster path — the same meshes and joint palette as the lit draw — so the shadow tracks the live animation rather than a frozen bind pose.
  • TAA jitter ordering. taaPass.updateCamera(ctx) must run before any geometry pass reads the camera, since TAA owns the camera's sub-pixel jitter and all geometry passes sample jitteredViewProjectionMatrix().
  • Grass density re-scatter. Moving the density slider doesn't rebuild the whole field in one frame — affected tiles are queued and re-scattered a couple per frame, so a slider drag stays smooth.