Forest
Purpose
Section titled “Purpose”Examples/Forest (“Forest Demo”) is the engine’s showcase: a first-person walk through a
small forest with a stream, meant to look like a UE or Unity scene (proposal
future/forest-showcase.md, G8d; ADR 0155).
Like the Demo it is a real game project built against this checkout (MainframeEnginePath = ../..) through
its own Examples/Forest/Forest.slnx, and it is not in MainframeEngine.slnx.
Current state: the playable valley. The project, the first-person controller, the CC0 art (Assets), the
procedural audio and the valley: a generated 256 m terrain with a stream (a small fall, three
pools, a log bridge) running into a pond, about 1 850 trees with an understorey of saplings, 180 bushes, grass, reeds,
ferns, forest-floor clutter and props sunk into the ground (art pass, ADR 0175), under a
physical morning sky with fog, light shafts, eye adaptation, SSAO (GTAO, ADR 0165) and TAA,
look-developed against Unreal forest references (look-dev, ADR 0178: a low back-light, a warm
white haze, muted greens, flowers, dust motes). Also the reference shots, the
benchmark, a path-following --autowalk and the pause menu
(Esc: live graphics, audio and control settings, a fly-over, the reference shots and a free camera; ADR 0180). Not
yet: butterflies and the release job.

Layout
Section titled “Layout”Examples/Forest/├── Forest.slnx, Directory.Build.props, global.json, .gitignore, .gitattributes (the Demo's + *.hdr, *.exr, *.cube),│ NOTICE.md (every third-party asset), README.md├── project.mfproj "Forest Demo" (also the window title), window.icon Content/Brand/icon.png, main scene forest,│ 1920 × 1080 px (contentScale 1), shadows High, TAA, 3D at 0.75 with TAAU (ADR 0174), UI│ scaled from 1920 × 1080 (ADR 0181), the input map, autoload Dev├── Brand/ the app icon (ADR 0182): icon.svg, icon-small.svg, icon-macos.svg → png/, forest.ico,│ forest.icns (`just forest-brand`)├── benchmark-baseline.json this Mac's forest-bench result (1920 × 1080)├── Forest/ Src/Player (FirstPersonController, IFootstepSurface + SurfaceBody3D, ForestSettings),│ Src/World (ForestScene, ForestValley, ValleyLayout, ValleyGenerator, ValleyNoise,│ PolylineField, ForestVegetation, ForestClutter, SceneWriter, ForestAssets, ForestAssetGallery,│ ForestLook, ForestGrade + ForestGradeFit (generated by Tools/grade_from_shots.py)),│ Src/Dev (ForestDev, PathWalker, ForestBenchmark), Src/Audio (ForestAudio, …),│ Src/Menu (PauseMenu, ForestOptions + ForestOption, ForestWorld, ForestCameras, FreeFlyCamera)├── Forest.Desktop/ GameHost.Run(args, typeof(Forest.FirstPersonController).Assembly) + --write-scenes <dir>;│ ApplicationIcon ../Brand/forest.ico, MacAppIcon ../Brand/forest.icns├── Forest.Tests/ xUnit v3, no GPU; the asset tests skip without the LFS content├── Tools/ fetch_assets.py downloads and imports Content/Art (python3 + Pillow, curl);│ grade_from_shots.py fits the grade to ungraded renders of R1–R7 (ADR 0175)└── Content/ Scenes/forest.mscene, Scenes/asset_gallery.mscene (generated), Settings/AudioBusLayout.mres (the Forest's buses), UI/ (fps.rml, menu.rcss, shots/: R1–R8 thumbnails), Art/ (CC0, in LFS: Terrain/<Layer>/, Props/<id>/, Debris/<id>/, Sky/)App name and icon
Section titled “App name and icon”The app is Forest Demo (ADR 0182): project.mfproj
name, which is also the window title (no window.title) and the user data folder
(~/Library/Application Support/Forest Demo on macOS; before ADR 0182 it was Mainframe Forest, so settings start
fresh once). On macOS just forest runs bin/Release/net10.0/Forest Demo.app/Contents/MacOS/Forest.Desktop, so the
Dock, the app menu and Cmd+Tab say Forest Demo with the Forest icon; on Windows the exe’s file description and product
name are Forest Demo and it carries forest.ico. The assembly stays Forest.Desktop (the scripts and tests run
Forest.Desktop.dll). build/package-game.sh makes Forest Demo.app / ForestDemo.exe.
The icon, in the Mainframe brand family (the C3 logo’s rounded tile and amber): a backlit spruce against a warm haze
with light shafts, a misty far ridge of hazy pines, darker near pines and the valley floor. Sources in
Examples/Forest/Brand: icon.svg (64 px and above), icon-small.svg (48 px and below: the spruce, its glow and the
ground only), icon-macos.svg (Apple’s grid: 824 px tile on 1024, for forest.icns). just forest-brand renders
them with Inkscape through build/brand/app-icon.sh (PNGs, the 7-size .ico, the .icns) and copies png/icon-256.png
to Content/Brand/icon.png, the window icon (Windows/Linux title bar and taskbar; unbundled macOS runs).
Assets
Section titled “Assets”Brogan’s choice for the art is “CC0 + procedural”: trees, grass and the terrain’s shape are generated (Ez Tree’s bark and leaf textures are already in the engine), and only surfaces and props are photographed assets, from ambientCG and Poly Haven and nowhere else. Both sites are CC0 1.0 site-wide (their licence pages; neither API has a per-asset licence field). NOTICE.md lists every asset (name, ID, source page and download URL, author, licence); THIRD_PARTY_NOTICES.md has a summary row.
Import. python3 Examples/Forest/Tools/fetch_assets.py [--cache DIR] (curl + Pillow) downloads over HTTPS from
ambientcg.com/api/v2/full_json + ambientcg.com/get?file=… and api.polyhaven.com + dl.polyhaven.org (Poly Haven
files checked against the API’s MD5), converts, and writes .meta sidecars (UIDs) for new files; re-running keeps the
UIDs. Everything under Content/Art is in LFS except the .gltf JSON and the sidecars: 152 MB (G8e.7 added 45 MB:
nine Poly Haven scans at 1K and ambientCG’s scattered leaves) of G8d’s 400 MB budget; before G8e.7 it was 107 MB in 77 LFS files
(terrain 31.5 MB, props 73.6 MB, sky 2.4 MB).
| Kind | Source → committed |
|---|---|
| Terrain layers | ambientCG 2K JPG sets → 1024² (the splat material packs every layer at LayerTextureSize 1024): <Id>_Color.jpg, <Id>_NormalGL.jpg, <Id>_ORM.png (R = AmbientOcclusion, G = Roughness, B = 0), <Id>_Height.png (8-bit, from Displacement) |
| Props | Poly Haven glTF + .bin + textures at 1K/2K; the grey roughness image replaced by the asset’s ARM map (R = AO, G = roughness, B = metallic: glTF’s packing) and wired as occlusion + metallic-roughness; the fern’s diffuse JPG and Alpha map merged into an RGBA PNG |
| Debris (G8e.7) | ambientCG 1K JPG sets of scattered leaves → <Id>_Color.png (colour + opacity), <Id>_NormalGL.jpg, <Id>_ORM.png (occlusion 1, roughness); fetch_assets.py --only props,debris --assets … fetches some kinds or assets only |
| Sky | Poly Haven lilienstein (a sunlit meadow at a forest edge, low sun) tonemapped JPG → 4096 × 2048, 2.4 MB (the panorama path is LDR) |
| Layer (splat channel) | Tag | ambientCG | Tiling | Contrast |
|---|---|---|---|---|
| 0 Grass | grass | Ground037 (mossy forest grass; tinted (196, 212, 170): the photo is a bright yellow-green) | 2.5 m | 0.25 |
| 1 Leaves | leaves | Ground023 (leaf litter, sticks, dark soil) | 2.5 m | 0.25 |
| 2 Moss | moss | Moss002 | 2 m | 0.3 |
| 3 Rock | rock | Rock063 (mossy layered cliff) | 6 m | 0.2 |
| 4 Dirt | dirt | Ground067 (brown forest dirt) | 2.5 m | 0.15 |
| 5 Gravel | gravel | Ground108 (riverbed) | 1.5 m | 0.1 |
| 6 Mud | mud | Ground051 (dark wet mud, pebbles) | 2 m | 0.15 |
| 7 Needles | needles | Ground082S (soil under needles and twigs) | 2 m | 0.25 |
| Prop | Poly Haven | Size (m, y up) | Notes |
|---|---|---|---|
MossyRocks01, MossyRocks02 | rock_moss_set_01/02 (2K) | 8.0 × 1.8 × 7.0, 8.3 × 1.4 × 3.4 | six / seven mossy rocks each (one mesh node per rock) |
Boulder | boulder_01 (2K) | 1.3 × 1.0 × 1.8 | 66 k triangles |
Rock07, Rock09 | rock_07, rock_09 (1K) | 0.17 × 0.14 × 0.32, 0.07 × 0.03 × 0.14 | stones: scale up (× 4, × 8 in the gallery) |
DeadTrunk, DeadTrunk02 | dead_tree_trunk, dead_tree_trunk_02 (2K) | 3.1 × 0.3 × 0.3, 4.1 × 1.1 × 1.1 | logs; 102 k and 83 k triangles |
Stump01, Stump02 | tree_stump_01 (2K), tree_stump_02 (1K) | 1.4 × 0.6 × 1.6, 1.5 × 0.5 × 1.4 | with roots |
DryBranches | dry_branches_medium_01 (1K) | 1.1 × 0.3 × 1.3 | three branches |
Fern | fern_02 (2K) | 2.0 × 0.4 × 1.7 | four clumps, alpha-tested (scale 1.2–1.5 reads as forest ferns) |
RootCluster01, RootCluster02 | root_cluster_01/02 (1K) | 4.1 × 1.5 × 2.7, 2.4 × 0.2 × 2.7 | G8e.7; 225 k / 340 k triangles: hero props only |
SingleRoot, BarkDebris | single_root, bark_debris_01 (1K) | 0.6 × 0.2 × 1.8, 1.2 × 0.1 × 0.7 | G8e.7; 61 k / 193 k triangles |
MossClumps | moss_01 (1K) | 0.22 × 0.04 × 0.03 | G8e.7; a row of twelve tiny moss tufts (alpha), one node each; the clutter scales one ×3–6 |
Stone01 | stone_01 (1K) | 0.15 × 0.07 × 0.09 | G8e.7; 54 k triangles: its textures dress the procedural pebbles |
GrassClumps | grass_medium_01 (1K) | 5.6 × 0.34 × 0.33 | G8e.7; a row of 17 grass clumps (alpha), one node each (310–6 400 triangles) |
DeadBranch | dead_quiver_branch_01 (1K) | 0.25 × 0.44 × 0.23 | G8e.7 |
Nettles | nettle_plant (1K) | 1.1 × 0.22 × 0.09 | G8e.7; a row of six nettles (alpha) |
| Debris | ambientCG | Size | Notes |
|---|---|---|---|
LeafLitter | ScatteredLeaves004 (1K) | 1.6 m patch | a clump of scattered leaves fading out at its edges: the leaf-litter cards |
ForestAssets (Forest/Src/World/ForestAssets.cs): project-relative paths (Content/Art/…; Resolve =
ContentPaths.Resolve), descriptors and factories. Textures load through ResourceLoader, so they are shared and a
saved scene references them by UID; every factory call returns new resources, so keep what you build many times.
ForestLayerAsset(Name,Tag,AssetId,TilingMeters,HeightBlendContrast,Tint, the four paths) forGrass…Needles,TerrainLayersin splat order,LayerIndex(tag);CreateTerrainLayer(layer),CreateTerrainLayers(),CreateTerrainMaterial()(aTerrainSplatMaterial3Dwith all eight).ForestPropAsset(AssetId,Resolution,Kind: Rock/Log/Stump/Debris/Plant,Size,Cutout, paths) for the eleven props,Props;CreatePropMaterial(prop): PBR, metallic 0, roughness and occlusion from the ARM map, back faces culled for closed meshes (the glTFs ask for double-sided), cut-out and double-sided for the fern;InstantiateProp(prop, material?): the glTF’s nodes (a nested instance when saved) with the material as each mesh’sMaterialOverride— the importer reads only Blinn-Phong colour and normal maps, so the override is what makes them PBR;LoadPropMeshes(prop): eachArrayMeshwith its transform, forMultiMeshscattering.SkyPanorama,CreatePanoramaSky(): an optionalPanoramicsky (the physical sky stays the default).
Asset gallery (ForestAssetGallery, Content/Scenes/asset_gallery.mscene, written by --write-scenes): a 64 m
terrain with one 7 m band per layer west to east (the rock band is a steep ridge: triplanar), a leaves / needles / moss
mix in front, every prop on it, a shadowed sun and the panorama sky. Game arguments: ++ --view overview|terrain|props,
--sky panorama|physical:
dotnet Examples/Forest/Forest.Desktop/bin/Release/net10.0/Forest.Desktop.dll --scene Content/Scenes/asset_gallery.mscene \ --fixed-fps 60 --max-frames 120 --screenshot gallery.png ++ --view props
Checked in the gallery: albedo in sRGB (the props and bands match their photos), OpenGL normals lit from the sun’s side, ORM roughness (gravel and mud read rough, a few rock faces glossy as scanned), the fern’s alpha test and the scale of each prop against the 7 m bands.
The valley
Section titled “The valley”ForestScene.Build() writes Content/Scenes/forest.mscene (dotnet run --project Examples/Forest/Forest.Desktop -- --write-scenes Examples/Forest/Content/Scenes; ProjectTests.CommittedSceneMatchesTheBuilder checks it byte for byte).
The scene holds only what an editor would tune: the Sun, the Environment (the look), the Valley node with its
knobs, the Lighting probe volume (below), the Player and the Audio. The valley is generated when the ForestValley node is ready, deterministically
from ValleyLayout and a seed, in about 1.2 s (Release, M5: heights 0.4 s, carve, pond and paint 0.4 s, trees 0.3 s,
props 0.1 s), plus Jitter2’s ≈ 2 s first step over the terrain’s 524k collision triangles. Generating beats committing
the layers as PNGs: nothing to re-commit to LFS on every tweak, nothing the tests need from LFS. Everything it builds is
an unowned child, never saved (not even its terrain’s layers: SceneSaver’s save hooks skip unowned nodes). The node is
a [Tool] (ADR 0169): the editor generates the same visuals; the player’s ground snap, the audio wiring and the map’s
walls run in the game only.
N (z = 0) outcrop ▲ source ─ fall (3.5 m) pool 1 · falls viewpoint (R2) glade (R1) pool 2 dense pine slope oak, ash pool 3 ═ log bridge (R3) lookout (R4) │ aspen banks (R5) trailhead ● pond (terrain water layer) S (z = 256)| Part | How |
|---|---|
Layout (ValleyLayout) | world = terrain-local metres, north −Z. The stream’s 15 points (surface, width, depth: a source in the outcrop, a 3.5 m fall, three wide deep pools between riffles, the bridge reach, the mouth), the pond (centre, radii 27 × 18 m, level 0.6 m, 1.7 m deep), outcrop, glade, the 21-point walking loop (≈ 480 m, ≈ 3 min at walk speed), spawn, sun (azimuth 25°, elevation 25°; ADR 0178), the fern dell, the pine glade, the dust clouds, tree clearings and view corridors, the reference shots |
Heights (ValleyGenerator) | the floor follows the stream’s downhill-clamped surface near it and a ±24 m smoothed profile away from it (the fall stays a local step), +0.4 m and 4 % per metre across; the west ridge rises to ≈ +30 m (gentler in the glade), the east to ≈ +46 m; the valley closes in the north and rises past the pond; fBm rolling ground, quieter near water and in the glade; the outcrop is a ridged plateau (+9 m); the pond basin is an ellipse with a noise-wobbled shore; the path bed is level across, follows the ground’s smoothed profile along it and blends into the slopes over 5 m (cut and fill), sunk 6 cm; the banks rise 1.25 m above the water at the bridge, a small gorge the log spans |
| Stream | River3D from the layout’s points plus two jittered points per reach (wandering banks), SectionLength 0.75 m, 6 cross segments, carved (Carve(), CarveId “stream”, BankWidth 2.2, ShoreLift 0.06); heights come from the layout, so no FitToTerrain |
| Pond | after the carve, ApplyPond floods every basin vertex below the level (TerrainData water layer, MaxWaterDepth 2 m) |
Splat (8 layers, ForestAssets order) | per cell from the final ground: needles under the pines, leaf litter on the aspen bank and forest edges, grass in the glade, patchy moss; then overrides: moss at rock bases, rock above ≈ 37° and on the outcrop’s steep, convex parts, moss 2–5 m from water, mud within ≈ 1.5 m of the stream and the pond, gravel in the stream bed and the pond’s deep bed, gravel path edges, dirt on the path |
| Terrain material | ForestAssets.CreateTerrainMaterial() with every ORM’s roughness lifted to 0.8 + 0.2 r (RoughenOrm: the scans’ 0.45 made dry ground glare white against a low sun; 0.6 + 0.4 r before ADR 0178), grass and leaves tinted, AntiTiling off (3.5 ms at 1080p), detail to 45 m, far from 110 m |
Ground cover (TerrainData.FoliageTypes) | meadow grass (ADR 0178: wide ForestFlowers.MeadowTufts, 4.8/m² on grass, to 36 m: GrassDistance, 32 before G8e.7), tall grass, woodland grass on leaves and moss, reeds on the pond’s mud, the Poly Haven fern (merged, re-centred, with a wind stream: FoliageMaterial3D, cut-out) on needles, leaves and moss, rock_07 stones on gravel and moss, and G8e.7’s clutter; densities × GroundCoverDensity (0.8); grass and ferns vary per clump in brightness and hue (InstanceValueJitter 0.15 / 0.14, InstanceHueJitter 0.12 / 0.1) |
Trees (ForestVegetation) | one jittered candidate per 4 m cell, accepted by zone: pines (large, medium, small; 2 + 2 + 1 seeds) on the east slope, outcrop, north and the high west ridge; aspens (5 variants) along the stream and round the pond; ash at the glade’s edge and in mixed woods; a few oaks in the glade; 35 % of each zone’s cells take G8e.5’s species instead (TreeNewSpeciesShare, ADR 0172): spruces and firs among the pines, birches among the aspens, beeches among the oaks and ashes. Clear of the path (3.2 m), water (1.6 m), the bridge (7 m), steep rock, the clearings and the R2/R4 view corridors. G8e.7 (art pass): the pine slope takes 70 % of its cells (60 % before), and 8 % of the forest’s gaps take a sapling of the zone’s species at 0.22–0.42 of its size (ForestVegetation.SaplingShare, TreeUnderstorey); every tree varies in brightness ±12 % and hue ±10 % (TreeValueJitter, TreeHueJitter). ≈ 1 850 trees in a TreeScatter with G8e.5’s foliage (below): levels at 22 m and 55 m, impostors from 85 m to 400 m, each tree cross-fading its own levels over ±4 m; levels 0 and 1 cast into every cascade where they are drawn (to 55 m), the impostor (ShadowCoarseLod 3) at half density into every cascade from 55 m, where level 1 stops (ADR 0179’s hand-off; it is drawn from 85 m), and into the far shadow for every tree; ≈ 180 bushes in a second one (to 75 m, no collision, only level 0 casts) |
| Props | ForestAssets.InstantiateProp with shared PBR materials: the bridge (dead_tree_trunk_02 × 1.6 along the path’s crossing, its top 14 cm above the banks, a 0.9 m walkway box tagged wood), mossy rock sets at the source and the fall, boulders round the fall and the pools, logs, stumps and dry branches placed near the path by hashed search; a collision box per mesh part (SurfaceBody3D, tagged rock / wood); rocks, logs, stumps and debris blend into the ground below them over 0.35 m (PropTerrainBlend 1, PropTerrainBlendHeight; the terrain’s macro texture, ADR 0175) |
| Edges | four 200 m high StaticBody3D walls just outside the map |
| Player | ForestValley stands the player on the ground and sets SurfaceResolver to Terrain3D.SurfaceTagAt (needles sound as leaves); ForestAudio gets the pine density for the woodpecker |
Trees are generated at load from the presets (23 variants, ≈ 1 s with their cluster cards and wind streams), not baked:
a baked TreeMesh .mres is ≈ 3 MB of text per variant to save that time. Their cluster atlases and impostors are baked
on the CPU the first time and then read from the tree-bake cache (TreeBakeCache, ≈ 8 s once per machine).
Foliage (G8e.5, ADR 0172). ForestVegetation.CreateTreeSpecies(clusters: true) gives every species leaf-cluster cards (from level 1 for Ez Tree’s presets, from level 0 for the leaf-heavy new
ones), coverage mips, hierarchical wind, a root flare, collars, moss (0.25 on conifers, 0.4 elsewhere) and detail normals.
ForestValley’s knobs: TreeClusters (on), TreeImpostorDistance (85 m), TreeLodFadeMargin (4 m, per-instance
levels), TreeImpostorShadowDensity (0.5: without it the impostor casters closed every sunfleck on the floor, R5),
TreeClusterShadowDensity (1: 0.6 brings back R2’s glade fleck for ≈ 2 ms of shadows), TreeNewSpeciesShare (0.35).
Trees change the light-probe bake: re-bake after changing any of them.
Pre-warm. For the first frames (PrewarmFrames, 8, from the second frame, after the foliage built its tiles) every
terrain level, foliage tile, tree level and prop is shown with no visibility range, every multimesh draws all its
instances and the foliage’s thinning is paused; then each gets back what LOD, distance and thinning chose. So every GPU
buffer, material set and pipeline exists before play: without it the walk allocated (and hitched) the first time
something came into view.
Light probes
Section titled “Light probes”The scene’s Lighting node is a LightProbeVolume (ADR 0170,
G8e.1; Lighting → Light probes), ForestScene.CreateProbes: terrain-following over the
whole 256 m terrain every 2 m, eight layers from 0.3 to 27 m above the ground (129 × 8 × 129 = 133 128 probes, a 5.3 MB
texture), 192 rays, two bounces. The bake is committed: Content/Scenes/forest-lighting.mres and its .probes
file (4.3 MB, LFS). The valley is generated at load, so the volume checks the bake’s hash against what the valley
generated (≈ 40 ms) and, with BakeWhenStale, bakes in the background when they differ (about a minute on every
core; the valley renders without probes until then and logs a warning). After changing the valley’s generator, its
trees or the sun, re-bake and commit:
dotnet build Examples/Forest/Forest.slnx -c Releasecd Examples/Forest/Forest.Desktop/bin/Release/net10.0dotnet Forest.Desktop.dll --bake-lighting --headless --project ../../../.. ++ --no-audioRun --write-scenes and rebuild before baking (the build copies the scene into the output; ADR 0178: a bake made
before the rebuild hashed a stale scene and the game rendered without probes), and check that a run logs “the bake is
current”.
(--project points at Examples/Forest, so the files land in the source tree rather than the build output; the editor’s
Bake Lighting button on Lighting does the same.) The art controls: Energy 4 (the bounce), SkyOcclusion 0.15
(15 % of the baked sky occlusion; 2.4 and 0.5 until Brogan’s 2026-10-09 playtest) and OcclusionTint (200, 225, 170): the full physical occlusion read near-black next to
the sunlit glade (the bake does not see the light the canopy scatters through its leaves’ gaps and green transmission),
so a fraction of it applies and the blocked sky light that remains takes the canopy’s pale green. A darker shade pinned
auto exposure at its maximum boost and blew out sun-facing slopes and canopy tops, so the shade is filled and the boost is
capped (AutoExposureMinLuminance 0.03); histogram auto exposure’s highlight protection
(ADR 0177) now keeps the sunlit slopes in range in shaded views. ++ --set Lighting.Visible=false renders
without the probes (the before shots below).
The look (ForestScene.CreateEnvironment, the Sun, Content/PostProcess/*.mres)
Section titled “The look (ForestScene.CreateEnvironment, the Sun, Content/PostProcess/*.mres)”The post-processing look is a resource file the editor tunes (ADR 0169):
Content/PostProcess/forest.mres (a PostProcessProfile: exposure, glow, shafts, SSAO, the grade) and
Content/PostProcess/forest-lens.mres (the CameraAttributesPractical), referenced by the scene’s Environment
(ForestLook). The files are the source of truth: --write-scenes writes them from ForestLook.CreateProfile and
ForestGrade.CreateLens only when they are missing, so values tweaked in the editor survive a scene rebuild; the table
holds their first values. To tune it: just editor Examples/Forest/project.mfproj opens the forest scene with
Preview Post-Processing on (the valley is generated in the editor too: ForestValley is a [Tool]); select
Environment, Edit (pencil) on Post Process, open a group, change a value: the view follows at once, and Save writes
forest.mres with the scene. Tests/QA/forest-post.qa drives that and captures it.
| Knob | Value | Why |
|---|---|---|
| Sun | DirectionalLight3D from azimuth 25° (north-north-east), elevation 25° (ADR 0178; 105° and 21° before), colour (1, 0.93, 0.82) (ADR 0178; (1, 0.87, 0.7) before), energy 2 (2.6 before the 2026-10-09 playtest), 4 cascades × 1024² to 140 m, split λ 0.8; G8e.2 (ADR 0167): ShadowCacheMode.Staggered, LightAngularDistance 0.5°, ContactShadows (0.4 m), FarShadowEnabled; no coarse cascades (ADR 0179; 2 before: the canopy’s shadow stopped at a line 16 m ahead) | low and ahead of the walk: the trailhead, the glade, the fall and most shots look into it (back-lit leaves and grass, shadows towards the camera, the haze glowing); a warm white rather than orange; the shadow budget below |
| Sky | Physical, turbidity 6, Mie 0.005, ground (0.28, 0.27, 0.2) | a clear morning; the IBL follows it |
| Ambient / reflections | AmbientSource.Sky × 2.4 (ADR 0178; 2 after the 2026-10-09 playtest, 1.6 before), ReflectedLightSource.Sky, occluded and joined by bounce light by the light probes (G8e.1) | the shade under the canopy darker, green and deep, the glade open; water and rock under the trees stop mirroring the bright sky |
| Fog | density 0.0015, height 8 m, height density 0.03, colour (0.78, 0.8, 0.77), sun scatter 0.06 (ADR 0178’s 0.004 / 0.05 / 0.12 read too hazy in play, 2026-10-10; 0.003, 6 m, 0.08, (0.42, 0.47, 0.53), 0.05 before it) | strong aerial perspective: distance fades to a pale, warm, low-contrast haze that lifts the blacks (the blue-grey fog read cold and dark) |
| Volumetric fog (ADR 0171) | density 0.004, albedo (1, 1, 0.95), anisotropy 0.65, sky affect 0.15, ambient inject 0.08 (ADR 0178’s 0.01 / 0.35 / 0.3 read too hazy in play, 2026-10-10; 0.009, white, 0.75, 0.2, 0.02 before it); length 64 m, noise 0.7 at 8 m; temporal reprojection 0.9 | the glowing back-lit air of the references: rays through the canopy gaps, and the shaded air glows softly with the (probe-occluded) sky instead of going clear and dark |
| Light shafts | off (intensity 2.3, decay 0.965, density 0.85 kept) | the volumetric fog draws real shafts; the screen-space ones on top made the glade milky |
| Exposure | auto, scale 0.16, speed 0.6, min luminance 0.03 (2026-10-09 playtest: 0.02 in G8e.7 let a shaded view boost exposure 8× and blow out the sunlit slopes; 0.04 in ADR 0170, 0.05 before); histogram (ADR 0177): centre-weighted, the 10–90 % band, highlight protection at the 98th percentile to 0.8; AgX tonemap (ADR 0178; engine ACES before: AgX read flat in G8e.7’s darker scene, but with the lifted shade and haze its soft shoulder and its desaturation of bright light are the references’ soft contrast; ACES turned the back-lit leaves saturated yellow) | adapts between the glade and the pine shade; a view that is mostly shade still exposes for it until the sunlit 2 % (slopes, treetops, the fall’s white water) would pass 0.8 into the tonemap. Shade-only views (R3, R5, R6) are unchanged; at 0.04 the probe-darkened R3 sat on the clamp (mean luminance 36) |
| Glow | intensity 0.3, threshold 4, luminance cap 3, no bloom | only the sun and its glints bloom |
| Wind | from the east, strength 0.35, 0.45 Hz, turbulence 0.4 | a breeze |
| Anti-aliasing | rendering.antiAliasing: Taa (ADR 0166), sharpness 0.25 (default); tree leaves and ferns AlphaDither | leaf, needle and grass edges stop shimmering in motion; the stream is a reactive pixel (keeps 0.2 of its history) so its flow does not smear |
| Grade (ADR 0168, refitted in G8e.7 and ADR 0178) | AdjustmentEnabled, AdjustmentColorCorrection = Content/Grading/forest-morning.cube (33³, ForestGrade with the numbers Tools/grade_from_shots.py fitted to ungraded renders of R1–R7, ForestGradeFit; written by --write-scenes), strength 1 | ADR 0178: the fitter’s targets are the references’ own statistics (--reference measures them): a curve through the shots’ p5/p50/p95 to 0.147/0.364/0.786 with blacks lifted to 0.03, the shade’s and the light’s casts moved halfway to the references’ (a neutral-green shade, a warm light), foliage saturation towards theirs; a faint warm white balance that spares the sky; saturation down in the highlights and the deepest shade |
| Lens (ADR 0168) | CameraAttributes: vignette 0.2, grain 0.015 (1.5 px), no aberration, no depth of field | a filmic frame without a muddy one; depth of field only in the photo shots |
| Water (G8e.6, ADR 0173) | both refract (RefractionEnabled) with caustics and screen-space reflections (rendering.waterSsr High). Stream: absorption (0.42, 0.13, 0.1), roughness 0.05, reflection 0.7, normals 1.2/5 m × 0.6, caustics 0.9 every 1.6 m to 1.2 m deep, shore foam 8 cm. Pond: peaty absorption (2.4, 1.4, 1.2), reflection 1, calm (normals × 0.3), caustics 0.6 every 2.5 m. The 3.5 m fall is a River3D fall: a jet (WaterfallMaterial3D) with spray cards at its foot | the stream shows its gravel bed bent by the ripples, with caustics; the pond’s shallows show the bed, its middle goes dark and mirrors the trees and banks; the fall is a white sheet with mist |
++ --set Node/Path.Property=value overrides any of these for a run (Valley.* before the valley generates), e.g.
--set Sun.ShadowCascades=3 --set Environment.Sky.Turbidity=10 --set Valley.GroundCoverDensity=1.2.
Reference shots
Section titled “Reference shots”++ --shot <1-8|name> puts a fixed camera at a pose of ValleyLayout.Shots (heights above the ground); --view x,y,z,tx,ty,tz[,fov] at any pose (y 0: eye height above the ground, −h: h above it). just forest-screenshots renders
all eight at 2560 × 1440 (the display’s limit: 2560 × 1308 on this Mac; frame 300 at a fixed 60 Hz: fixed wind, water and exposure) and scales them to 1600 px wide into
docs/images/forest/. R4 and R5 are photo shots (ReferenceShot.BlurNearUntil/BlurFarFrom/BlurAmount,
ForestDev.PhotoLens): their camera adds 32-tap depth of field to the Forest’s lens (R4: what is nearer than 6 m blurs;
R5, a close-up: nearer than 0.9 m and beyond 7 m at BlurAmount 0.3, a shallow focus on the log and the ferns); walking
never has depth of field. ADR 0178 recomposed them after the references’ compositions, most into the low sun:
![]() R1 Glade: a back-lit meadow under the sun, the glade’s trees in the haze (the meadow reference) | ![]() R2 The fall: from the falls viewpoint, the drop between rocks and birches, the haze and the sun behind (the rocks-and-haze reference) |
![]() R3 Log bridge: up the stream to the log bridge between birches (the birch-path reference) | ![]() R4 Misty pond: from the pond’s south shore up the hazy valley into the sun (the misty-glade reference) |
![]() R5 Fern dell: low along a mossy log among ferns, a shallow focus (the log-and-ferns reference) | ![]() R6 Pines: from a small glade on the outcrop, tall pines against the sun |
![]() R7 The fall, close: from pool 1’s south bank, the jet and its spray into the light | ![]() R8 Woodland: the path through the mixed wood south of the fall, straight into the sun: sunflecks, spruce and fir boughs, an ash trunk framing the haze |
R8 Woodland (72.5, 94 → 84, 72, 5 m above the ground; 60°, almost straight into the sun) fills the Cameras page’s
4 × 2 grid. It stands on the path inside R5’s fern-dell clearing and looks along the path (trees keep 3.2 m clear of
it), so it needed no clearing or view corridor of its own and the probe bake is unchanged (ValleyTests keeps every
shot’s camera 2.5 m from a trunk: the ash at its right edge is 3.2 m away). Candidates that lost: the lookout and the
outcrop looking south down the valley (front-lit, flat; pines now fill the lookout’s foreground), the pine slope’s path
(the slope crosses the frame at an angle), the pond’s birch bank (too close to R4) and the bridge looking upstream
(close to R3; kept for the README).
README images. The root README’s Forest showcase is six JPEGs, docs/images/forest/readme-*.jpg (1600 px wide,
quality 88, ≈ 3.8 MB together): R1, R8, R4, R7 and R5 converted from the just forest-screenshots PNGs, and
readme-upstream, a pose that is not a reference shot: from the log bridge up the stream between its birches,
just forest --fixed-fps 60 --max-frames 300 --no-log-file --screenshot /tmp/upstream.png ++ --no-capture --no-audio \ --resolution 2560x1440 --view 119.5,10,134,105,11,88,55sips -s format jpeg -s formatOptions 88 --resampleWidth 1600 /tmp/upstream.png --out docs/images/forest/readme-upstream.jpgVolumetric fog (G8e.3, ADR 0171). The shots above are with it. Against
the screen-space shafts it replaced: R1 keeps rays through the canopy around the sun but the trees to either side are no
longer veiled (the old milky haze was the shafts’ radial blur and bloom plus the height fog’s sun scatter; with the
shafts simply off the glade was already clear); R3 and R5 show shafts between the trunks with the sun off screen, and
the shade under the canopy got darker (mean luminance R3 59 → 39, R1 122 → 109) because nothing brightens the shaded
air any more; R4’s valley hazes over from the far fog (0.005). Looking 60° away from the sun (--view 48,-1.7,146,87.5,-15,99.5) the forward-scattering fog shows a warm glow and faint rays at the frame’s edge: with one
Henyey–Greenstein lobe (g 0.75) off-axis shafts stay subtle unless the fog is denser, which made R1 milky again.
Before/after pairs of the G8e phases are in docs/images/forest/g8e/ (G8e.6, water:
g8e6-r2-fall, g8e6-r3-bridge, g8e6-r4-vista, each -before and -after; the after shots and R2–R4 above were
rendered at 2304 × 1296, the display’s limit at the time, and scaled to 1600 × 900 like the rest).
Light probes (G8e.1, ADR 0170). The shots above are with them;
g8e1-r1-glade, g8e1-r2-fall, g8e1-r3-bridge and g8e1-r5-floor in docs/images/forest/g8e/
pair them with the same build without the volume (--set Lighting.Visible=false, the old exposure clamp). The
understory and the canopy’s undersides take a green tint and depth while the glade keeps its light (R1); the leaves
glow from below; under the trees the shade sits deeper and greener and the stream and the fall’s rocks stop reflecting
the bright sky (R2, R3); the forest floor’s shaded ferns ground themselves (R5). Mean sRGB of the frame, before → after:
R1 (114, 108, 77) → (115, 111, 73), R2 (44, 46, 23) → (34, 39, 15), R3 (32, 41, 23) → (30, 38, 16), R4 (58, 63, 35) →
(60, 70, 33), R5 (88, 79, 39) → (93, 84, 38).
Foliage (G8e.5). g8e5-r1-glade, g8e5-r3-bridge, g8e5-r4-vista and g8e5-r5-floor pair the shots before
(feature tip 306a6183, with probes) and after. The distant canopies (R4 across the pond, R1’s far edge) read as full
crowns instead of speckled single cards with sky between them; birches line the pond and the stream, spruces and firs
stand among the pines (R4’s left foreground), beeches in the mixed wood; trunk bases flare and carry moss (R5); the floor
keeps dappled light (R5, the impostor casters at half density); R2’s glade fleck is gone (the level-1 cluster casters
are denser; TreeClusterShadowDensity). Mid-distance clusters (20–40 m) read full but soft: an atlas cell is 256 × 512
px for a whole twig.
Art pass (G8e.7)
Section titled “Art pass (G8e.7)”ADR 0175; the shots above are the art pass. g8e7-r1-glade …
g8e7-r7-fall-close in docs/images/forest/g8e/ pair each shot after with the feature tip
before it (b1f26cd7, at the same poses: R1, R6 and R7 with --view). Mean luminance of the frame (sRGB, 0–255), before
→ after: R1 109 → 105, R2 52 → 74, R3 36 → 60, R4 64 → 93, R5 59 → 93, R6 23 → 37, R7 63 → 90.
- Shots. R1 recomposed: lower (1.5 m) and closer to the glade’s east edge, the sun behind a crown, a full meadow in
front. R6 Under the pines (60, 25 → 80, 50; 60°) and R7 The fall, close (92.5, 51 → 85.5, 42.5; 58°) added, with
clearings so no tree grows into them. Each
ReferenceShotkeeps its FOV and optional depth of field (R4 near, R5 far). - Look fixes. R1’s milk was the volumetric fog over the open glade looking into the sun (with it off the glade is clean but flat): density halved (0.018 → 0.009) with noisier density (0.5 → 0.7) and the far fog to 0.003, so the shafts read as shafts between the crowns. The glade’s turf: the grass layer’s tint darker and greener ((150, 168, 118) → (118, 140, 88)), moss in its hollows (the splat), scanned grass clumps between the blades and grass to 36 m. R3 (and every probe-darkened shade) sat on the auto-exposure clamp: its minimum is 0.02 (R3 36 → 60).
- Clutter (
ForestClutter, terrain foliage, art only,Clutterknob): ambientCG leaf-litter cards (1.6 m, flat, on leaves, needles, moss, grass and dirt, to 24 m), twigs (dry_branches_medium_01at 0.22–0.45), procedural pine cones on the needles, pebbles (procedural shapes instone_01’s textures) on leaves, dirt, mud and moss, moss tufts on moss, nettles along leaves and grass, to 15 m (30 m for the nettles); three grass_medium_01 clumps in the glade to 31 m. The twigs and pebbles blend into the ground (TerrainBlend). - Understorey and density: saplings in the gaps and a denser pine slope (the valley); a first version (16 % saplings, a second pine in 30 % of the slope’s cells, 2 700 trees) cost 3–4 ms; this one ≈ 1 ms at native 1440p.
- Variation: trees, bushes, grass, ferns, litter and scanned plants take a brightness and hue from a hash of their
origin (
InstanceVariation, ADR 0175). - Props sink into the ground: the terrain’s macro texture and
StandardMaterial3D.TerrainBlend(Terrain → Macro texture). - Grade: ACES kept; the LUT refitted to the renders (the look table above). To refit after the scene changes:
FOREST_SHOTS_OUT=/tmp/ungraded build/forest-screenshots.sh 300 --set Environment.PostProcess.AdjustmentEnabled=false,python3 Examples/Forest/Tools/grade_from_shots.py /tmp/ungraded/*.png, then--write-scenes. - Not done (measured or weighed):
TreeClusterShadowDensity0.6 (R2’s glade fleck) stays 1: ≈ 2 ms of shadows; the twig atlas cells stay 256 × 512 (512 × 1024 is 4× the cluster atlases, ≈ 1.2 GB for 18 species); the per-instance LOD cross-fade’s vertex cost (≈ 2 ms, ADR 0172) stays; R7’s fall is G8e.6’s sheet (a rectangular jet close up).
What limits the look (engine features the slice does not have yet): static probes (one sun direction; 2 m apart, so nothing sharper than a probe spacing), TAA softens the image a little in motion (the sharpen restores some of it), no impostors or coverage-preserving alpha mips (distant canopies read as speckled cards), reflections only of what is on screen (water elsewhere reflects the sky, dimmed under the canopy by the probes), spray as a few soft cards (particles are G6.3), and 1024² rather than 2048² shadow cascades (2048² costs 4–6 ms more under the leaf cards until G8e.5’s impostors).
Histogram auto exposure (ADR 0177)
Section titled “Histogram auto exposure (ADR 0177)”ADR 0177, after Brogan’s 2026-10-09 playtest: from the start
position, looking up the path (mostly canopy shade, a sun-facing slope and treetops in sun), the mean-luminance exposure
pinned at its maximum boost and washed the sunlit slope to cream. forest.mres now uses AutoExposureMode.Histogram with
highlight protection (above). g8e-histo-start-before / -after in docs/images/forest/g8e/
are the start view at 1920 × 1080, frame 240: frame mean luma 71 → 59, p95 191 → 177; the slope (x 520–900, y 330–520)
183 → 170, saturation 0.39 → 0.46; the shaded path 51 → 38. Reference shots, mean luma before → after: R1 105 → 101,
R2 66 → 56, R3 53 → 53, R4 82 → 81, R5 73 → 73, R6 36 → 36, R7 80 → 61 (R2 and R7: the fall’s white water and the sunlit
slope behind it now hold; the pools and banks are darker). The sun, sky and probe values did not change (the bake is
current).
Look-dev (ADR 0178)
Section titled “Look-dev (ADR 0178)”ADR 0178: Brogan gave five Unreal Engine forest screenshots (third-party: not in the repo, described in the ADR) and asked for their mood, colours and light, not a pixel match. What reads as “Unreal” in them: a low sun ahead of the camera behind the trees, a bright warm haze that fades the distance and lifts every black, muted olive and sage greens with only back-lit leaves and grass glowing, soft contrast, a dense floor (grass, ferns, flowers, mossy logs) and dust in the shafts. Forest content only; no engine code changed.
- Sun and light: the sun moved to azimuth 25° / elevation 25° (north-north-east), so the first leg of the walk and most shots look into it; a warmer white colour; ambient 2.4. The probes are re-baked for it.
- Atmosphere: the far fog is a pale warm grey, denser with a higher height falloff (aerial perspective); the
volumetric fog is a little denser, warm, less forward-peaked (0.65) and lit by the sky (
AmbientInject0.3), so the shaded air glows instead of going black. AgX replaces ACES. - Colour: every tree’s and bush’s leaf tint is multiplied towards olive (
ForestVegetation.Mute; the aspens use the birch leaf in a soft green instead of Ez Tree’s autumn yellow), less per-instance hue jitter (trees 0.04, grass 0.06, ferns 0.05) so no crown turns yellow, the grass layer, leaf litter, moss and rock layers tinted darker and less saturated, terrain roughness 0.8 + 0.2 r (the ground glared white at grazing angles into the sun). The grade fitter’s targets are now the references’ measured statistics (grade_from_shots.py --reference). - Ground cover: wide procedural meadow tufts (
ForestFlowers.MeadowTuft: 30 blades over 0.6 m, a few straw-coloured) instead of narrow clumps, so the meadow is a sward; grass translucency 0.75 in a pale yellow-green; denser woodland grass (1.8) and ferns (0.28, translucency 0.7); flowers (ForestFlowers: buttercups, ox-eye daisies, heather as whole procedural patches,Valley.Flowers); R5’s fern dell (ValleyLayout.FernDell: a mossy log among 70 ferns in one multimesh); R6’s small meadow on the outcrop (ValleyLayout.PineGlade). - Dust motes (
ForestDust,Valley.Dust):SprayCards3D(G8e.6’s mist cards) shrunk to 2 cm specks, lit by the shadowed sun and brighter looking into it, drifting up and fading, 64 per cloud at six places (ValleyLayout.DustClouds). The cheap stand-in for G6.3’s particles: they read as specks in the sunlit air, not as a dense shimmer. - Exposure (on ADR 0177’s histogram): unchanged: scale 0.16, speed 0.6, min luminance 0.03, centre-weighted 10–90 %,
highlight protection at the 98th percentile to 0.8. With the haze lifting the shade,
HighlightWhite0.9 rendered the same frames (protection does not engage) and scale 0.2 raised every shot’s median by 10–15 levels past the references.
Colour statistics of the frame (1600 px, sRGB 0–255; luma p5 / p50 / p95, mean HSL saturation, the share of pixels
below luma 16), the references against the shots before (feature tip 0d6742c8, at their old poses) and after:
| Reference | Before | After | |
|---|---|---|---|
| R1 Glade / ref1 meadow and pines | 38 / 84 / 175, 0.20 | 41 / 98 / 184, 0.44, 0.3 % | 42 / 106 / 201, 0.28, 0 % |
| R2 The fall / ref2 rocks and haze | 54 / 104 / 197, 0.15 | 14 / 47 / 185, 0.40, 8 % | 35 / 74 / 182, 0.28, 0 % |
| R3 Bridge / ref3 birch path | 31 / 70 / 146, 0.25 | 16 / 49 / 108, 0.37, 5.3 % | 35 / 78 / 188, 0.31, 0 % |
| R4 Misty pond / ref5 glade mist | 34 / 80 / 228, 0.28 | 28 / 70 / 183, 0.48, 0.6 % | 44 / 106 / 206, 0.23, 0 % |
| R5 Fern dell / ref4 log and ferns | 22 / 105 / 203, 0.36 | 20 / 66 / 154, 0.46, 3.7 % | 42 / 88 / 203, 0.35, 0.1 % |
| R6 Pines | 12 / 26 / 85, 0.40, 17 % | 38 / 102 / 191, 0.33, 0 % | |
| R7 The fall, close | 19 / 60 / 201, 0.42, 3.1 % | 43 / 85 / 170, 0.22, 0 % | |
| The player’s start view | 14 / 42 / 169, 0.40, 9.6 % | 30 / 94 / 207, 0.31, 0 % |
Pairs (g8e-look-<shot>-before / -after, and g8e-look-start-*) are in docs/images/forest/g8e/.
The blacks no longer crush anywhere, saturation is inside the references’ 0.15–0.36, the medians sit in or a little above
their range (the haze lifts the low end more than the references’ p5 in R1 and R4).
Still short of the references (honestly): the trees are Ez Tree’s generated crowns and cluster cards, not scanned meshes: up close (R6, R1’s left edge) the needles read as flat cards and the canopies as uniform blobs; the floor’s ferns are one Poly Haven fern repeated; the flowers are procedural strips, convincing only from a few metres; there is no real particle system, so the dust is sparse specks rather than a lit shimmer in the shafts; the shafts themselves are softer and fewer than Unreal’s (one Henyey–Greenstein lobe at 64 m, 1024² cascades); bloom stays off (the threshold-4 glow only), the references’ soft halo around the bright sky is the haze; distance has no real atmosphere scattering model, only the analytic fog; and the static probes are 2 m apart.
Performance
Section titled “Performance”Apple M5 (MoltenVK), Release, just forest-bench (1 800 frames along the spline, VSync off; other lanes’ GPU work was
running at times):
| Resolution | p50 | p90 | p99 | Sun shadows (GPU p50) |
|---|---|---|---|---|
| 1600 × 900 | 11.0 ms | 13.7 ms | 16.2 ms | 2.0 ms |
| 1920 × 1080 | 13.0 ms | 16.0 ms | 20.2 ms | 2.0 ms |
| 2560 × 1440 | 18.4 ms | 23.4 ms | 27.3 ms | 2.1 ms |
Measured before SSAO (ADR 0165), which turns the depth prepass on: at 1920 × 1080 the passes take ≈ 1 ms of GPU time
and the prepass saves about as much, so the frame stays near these numbers (ADR 0165 has the runs). The cinematic grade
(ADR 0168: LUT, vignette, grain, one AfterTonemap pass) costs ≈ 0.16 ms GPU at 1080p. ≤ 465 draws, 0 B per frame,
≈ 4 s from launch to the first measured frame. 1080p holds 60 fps at the median and through
most of the flight; the p99 frames are the vista over the whole valley. Measured at 1080p against these defaults: the
engine’s default sun shadows (4 × 2048² to 100 m) +12 ms, 3 × 2048² to 90 m +8 ms, 3 × 1024² to 110 m +1.2 ms;
hex-tiling +3.5 ms; the trees +10 ms; the ground cover +5 ms (p50). The GPU, not the CPU, bounds every case.
G8e.2 shadows (ADR 0167), just forest-bench
at 1920 × 1080, interleaved runs on a busy machine (other lanes’ GPU and Docker work), the old sun (2 × 1024² to 60 m)
against the new one with the same build:
| Sun | p50 | p90 | p99 | Sun shadows (GPU p50) |
|---|---|---|---|---|
| Before: 2 × 1024² to 60 m, every frame | 15.37 / 15.46 / 15.44 ms | 19.3–20.7 ms | 24.2 / 25.7 / 25.3 ms | 2.1 ms |
| After: 4 × 1024² to 140 m staggered, coarse level 1, PCSS 0.5°, contact shadows, far shadow | 14.11 / 14.20 / 15.40 ms | 19.5–21.6 ms | 25.0 / 23.9 / 28.8 ms | 3.7–4.0 ms |
The frame stays within noise of before: the contact shadows turn the depth prepass on, which pays for itself (ADR 0163), and that offsets PCSS in the lit pass and the larger shadow pass (still two 1024² cascade passes a frame, but over 140 m and with the far trees). Measured along the way: the same setup every frame 15 ms of shadows; 4 × 2048² staggered +4–6 ms of shadows; the first PCSS (16 point taps, no early-outs) +3 ms; Ez Tree’s level 2 as the coarse level +2–3 ms and a black forest floor; translation-only cascade margins 2.7 cascade passes a frame (turning). At the start of the lane, on a quieter machine, the old setup measured p50 13.0 ms, p99 20.25 ms, shadows 2.0 ms.
G8e.3 volumetric fog (ADR 0171), just forest-bench at 1920 × 1080,
interleaved runs on a busy machine (other lanes’ GPU and Docker work: the sun shadows read 3.6–5.0 ms here against 2.0 ms
quiet), the old look (screen-space shafts, sun scatter 0.3, no volumetrics) against the new with the same build:
| Look | p50 | p90 | p99 | Volumetric fog (GPU p50) |
|---|---|---|---|---|
| Before: screen-space shafts | 19.35 / 16.70 ms | 30.2 / 21.3 ms | 36.3 / 30.3 ms | — |
| After: volumetric fog, shafts off | 18.26 / 16.72 ms | 37.2 / 21.3 ms | 46.5 / 31.4 ms | 1.41 / 1.27 ms |
The quieter pair (the second) is equal at the median and 1 ms apart at p99: the fog’s passes replace the shafts’ three,
and the difference stays inside this machine’s run-to-run noise (the first pair’s spread is larger than either change).
A quiet-machine baseline is still to be recorded (--write-baseline). 0 B per frame.
G8e.1 light probes (ADR 0170), just forest-bench at 1920 × 1080,
interleaved runs with the volume hidden (--set Lighting.Visible=false) on a busy machine (sun shadows 3.5–4.9 ms
against 2.0 ms quiet):
| p50 | p99 | Volumetric fog (GPU p50) | |
|---|---|---|---|
| Without probes | 17.30 / 18.24 / 18.21 ms | 31.9 / 35.8 / 62.7 ms | 1.31–1.34 ms |
| With probes (first shaders) | 17.99 / 19.58 / 19.44 ms | 32.2 / 33.1 / 56.1 ms | 1.54–1.65 ms |
| Without / with (final shaders, busier) | 20.92 / 20.86 ms | 54.5 / 51.3 ms | 1.41 / 1.49 ms |
The fog’s per-step probe lookups were most of the first difference; looked up every fourth step and with a branchless layer lookup the pair is within noise. 0 B per frame.
G8e.5 foliage (ADR 0172), just forest-bench at
1920 × 1080, two interleaved rounds on a busy machine (other lanes’ render tests), medians; before = the feature tip
306a6183:
| p50 | p90 | p99 | Sun shadows (GPU p50) | Draws | |
|---|---|---|---|---|---|
| Before: Ez Tree’s cards, per-chunk levels, level 1 as the coarse caster | 18.86 ms | 24.66 ms | 32.50 ms | 3.61 ms | 465 |
| After (the Forest’s defaults) | 21.55 ms | 29.48 ms | 32.46 ms | 4.72 ms | 615 |
| After without the new species | 20.77 ms | 29.65 ms | 32.59 ms | 4.07 ms | 466 |
| After with 4 × 2048² cascades | 25.33 ms | 34.84 ms | 42.72 ms | 9.96 ms | 615 |
Per-instance levels cost most of the median (≈ 2 ms: every drawn batch runs the vertex shader for all its trees and
collapses the others; per-chunk levels measured 15.7 against 18.0 ms in one pair); the new species add 0.8 ms (more
variants, more batches); p99 is unchanged. 2048² cascades still cost ≈ 6 ms of shadows with the impostor casters (the
near cascades’ leaf cards are the cost), so the Forest keeps 4 × 1024². Measured on the way: biased LESS_OR_EQUAL
prepassed foliage +3.5 ms (replaced by invariant positions), each shadow density at 0.5–0.6 ≈ +2 ms of shadows (the
layers it opens are drawn), level-wide prewarm misses (hitches up to 0.4 s, fixed). 0 B per frame.
G8e.7 art pass (ADR 0175), just forest-bench, interleaved with the
feature tip before it (b1f26cd7) on a quiet machine. Frame times are quantised to 16.7 / 33.3 ms on this display (FIFO
presentation even with --no-vsync), so p50 is the share of frames over 16.7 ms; the native 1440p rows (all frames over
16.7 ms) show the cost directly:
| Run | Output / 3D | p50 | p90 | p99 | mean | Sun shadows (GPU p50) | Draws |
|---|---|---|---|---|---|---|---|
| Before | 1920 × 1080 / 1440 × 810 TAAU | 16.74–16.81 ms | 18.3–19.0 ms | 19.9–30.3 ms | 17.1 ms | 4.5–4.6 ms | 615 |
| After (baseline) | 1920 × 1080 / 1440 × 810 TAAU | 16.82–16.95 ms | 21.4–21.6 ms | 32.0–32.2 ms | 18.2–18.3 ms | 4.8–4.9 ms | 677 |
| Before | 2560 × 1308 / 1920 × 981 TAAU | 20.03 ms | 30.3 ms | 32.5 ms | 4.33 ms | 633 | |
| After | 2560 × 1308 / 1920 × 981 TAAU | 20.95 ms | 30.7 ms | 32.6 ms | 4.78 ms | 693 | |
| Before | 2560 × 1308 native | 27.44 ms | 31.8 ms | 40.3 ms | 26.4 ms | 4.23 ms | 633 |
| After | 2560 × 1308 native | 29.03 ms | 33.6 ms | 40.5 ms | 27.9 ms | 4.73 ms | 696 |
| … without the understorey | 27.94 ms | 4.39 ms | 650 | ||||
| … without the clutter | 28.86 ms | 4.66 ms | 679 | ||||
| … grass to 32 m | 28.75 ms | 4.69 ms | 688 | ||||
… without TerrainBlend | 29.09 ms | 4.73 ms | 696 |
≈ +1 ms at the median (+0.9 ms at 2560 × 1308 TAAU), mostly the saplings (their per-instance levels and shadows); the
clutter ≈ 0.2 ms, the longer grass ≈ 0.3 ms, the terrain blend nothing measurable. p90 at 1080p rises because more of the
glade’s low flight crosses 16.7 ms. 0 B per frame (--autowalk too). Load ≈ 5–6 s (4.7 s before): the trees; the
macro texture bakes on a worker thread (1.5–2 s, 2048²). benchmark-baseline.json is the “After” 1080p run.
--frames-csv file writes every measured frame (time, shadows, draws, camera position) to find where the slow ones are.
ADR 0179 shadow casters (Brogan’s “shadow line cut off”): no coarse cascades, the impostors’ hand-off at 55 m.
just forest-bench at 1920 × 1080 (TAAU 0.75) on a busy machine, two interleaved rounds, sun shadows GPU p50:
| Sun | Sun shadows (GPU p50) | Frame p50 / p99 |
|---|---|---|
ADR 0178’s casters (--set Sun.ShadowCoarseCascades=2) | 5.15 / 4.96 ms | 22.5 / 21.9, 42.2 / 29.3 ms |
| After, 140 m | 5.54 / 5.77 ms | 21.7 / 21.6, 31.3 / 39.1 ms |
| After, 200 m | 6.12 / 5.72 ms | 22.8 / 22.1, 34.2 / 32.8 ms |
| After, 250 m | 5.94 / 5.62 ms | 23.9 / 22.2, 37.4 / 30.4 ms |
The fix costs ≈ 0.6 ms of shadows (the far cascades draw the near trees’ mesh levels); the frame is within this machine’s
noise. A longer distance costs ≈ 0.1–0.3 ms more and moves every split out (cascade 0 to 12.8 m and cascade 1 to 28 m at
250 m, so the near cascades get coarser texels) without fixing anything: the far shadow already covers the valley past
140 m with no visible seam (fog hides its coarser texels there). The Forest stays at 140 m. --autowalk: 600 frames,
0 B. In the benchmark both shipped-configuration runs allocated 0 B; single small frames (56 B in the 200 and 250 m
runs, 1.5 KB in a run before the change) and one 365 KB burst with a 395 ms hitch (coarse cascades, round 1, not
repeated in round 2) came and went between identical runs on this busy machine.
The look: R1 gains shafts and floor shadows; the glade behind the fall in R2 and R7 loses most of its haze glow (the
volumetric fog there is now in the canopy’s shadow), and R7’s straight dark streak through the shafts (a cascade edge)
is gone. --set Valley.TreeClusterShadowDensity=0.7 brings flecks, shafts and some of the glow back for ≈ 2 ms of
shadows (ADR 0172): not applied.
ADR 0178 look-dev, just forest-bench at 1920 × 1080 (TAAU 0.75), interleaved with the feature tip before it
(0d6742c8, a second worktree), two rounds: p50 17.50 / 18.70 → 18.43 / 18.44 ms, p99 23.5 / 25.2 → 24.4 / 25.0 ms, mean
17.6 / 18.3 → 18.2 / 18.3 ms, sun shadows 4.85–5.06 → 4.39–4.41 ms, ≤ 677 → 692 draws, 0 B per frame. The meadow tufts,
woodland grass and ferns are most of it (without ground cover the pair is 14.3 → 15.2 ms); the flowers ≈ 0.1 ms, the dust
nothing measurable. A first version without the lookout’s view corridor grew 123 trees (+1.5 ms) and was fixed; denser
grass and ferns (+0.5–1 ms) were trimmed back.
FirstPersonController
Section titled “FirstPersonController”Forest/Src/Player/FirstPersonController.cs, a CharacterBody3D whose origin is at the feet: a capsule Shape
(Radius 0.3 m, StandingHeight 1.75 m), a Head at eye height (top − 0.12 m) and Head/Camera. Children the scene
does not have are created unowned when it is ready.
- Look. Mouse motion (
InputEventMouseMotion.Relative×MouseSensitivity0.1°/count,InvertY) per event inOnInputwhile the mouse is captured (Input.MouseMode, ADR 0125); the right stick (look_*) inOnProcesswith a 0.15 radial deadzone and a squared curve atGamepadLookSpeed140°/s; pitch within ±89°. Yaw and pitch both live on the head: the body’s transform is physics-interpolated (ADR 0024), so a yaw set on it between steps would be overwritten by the render pose.AddLook(yaw, pitch)turns it from code. - Mouse capture. Captured when ready (
CaptureMouse;++ --no-captureturns it off), released bypause(Escape / Start) and recaptured by a click; with the pause menu (the game),pauseopens the menu instead (ReleaseMouseOnPauseoff) and the menu disables the controller while it is open. - Move.
move_*in the head’s yaw frame; walk 2.5, sprint 5 (sprintheld orsprint_toggle, which clears when the stick is released), crouch 1.3 m/s (crouchheld orcrouch_toggle);Acceleration14 m/s² on the floor,AirControl0.3. The controller keeps its own horizontal velocity and, on the floor, moves along the floor plane at that speed (Godot’sfloor_constant_speed): reading the speed back fromVelocitywould lose the partMoveAndSlideremoves into a slope on every uphill step (0.7 m/s up a 30° ramp). Walls remove the intent going into them.FloorMaxAnglestays 45°,FloorSnapLength0.3 m in the scene. - Jump.
JumpVelocity4.2 m/s (≈ 0.9 m),Gravity9.81,CoyoteTime0.1 s, which is also the jump buffer. - Crouch. The capsule becomes
CrouchHeight1.1 m (bottom fixed); the head eases over 0.15 s. Standing up first checks a slightly thinner standing capsule withDirectSpaceState.IntersectShape(preallocated list); if blocked it stays crouched. - FOV.
HorizontalFov90° (60–110) sets the camera’s vertical FOV from the viewport’s aspect (VerticalFov(h, aspect)). - Head bob. From the walked distance: vertical at the stride frequency (lowest at each footstep), sideways at half,
HeadBobAmount0.035 m scaled by speed and eased out when stopping;HeadBobswitches it off. - Footsteps.
[Signal] Footstep(string surface)once perStrideLength(0.75 m) on the floor and on landing. The surface is"water"whenWaterDepthAt(feet) ≥ 0.05 m, else the floor collider’sIFootstepSurface(a ray down from the feet;SurfaceBody3Dhas an exportedSurface), elseSurfaceResolver(aFunc<Vector3, string?>; the content wave sets it toTerrain3D.SurfaceAt’s layer name), elseDefaultSurface(“default”).FootstepAudioplays them (Audio). - Wading.
World3D.Water(Water): speed ×WadeSpeedScale(ImmersionAt(feet)), plus the flow ×FlowPush0.3; more thanMaxWadeImmersion1.2 m ahead is a soft wall (no swimming). - Settings.
ForestSettings(FOV, sensitivity, invert Y, pad look speed, head bob, the audio volumes and the graphics options changed in the pause menu) is read fromsettings.jsonin the user data folder when the game runs underGameHost, until G4’sUserSettingsexists. - Nothing allocates per frame.
Input map
Section titled “Input map”project.mfproj: move_forward/back/left/right (WASD, arrows, left stick, deadzone 0.2), look_left/right/up/down
(right stick, deadzone 0: the controller applies its radial deadzone), sprint (Shift), sprint_toggle (left stick
press), crouch (C, Ctrl), crouch_toggle (B), jump (Space, A), pause (Escape, Start), photo (F2, Back; for the
HUD later).
Forest/Src/Audio/: the whole soundscape is procedural (no recorded or downloaded audio, so nothing to list in
NOTICE.md): seeded noise, filters and ZzFX, synthesised once into in-memory AudioStreams (AudioStream.FromSamples;
ADR 0162).
Wiring
Section titled “Wiring”var audio = ForestAudio.Attach(sceneRoot, river, player, fallPositions: [new Vector3(…)]); // or a ForestAudio node in the sceneaudio.PineDensity = p => PineDensityAt(p); // optional (0–1): quieter songbirds, more woodpecker in the pinesForestAudio (a Node; exports RiverPath, PlayerPath, FallPositions) creates its parts when ready, unowned
(never saved): Ambience, Stream, Birds under itself and FootstepAudio under the player. With empty paths it finds
the first River3D and FirstPersonController in its scene (its Owner, else its parent). It applies the volumes in
ForestSettings.Audio (settings.json) when the game runs under GameHost.
| Part | What | Bus |
|---|---|---|
AmbienceAudio | two non-positional stereo loops: wind roar (24 s, 32 kHz: decorrelated noise per channel through a band-pass whose centre rises with the gusts, 180 → 700 Hz, plus a low rumble; the right channel lags 0.35 s so gusts sweep across) and leaf rustle (17 s: 2–8 kHz noise with a fast flutter under its own gusts squared). The gust envelopes are sums of sines at whole multiples of the loop frequency, so they repeat with the loop; the noise is folded into a seamless loop by an equal-power crossfade of a 1 s tail. WorldEnvironment.WindStrength (the trees’ wind) sets the levels (calm: roar −6 dB, rustle −12 dB) and the roar’s pitch (0.9–1.08), eased over 2 s | Ambience |
StreamAudio | the brook (14 s mono: band-passed noise at 800 Hz and 2.5 kHz that undulates, a low rumble, ≈ 38 Minnaert bubbles per second — sines at 350 Hz – 2.4 kHz whose pitch rises 1.3–2.4× as they decay — and bursts of babble) on two AudioPlayer3Ds: the near one at the river point closest to the listener (Curve3D.GetClosestOffset), pushed toward the listener by up to half the width; the far one 12 m further along, −5 dB, half a loop apart. Both move at most 30 m/s. Level StreamVolumeDb + clamp(20·log10(flow / 1 m/s), −6, +6) from River3D.FlowSpeedAt; UnitSize 3 m, MaxDistance 30 m, low-pass to 2.5 kHz at range. The falls loop (10 s: the brook’s recipe, brighter and denser) plays at each of FallPositions (MaxDistance 80 m); the positions are parameters (River3D.Falls now lists the rendered falls, ADR 0173), and StreamAudio.FindSteepPoints(river, minSlope 0.25, spacing 15 m) finds candidates | Water |
BirdSongs | a pool of 6 AudioPlayer3Ds (UnitSize 8 m, low-pass at range); Poisson-timed (mean 4 s, at least 0.8 s apart), 10–40 m away and 6–16 m above the listener; a species never twice in a row, nor a variant. Five ZzFX species (BirdSynth): Warbler (sliding whistles), Chickadee (“fee-bee”), Finch (a trill from ZzFX’s repeat + tremolo, then a flourish), Thrush (flute phrases with pitch jumps, a soft trill), Woodpecker (a drum roll of tan knocks, slowing). Four songs and two calls each; pitch randomness 0.04. With PineDensity, songbirds are up to 8 dB quieter in dense pines and the woodpecker is likelier (weight 0.35 → 2) | Ambience |
FootstepAudio | FirstPersonController.Footstep → one of 6 variations of the surface’s step, never the same twice in a row, PitchRandomness 0.08, ±1.5 dB, crouch −6 dB, sprint +2 dB; two alternating players at the feet. Each step is a heel and a softer toe impact 50–90 ms apart built from thumps, filtered noise bursts, grains (crunch), resonant modes and sweeps: grass, leaves (also leaf_litter), moss, rock, dirt (also path), gravel, mud, water (a splash with droplets), wood (the log bridge) and default | Foley |
ForestSoundBank.Shared builds each group on first use (≈ 0.5 s in Debug for all of it, at scene load; about 17 MB of
samples, mostly the stereo beds); Preload() builds everything now. All synthesis is deterministic for its seed
(ForestRandom, SplitMix64).
Content/Settings/AudioBusLayout.mres (project.mfproj → audio.busLayout): Master (16 voices) → Ambience −7 dB
(10), Water −8 dB (6), Foley −7 dB (6, a light Freeverb: room 0.3, damp 0.7, wet 0.08), Music, SFX, UI. Measured
through the offline path (ForestAudioRenderTests, master mix):
| Scene | RMS | Peak |
|---|---|---|
| bed, calm / full wind | −42 / −35 dBFS | −25 / −19 dBFS |
| brook 4 m from the bank / 25 m | −32 / −47 dBFS | −13 / −29 dBFS |
| falls at 8 m | −30 dBFS | −14 dBFS |
| birds (a song every 2 s) | −40 dBFS | −20 dBFS |
| footsteps at a walk | −36 … −28 dBFS | −16 (moss) … −10 (wood) dBFS |
| everything, walking by the stream | −26 dBFS | −9 dBFS |
ForestAudioSettings (MasterVolume, AmbienceVolume, WaterVolume, FoleyVolume, linear 0–1, default 1) scale the
buses over the layout’s levels (ForestAudio.ApplySettings; 0 mutes).
Per frame
Section titled “Per frame”Struct maths and property sets on existing players: the stream’s closest-point search over the baked curve, the bird
schedule and footsteps allocate nothing (ForestAudioTests.FramesAllocateNothing: 600 frames of walking with birds,
footsteps on every surface and the server rendering, 0 B).
G8e.8 TAA upscaling (ADR 0174): the project renders the 3D scene at
0.75 (rendering.scaling3DScale) and TAAU upscales it (rendering.scaling3DMode: Taau); ++ --scale 1 is native
resolution, and the benchmark reports both resolutions (RenderWidth, RenderHeight, Scaling in the JSON). Apple M5
(MoltenVK), just forest-bench, no other GPU process running, two runs where given (the laptop’s display caps the
window at 2560 × 1308, so the “1440p” rows are that):
| Output | 3D | p50 | p90 | p99 | Sun shadows / SSAO / fog (GPU p50) |
|---|---|---|---|---|---|
| 2560 × 1308 | 1920 × 981 (0.75, TAAU) | 19.89 / 19.34 ms | 27.1 / 25.9 ms | 32.6 / 32.6 ms | 3.9 / 1.05 / 1.4 ms |
| 2560 × 1308 | 2560 × 1308 (native TAA) | 26.29 / 26.49 ms | 32.5 / 33.0 ms | 41.6 / 41.5 ms | 3.6 / 1.5 / 2.3 ms |
| 1920 × 1080 | 1440 × 810 (0.75, TAAU) | 15.25 ms | 20.4 ms | 23.9 ms | 3.5 / 0.49 / 0.86 ms |
| 1920 × 1080 | 1920 × 1080 (native TAA) | 18.21 ms | 25.4 ms | 32.5 ms | 3.5 / 0.92 / 1.44 ms |
| With G8e.5’s foliage (ADR 0172) merged: | |||||
| 2560 × 1308 | 1920 × 981 (0.75, TAAU) | 20.99 ms | 30.5 ms | 32.5 ms | 4.6 / 0.95 / 1.36 ms |
| 2560 × 1308 | 2560 × 1308 (native TAA) | 27.95 ms | 32.6 ms | 40.9 ms | 4.4 / 1.7 / 2.3 ms |
| 1920 × 1080 | 1440 × 810 (0.75, TAAU) | 16.86 / 16.74 ms | 19.9 / 18.7 ms | 31.7 / 21.4 ms | 4.5 / 0.57 / 0.88 ms |
| 1920 × 1080 | 1920 × 1080 (native TAA) | 20.93 / 20.60 ms | 28.5 / 25.2 ms | 32.5 / 32.5 ms | 4.5 / 1.0 / 1.5 ms |
0.75 is ≈ 25 % faster at the larger window and 16–19 % faster at 1080p (p99 −20 to −26 % in most runs); the per-pixel passes (SSAO, fog, the lit scene) scale with the pixel count, the shadows do not. The G8e target (p50 ≤ 14 ms at 2560 × 1440 on an M-series Pro) is not reached on this base M5 yet; 0 B per frame (one native run of five reported 56 B once; its rerun 0 B). The baseline file is G8e.7’s 1080p TAAU 0.75 run (below).
Loading screen (ADR 0183)
Section titled “Loading screen (ADR 0183)”The game starts behind a loading screen (async loading):
project.mfproj sets "loading": { "screen": "Content/UI/loading.rml" }, a document styled like the pause menu
(Content/UI/loading.rcss): the R1 glade shot (loading-glade.jpg) drifting slowly behind the menu’s dark green
scrim, the brand block (“MAINFRAME ENGINE”, Forest Demo), and a sunlight-gold bar with the stage and three pulsing
dots.

ForestValley is an ISceneLoadable: on the loading thread it generates the valley (“Generating the valley”), carves
and paints (“Carving the stream”), grows the trees and bushes and bakes their impostors (TreeScatter.Rebuild(progress):
“Growing trees”, “Baking tree impostors”), builds the terrain’s chunks and ground-cover tiles (Terrain3D.BuildNow:
“Shaping the terrain”) and the props (“Placing props”), all before the scene enters the tree; ready then only connects
the player and the audio. PollLoaded keeps the screen up through the pre-warm (every batch shown for
PrewarmFrames: “Uploading to the GPU”, “Compiling shaders”). Loaded synchronously (the editor, --sync-load, tests)
it builds in ready as before; ValleyTests checks both builds match node for node.
Start-up on an M5 (Release, 1920×1080, cached tree bakes; seconds from launch, three runs each):
| First frame on screen | Valley built | Game playable | |
|---|---|---|---|
Before (--sync-load) | 10.0–10.6 (black window until then) | 3.6–3.9 | 12.4–13.1 |
| After (async + loading screen) | 1.29–1.32 (the loading screen) | 4.7–4.9 (worker) | 8.7–9.0 (+0.45 s fade) |
The worker phase (~3.5 s) animates at the display rate; the warm-up after the scene enters (~3.5 s: entering ~0.3 s, the
first frame ~1.4 s of GPU uploads and pipelines, the foliage’s instance buffers ~0.6 s, the pre-warm frames ~0.1–0.15 s
each) still runs at a few frames per second behind the screen. The image prefetch took the first frame from ~6 s to
~1.4 s (PNG/JPEG decoding ~3.3 s, coverage mip chains ~1.3 s, now on worker threads). Benchmark and autowalk start once
the screen has faded (SceneTree.IsLoading); --screenshot captures and --max-frames count from there too.
ForestDev, --autowalk and the benchmark
Section titled “ForestDev, --autowalk and the benchmark”The Dev autoload (ForestDev) is idle unless started with game arguments after ++:
--autowalk:PathWalkerwalks the loop from the trailhead (it pressesmove_forwardand turns towards a point 4 m ahead on the path, sprints 10 s of every 40 and jumps once), then measures a 600-frame window after--warmupframes (default 1 500: past the first jump, landing and sprint, so the physics’ working lists have grown) withGC.GetAllocatedBytesForCurrentThreadon the main thread (the whole frame: tree, physics, rendering, UI, audio) and quits with exit code 0 when it allocated nothing, 1 otherwise, listing the frames that did. Measured: 0 B, 50 footsteps, 0 gen-0 collections.--autowalk-lap: walks the whole loop and quits when back at the trailhead (exit 1 if stuck after 12 minutes). Measured: 150 s, 469 m, 98 % of frames on the floor, 618 footsteps; the bridge and every slope walkable.--benchmark [--frames n] [--out file.json] [--baseline file.json [--write-baseline]]:ForestBenchmarkwaits 240 frames, then a camera flies the 15-pointForestBenchmark.Spline(low through the glade, past the fall, over the pine canopy, down to the pond and back to the trailhead) for n frames (default 1 800); it prints p50/p90/p99/p99.9/max of the wall-clock frame intervals, the sun shadows’, SSAO’s and the volumetric fog’s GPU time and the most draws, writes JSON and compares p50 and p99 with the baseline (exit 1 when more than 10 % slower or when a frame allocated).just forest-benchruns it at 1920 × 1080 againstExamples/Forest/benchmark-baseline.json.--shot,--view,--set: above;--resolution WxHresizes the window to W × H pixels;--scale ssets the 3D render scale (ADR 0174; the project’s is 0.75,--scale 1is native). A flag given twice takes its last value, sojust forest-bench --resolution 2560x1440 --scale 1overrides the script’s defaults.--no-captureleaves the mouse free;--no-audioskips the audioForestDevwould attach to a scene without one.- Frame rate:
ForestDevaddsFpsHud(Src/Dev/FpsHud.cs,Content/UI/fps.rml) on its ownUiLayer(100) in normal play: FPS, the average frame time and the slowest frame of the last half second, top right; F3 toggles it. Screenshot, benchmark and autowalk runs leave it out (clean captures, allocation windows), and so does--no-fps. The values are published as numbers twice a second (RmlUi formats them), so it allocates nothing per frame.
The controller has two fixes for the terrain: on 0.5 m triangles it lost the floor on 46 % of frames (walking up one
triangle’s plane leaves it above the next, flatter one, and MoveAndSlide does not snap a body moving up), so a ray
within FloorSnapLength now snaps it back; and touching down is a footstep only after LandingAirTime (0.2 s) in the
air. Jitter2’s “EPA could not converge” warning, which character queries on the terrain hit every few seconds and whose
string Jitter2 allocates, is muted for those queries (JitterLogMute, Physics).
Pause menu
Section titled “Pause menu”ADR 0180. Escape (or Start) opens it over the running world and closes it again; gamepad B closes it. While it is open the player is disabled (it stands still), the mouse is free and the world keeps running: wind, water, birds and dust move, and a fly-over keeps flying behind it, so a setting’s effect shows at once. Resume (or Escape) recaptures the mouse and saves what changed; Quit saves and quits.

| Page | Rows |
|---|---|
| Graphics | Display: upscaling (TAAU / FSR 1 / native), render scale (50–100 %), anti-aliasing (TAA / FXAA / off), VSync, field of view, the frame-rate readout. Shadows and light: shadow resolution (512–4096), distance (40–300 m), cascades (1–4), soft shadows (PCSS: the sun’s 0.5° disc or 0), contact shadows, ambient occlusion and its strength. Atmosphere: haze (the distance fog, 0–400 % of the look), volumetric fog and its density (0–400 %), screen-space light shafts, wind (0–400 %; the trees, the grass and the wind sound). Sun (lighting bake approximate): height (2–85°) and direction (0–355°). Camera and post-processing: tonemapper (AgX / ACES / Filmic / Reinhard), exposure (±2 EV), auto exposure, glow (0–400 %), the colour grade, depth of field and its focus distance, film grain, vignette, chromatic aberration. Vegetation: ground cover density (10–100 %) and distance (25–100 %) |
| Audio | master, ambience (wind, leaves, birds), water (the brook, the falls), footsteps (the Foley bus), mute |
| Controls | mouse sensitivity, invert look, gamepad look speed, head bob; the keys for reference |
| Cameras | Return to player; Fly-over: a cinematic camera along the benchmark spline at 7 m/s (ForestCameras.FlyOverSpeed), looking 14 m ahead with an eased turn, closed into a ≈ 70 s loop; Free camera: WASD, Space up, C down, Shift faster, mouse or right stick (FreeFlyCamera, no collision); the reference shots R1–R8 (Glade, Fall, Bridge, Vista, Floor, Pines, Fall close, Woodland) as cards with thumbnails (a full 4 × 2 grid), with their photo lenses. In a shot, A / D (or left / right) step through the shots. A camera closes the menu; Escape then opens it again on the Cameras page |

How it works.
ForestOptions.Allis the table: eachForestOptionhas a key, a label, its page and group, a kind (slider, Off/On switch, choice), a range and step, a readout format, an optional tooltip and “greyed out when” expression, andGet/Setdelegates on aForestWorld. Values are floats (switches 0/1, choices an index);Clampsnaps them.PauseMenu.BuildRml()generates the document from the table (styled byContent/UI/menu.rcss), with one data binding per option and onepick(key, value)event for the segmented controls.ForestWorld.Capture(scene, display, settings)finds the sun, the environment (and itsPostProcessProfileand lens), the light probes, the terrain, the player, the audio and the readout, and the renderer throughIForestDisplay(RendererDisplay:IVulkanContext.AntiAliasing,Scaling3DMode,Scaling3DScale,IRenderer.VSync). It records the scene’s own values: the defaults (“Reset to defaults”, per page) and the baselines the relative rows scale (haze, fog density, glow, wind: 100 % is the look).- Everything applies through runtime properties the engine already had, except the ground cover: the terrain’s
FoliageDensityScaleandFoliageDistanceScaleare new (Terrain; they thin and shorten the drawn prefix of every foliage tile, no rebuild). The shadow rows write the sun’s own properties (resolution, distance, cascades,LightAngularDistance,ContactShadows), so a change of the Forest’s shadow defaults is a change of the rows’ defaults. - The sun turns the
Sunnode only: the light probes keep their bake for the morning sun (no re-bake: a moved sun never re-checks the bake’s hash), so the bounce light and the canopy’s occlusion are approximate elsewhere, as the group’s title says. The sky, the IBL, the fog’s sun scatter and the shadows follow the light. - Persistence.
settings.jsonin the user data folder (GameHost.UserDataPath):HorizontalFov, the look settings,Audio(withMuted) andGraphics, a map of the graphics options that differ from the scene’s defaults (so a scene default changed later still reaches players who never touched that row). Applied at start in normal play only: capture, measurement and QA runs (--shot,--view,--benchmark,--autowalk,--menu,--camera;ForestDev.UsesPlayerSettings) keep the project’s look on every machine. Unknown keys are dropped and values clamped. - Per frame: closed, the menu’s layer is hidden and the menu checks two flags;
ForestCamerasmoves only the fly-over camera (an arc-length table built once): 0 B (AClosedMenuAllocatesNothingPerFrame,FlyingAndShotsAllocateNothingPerFrame). Opening it loads the document once and allocates. - Scaling. The style sheet is in dp throughout, laid out for 1920 × 1080; below 1600 dp wide the Graphics page is
one column and the shot cards smaller (
@media (max-width: 1599dp)). With reference-resolution UI scaling (ADR 0181) every 16:9 window keeps the 1080p layout. - RmlUi notes:
data-visibleonly setsvisibility: hidden(the space stays), so pages usedata-class-hidden; abox-shadowlarger than the window is clipped and stretched, so the panel’s shadow is small.
Game arguments for QA: ++ --menu graphics|audio|controls|cameras opens the menu on a page, --camera flyover|free|<1-8|name> starts a camera, --option key=value sets an option for the run, e.g.
just forest --resolution 1920x1080 --screenshot menu.png --max-frames 200 ++ --no-capture --menu graphics.
Recipes
Section titled “Recipes”| Recipe | What |
|---|---|
just forest *args | dotnet run --project Examples/Forest/Forest.Desktop -c Release -- {{args}} |
just forest-test | dotnet test Examples/Forest/Forest.Tests |
just forest-audio | the offline audio renders: Examples/Forest/artifacts/audio/*.wav (ambience calm and windy, brook near and far, falls, birds, footsteps per surface, the whole mix walking) |
just forest-screenshots [frames] | build/forest-screenshots.sh: R1–R8 at 2560 × 1440 → 1600 px wide into docs/images/forest/ (display awake, LFS art; FOREST_SHOTS="1 6" some, FOREST_SHOTS_OUT=dir elsewhere) |
just forest-bench [args] | build/forest-bench.sh: the benchmark at 1920 × 1080 (3D at 0.75, TAAU) against benchmark-baseline.json (--resolution 2560x1440, --scale 1; --write-baseline re-records; quiet machine) |
Forest.Tests (ControllerHarness: a headless tree with single-threaded physics and the project’s input map at
60 Hz):
- walk, sprint and crouch speeds, acceleration, the yaw frame; jump height ≈ v²/2g, coyote time, the jump buffer;
- a 30° slope climbs, a 55° one does not; standing up waits for headroom; the crouch toggle;
- wading speed, the deep-water wall, the flow push from a
River3D; - mouse look only while captured, sensitivity, invert Y and the pitch clamp; pause releases the mouse and a click recaptures it; the gamepad deadzone and curve; the FOV conversion;
- head bob amplitude and switch; one footstep per stride with the floor’s surface; the resolver and default fallbacks;
- 600 physics ticks of walking, sprinting, jumping and turning in water allocate 0 B;
- the pause menu (
PauseMenuTests.cs): every option has a unique key, a page and a valid range; clamping and readouts; defaults from the scene and a fresh settings file; every option reads back what it applied and sets the engine’s property (renderer switches through a fake display); only changed scene options are stored, Reset restores a page; the JSON round trip, saved settings applied at start, unknown keys dropped, out-of-range values clamped; saved settings only in play; the cameras (shots, stepping, photo lenses, the free camera, the fly-over’s steady speed, its loop or hand-back, the player stopped while the menu is open, 0 B per frame); over a headlessUiServer(theMenucollection): Escape (and key repeat), Start and B, every row in the document, a choice, a switch (and the greyed row), a slider and Reset clicked through RmlUi, saving on close, the Cameras page, the layout inside 1920 × 1080 and 1280 × 720, a closed menu 0 B per frame; - settings round trip; the project file (main scene, keyboard and pad bindings, TAA, pixel size); the scene contract
(sun first, the look’s switches, the valley, the audio wiring, a low east-south-east sun);
PathWalkerdrives the walk; - the valley (
ValleyTests): the same seed gives bit-identical heights, splat weights and tree placements and another seed different ones; the stream falls from the outcrop over the fall to the pond and the ridges stand above the floor; the path crosses the stream exactly once, at the bridge, above the water; the loop is a 2.5–5 minute walk and no point of the path is steeper than 35°; trees keep clear of the path, the water, the bridge and the clearings, and every zone has its species; a headless build (no art) stands the player on the ground at the trailhead, on the floor after a second, with water in pool 1, the pond at its level, the bridge’s walkway above the stream and 1 500–3 000 trees; footstep tags; the shots and the benchmark spline on the map; ForestAssetsTests: the layer tags and order; every manifest file exists, and every file underContent/Artis in the manifest with a.meta; NOTICE.md names every asset; the layers load 1024² textures with resource paths; the ORM maps have no metal; every prop imports through Assimp with one shared PBR override whose ORM is the ARM map (the fern’s albedo has alpha) at its documented size; the sky is a 4096 × 2048 JPG under 10 MB; the gallery scene matches its builder and paints one layer per band. The LFS-dependent tests skip when the art is only pointers.- audio (
ForestSynthTests,ForestAudioTests,ForestAudioRenderTestsoverAudioHarness: the controller harness plus anAudioServeron the manual null device with the Forest’s bus layout): every sound is deterministic per seed, loops are seamless, levels and frequency bands are where they should be, birds sing in their register; the wiring, the wind following the environment, the brook’s emitters (nearest point, half-width push, 30 m/s glide, louder with flow), steep-point detection, the Poisson bird schedule (count, spacing, no repeats, determinism, pines), footsteps per surface without repeats and from the controller’s signal, settings on the buses, 0 B per frame; and the offline renders above, which must not clip and must stay within their RMS and peak windows.
Not in CI yet (the G8d.9 CI step and build/forest-smoke.sh come with the release work).
Related
Section titled “Related”Water · Audio · Demo · Cameras & input · Physics · Projects & GameHost · future/forest-showcase.md






