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 terrain —
terrain/terrain.ts: a streaming grid of noise-based mesh tiles, with per-tile procedural trees and grass. - Vegetation —
terrain/grass_pass.ts+terrain/grass.wgsl: a GPU-instanced, wind-animated grass field that both receives and casts shadows. - Instanced props —
terrain/instanced_geometry_pass.ts+terrain/shadow_instanced.wgsl: the whole tree forest in two instanced draws. - The fox —
terrain/fox_controller.ts+terrain/fox_touch_controls.ts: movement, terrain-following, animation, follow-camera, mobile input. - Day cycle —
terrain/day_cycle.ts: sun angle → direction / color / intensity with a skewed long-day curve. - Frame orchestration —
grassy_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:
- Computes the focus tile
(ctx, ctz)and collects the set ofneededtile keys — every tile in the 7×7 Chebyshev block whose center falls within the circularVIEW_DIST(the corners of the square block are culled, giving a roughly disc-shaped loaded set). - Unloads any loaded tile not in
needed, callingtile.mesh.destroy(). - Queues missing tiles, sorts them by squared distance to the focus
(closest first), and builds at most
budgetof them this frame. - Marks the world
dirtyif 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 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:
_drawItems— oneDrawItemper tile for the deferredGeometryPass._shadowItems— oneShadowMeshDrawper tile for theShadowPass.- 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
mulberry32PRNG seeded fromix/iz). - A low-frequency
patchnoise field decides keep/cull: a candidate survives only whenrng() <= patch + densityBias.densityBias(the UI slider, range-0.25 … 1.3, defaultDEFAULT_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 = 12floats — threevec4s matchinggrass.wgsl'sBladestruct: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 atwind.x * wind.w, scaled bywind.y(strength); a per-bladesinflutter keeps neighbors out of lockstep. The combinedswayis divided bystiffnessand applied asbend = sway * t * t, so the blade hinges from its root and leans most at the tip. A-bend*bend*height*0.5term fakes arc-length so the bent blade doesn't visibly stretch. - Fox-flattening —
smoothstepof distance from the fox pushes nearby blades radially outward (fox.wstrength) and presses them down (misc.xdrop), again weighted byt*tso 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 approachingGRASS_MAX_DISTANCEand 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 (
perlinFbmNoise3at frequency0.0035) decides how forested a tile is —target = round(density^1.6 × MAX_TREES_PER_TILE)withMAX_TREES_PER_TILE = 30. Some tiles are dense forest, some near-empty. - An 8×8 jittered candidate grid, inset by a
margin = 3.0so cross-tile trees rarely collide, is shuffled so the accepted subset spreads across the tile. - Candidates are accepted until
targetis 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:
- Input — keyboard WASD/arrows contribute ±1 per axis; the touch joystick
adds analog
inputForward/inputStrafein[-1, 1]. Input is expressed in camera-relative ground directions (derived from_camYaw). - Movement — the move vector is normalized, with a
throttlefrom its pre-normalized length so a partial joystick push walks slower. Speed isWALK_SPEED = 4.2orRUN_SPEED = 10.0(Shift, or the touch RUN toggle viainputRun). - Facing —
_foxYawslews toward the headingatan2(mx, mz)atTURN_RATE = 9.0rad/s viaapproachAngle(shortest-path, capped). - 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. - Animation — the gait is
Surveywhen idle, elseWalk/Run. When the gait changes, the matching clip plays looped; the Run clip plays atRUN_ANIM_SPEED = 1.8to 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,
_camYawslowly slews to sit behind the fox atCAM_TRAIL_RATE = 1.7rad/s. - Ground clamp — the camera position is lifted to at least
terrainHeight(camX, camZ) + 0.8so 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 aJOY_DEAD_ZONE = 0.16dead 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 viacontroller.adjustZoom()(PINCH_ZOOM = 0.035units 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π)so0 = 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 aSunState: a light-travel directionnormalize(0.32, -elevation, sweep)— the constant0.32X tilt keeps shadows off the world axes; a color that is warm at the horizon and white near noon; an intensitysmoothstep(-0.05, 0.24, elevation) × 4.6that fades through twilight to0at night; and the rawelevation(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
DirectionalLightriding opposite the sun.moonLevelramps0→1just 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 sky —
dynamicSky.bake()re-renders the sky panorama and its IBL cubes, but only whensunAnglehas moved more than0.03since the last bake (a coarse cadence — re-baking every frame is wasteful). - Cloud / fog tint — cloud ambient color and
CompositePass.fogColorare lerped between day and night palettes fromsunState.elevation.
Frame flow (grassy_hills.ts)#
Each frame():
ctx.update();dtclamped to0.066s; smoothed FPS.- Day/night — advance
sunAngle,computeSun(), update sun & moon lights, pickkeyLight. - 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 toBiomeType.GrassyPlains(the only biome the terrain produces). - Simulation —
controller.update(dt), thenworld.update(foxX, foxZ)streams tiles around the fox. - 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. - 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). - Per-pass uniforms —
setDrawItems/setBatches/setInstancesand theupdate*calls upload this frame's data. - Build the render graph and execute it (below).
- 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
mulberry32PRNG 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.
CompositePasssetsblackLowerHemisphere = trueto hide the lower sky a downward camera look would otherwise expose past the terrain edge. - Coalesced instanced draws. The entire tree forest is two
drawIndexedcalls; 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'ssolveBladeis 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.03rad, 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 samplejitteredViewProjectionMatrix(). - 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.