Skip to content

Forest

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.

R1: the meadow, looking into the morning sun

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/)

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).

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).

KindSource → committed
Terrain layersambientCG 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)
PropsPoly 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
SkyPoly 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)TagambientCGTilingContrast
0 GrassgrassGround037 (mossy forest grass; tinted (196, 212, 170): the photo is a bright yellow-green)2.5 m0.25
1 LeavesleavesGround023 (leaf litter, sticks, dark soil)2.5 m0.25
2 MossmossMoss0022 m0.3
3 RockrockRock063 (mossy layered cliff)6 m0.2
4 DirtdirtGround067 (brown forest dirt)2.5 m0.15
5 GravelgravelGround108 (riverbed)1.5 m0.1
6 MudmudGround051 (dark wet mud, pebbles)2 m0.15
7 NeedlesneedlesGround082S (soil under needles and twigs)2 m0.25
PropPoly HavenSize (m, y up)Notes
MossyRocks01, MossyRocks02rock_moss_set_01/02 (2K)8.0 × 1.8 × 7.0, 8.3 × 1.4 × 3.4six / seven mossy rocks each (one mesh node per rock)
Boulderboulder_01 (2K)1.3 × 1.0 × 1.866 k triangles
Rock07, Rock09rock_07, rock_09 (1K)0.17 × 0.14 × 0.32, 0.07 × 0.03 × 0.14stones: scale up (× 4, × 8 in the gallery)
DeadTrunk, DeadTrunk02dead_tree_trunk, dead_tree_trunk_02 (2K)3.1 × 0.3 × 0.3, 4.1 × 1.1 × 1.1logs; 102 k and 83 k triangles
Stump01, Stump02tree_stump_01 (2K), tree_stump_02 (1K)1.4 × 0.6 × 1.6, 1.5 × 0.5 × 1.4with roots
DryBranchesdry_branches_medium_01 (1K)1.1 × 0.3 × 1.3three branches
Fernfern_02 (2K)2.0 × 0.4 × 1.7four clumps, alpha-tested (scale 1.2–1.5 reads as forest ferns)
RootCluster01, RootCluster02root_cluster_01/02 (1K)4.1 × 1.5 × 2.7, 2.4 × 0.2 × 2.7G8e.7; 225 k / 340 k triangles: hero props only
SingleRoot, BarkDebrissingle_root, bark_debris_01 (1K)0.6 × 0.2 × 1.8, 1.2 × 0.1 × 0.7G8e.7; 61 k / 193 k triangles
MossClumpsmoss_01 (1K)0.22 × 0.04 × 0.03G8e.7; a row of twelve tiny moss tufts (alpha), one node each; the clutter scales one ×3–6
Stone01stone_01 (1K)0.15 × 0.07 × 0.09G8e.7; 54 k triangles: its textures dress the procedural pebbles
GrassClumpsgrass_medium_01 (1K)5.6 × 0.34 × 0.33G8e.7; a row of 17 grass clumps (alpha), one node each (310–6 400 triangles)
DeadBranchdead_quiver_branch_01 (1K)0.25 × 0.44 × 0.23G8e.7
Nettlesnettle_plant (1K)1.1 × 0.22 × 0.09G8e.7; a row of six nettles (alpha)
DebrisambientCGSizeNotes
LeafLitterScatteredLeaves004 (1K)1.6 m patcha 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) for Grass … Needles, TerrainLayers in splat order, LayerIndex(tag); CreateTerrainLayer(layer), CreateTerrainLayers(), CreateTerrainMaterial() (a TerrainSplatMaterial3D with 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’s MaterialOverride — the importer reads only Blinn-Phong colour and normal maps, so the override is what makes them PBR; LoadPropMeshes(prop): each ArrayMesh with its transform, for MultiMesh scattering.
  • SkyPanorama, CreatePanoramaSky(): an optional Panoramic sky (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

The asset gallery, props view

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.

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)
PartHow
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
StreamRiver3D 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
Pondafter 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 materialForestAssets.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)
PropsForestAssets.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)
Edgesfour 200 m high StaticBody3D walls just outside the map
PlayerForestValley 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.

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:

Terminal window
dotnet build Examples/Forest/Forest.slnx -c Release
cd Examples/Forest/Forest.Desktop/bin/Release/net10.0
dotnet Forest.Desktop.dll --bake-lighting --headless --project ../../../.. ++ --no-audio

Run --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.

KnobValueWhy
SunDirectionalLight3D 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
SkyPhysical, turbidity 6, Mie 0.005, ground (0.28, 0.27, 0.2)a clear morning; the IBL follows it
Ambient / reflectionsAmbientSource.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
Fogdensity 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.9the 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 shaftsoff (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
Exposureauto, 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)
Glowintensity 0.3, threshold 4, luminance cap 3, no bloomonly the sun and its glints bloom
Windfrom the east, strength 0.35, 0.45 Hz, turbulence 0.4a breeze
Anti-aliasingrendering.antiAliasing: Taa (ADR 0166), sharpness 0.25 (default); tree leaves and ferns AlphaDitherleaf, 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 1ADR 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 fielda 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 footthe 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.

++ --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
R1 Glade: a back-lit meadow under the sun, the glade’s trees in the haze (the meadow reference)
R2
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
R3 Log bridge: up the stream to the log bridge between birches (the birch-path reference)
R4
R4 Misty pond: from the pond’s south shore up the hazy valley into the sun (the misty-glade reference)
R5
R5 Fern dell: low along a mossy log among ferns, a shallow focus (the log-and-ferns reference)
R6
R6 Pines: from a small glade on the outcrop, tall pines against the sun
R7
R7 The fall, close: from pool 1’s south bank, the jet and its spray into the light
R8
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,

Terminal window
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,55
sips -s format jpeg -s formatOptions 88 --resampleWidth 1600 /tmp/upstream.png --out docs/images/forest/readme-upstream.jpg

Volumetric 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.

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 ReferenceShot keeps 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, Clutter knob): ambientCG leaf-litter cards (1.6 m, flat, on leaves, needles, moss, grass and dirt, to 24 m), twigs (dry_branches_medium_01 at 0.22–0.45), procedural pine cones on the needles, pebbles (procedural shapes in stone_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): TreeClusterShadowDensity 0.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).

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).

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 (AmbientInject 0.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, HighlightWhite 0.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:

ReferenceBeforeAfter
R1 Glade / ref1 meadow and pines38 / 84 / 175, 0.2041 / 98 / 184, 0.44, 0.3 %42 / 106 / 201, 0.28, 0 %
R2 The fall / ref2 rocks and haze54 / 104 / 197, 0.1514 / 47 / 185, 0.40, 8 %35 / 74 / 182, 0.28, 0 %
R3 Bridge / ref3 birch path31 / 70 / 146, 0.2516 / 49 / 108, 0.37, 5.3 %35 / 78 / 188, 0.31, 0 %
R4 Misty pond / ref5 glade mist34 / 80 / 228, 0.2828 / 70 / 183, 0.48, 0.6 %44 / 106 / 206, 0.23, 0 %
R5 Fern dell / ref4 log and ferns22 / 105 / 203, 0.3620 / 66 / 154, 0.46, 3.7 %42 / 88 / 203, 0.35, 0.1 %
R6 Pines12 / 26 / 85, 0.40, 17 %38 / 102 / 191, 0.33, 0 %
R7 The fall, close19 / 60 / 201, 0.42, 3.1 %43 / 85 / 170, 0.22, 0 %
The player’s start view14 / 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.

Apple M5 (MoltenVK), Release, just forest-bench (1 800 frames along the spline, VSync off; other lanes’ GPU work was running at times):

Resolutionp50p90p99Sun shadows (GPU p50)
1600 × 90011.0 ms13.7 ms16.2 ms2.0 ms
1920 × 108013.0 ms16.0 ms20.2 ms2.0 ms
2560 × 144018.4 ms23.4 ms27.3 ms2.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:

Sunp50p90p99Sun shadows (GPU p50)
Before: 2 × 1024² to 60 m, every frame15.37 / 15.46 / 15.44 ms19.3–20.7 ms24.2 / 25.7 / 25.3 ms2.1 ms
After: 4 × 1024² to 140 m staggered, coarse level 1, PCSS 0.5°, contact shadows, far shadow14.11 / 14.20 / 15.40 ms19.5–21.6 ms25.0 / 23.9 / 28.8 ms3.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:

Lookp50p90p99Volumetric fog (GPU p50)
Before: screen-space shafts19.35 / 16.70 ms30.2 / 21.3 ms36.3 / 30.3 ms—
After: volumetric fog, shafts off18.26 / 16.72 ms37.2 / 21.3 ms46.5 / 31.4 ms1.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):

p50p99Volumetric fog (GPU p50)
Without probes17.30 / 18.24 / 18.21 ms31.9 / 35.8 / 62.7 ms1.31–1.34 ms
With probes (first shaders)17.99 / 19.58 / 19.44 ms32.2 / 33.1 / 56.1 ms1.54–1.65 ms
Without / with (final shaders, busier)20.92 / 20.86 ms54.5 / 51.3 ms1.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:

p50p90p99Sun shadows (GPU p50)Draws
Before: Ez Tree’s cards, per-chunk levels, level 1 as the coarse caster18.86 ms24.66 ms32.50 ms3.61 ms465
After (the Forest’s defaults)21.55 ms29.48 ms32.46 ms4.72 ms615
After without the new species20.77 ms29.65 ms32.59 ms4.07 ms466
After with 4 × 2048² cascades25.33 ms34.84 ms42.72 ms9.96 ms615

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:

RunOutput / 3Dp50p90p99meanSun shadows (GPU p50)Draws
Before1920 × 1080 / 1440 × 810 TAAU16.74–16.81 ms18.3–19.0 ms19.9–30.3 ms17.1 ms4.5–4.6 ms615
After (baseline)1920 × 1080 / 1440 × 810 TAAU16.82–16.95 ms21.4–21.6 ms32.0–32.2 ms18.2–18.3 ms4.8–4.9 ms677
Before2560 × 1308 / 1920 × 981 TAAU20.03 ms30.3 ms32.5 ms4.33 ms633
After2560 × 1308 / 1920 × 981 TAAU20.95 ms30.7 ms32.6 ms4.78 ms693
Before2560 × 1308 native27.44 ms31.8 ms40.3 ms26.4 ms4.23 ms633
After2560 × 1308 native29.03 ms33.6 ms40.5 ms27.9 ms4.73 ms696
… without the understorey27.94 ms4.39 ms650
… without the clutter28.86 ms4.66 ms679
… grass to 32 m28.75 ms4.69 ms688
… without TerrainBlend29.09 ms4.73 ms696

≈ +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:

SunSun shadows (GPU p50)Frame p50 / p99
ADR 0178’s casters (--set Sun.ShadowCoarseCascades=2)5.15 / 4.96 ms22.5 / 21.9, 42.2 / 29.3 ms
After, 140 m5.54 / 5.77 ms21.7 / 21.6, 31.3 / 39.1 ms
After, 200 m6.12 / 5.72 ms22.8 / 22.1, 34.2 / 32.8 ms
After, 250 m5.94 / 5.62 ms23.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.

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 × MouseSensitivity 0.1°/count, InvertY) per event in OnInput while the mouse is captured (Input.MouseMode, ADR 0125); the right stick (look_*) in OnProcess with a 0.15 radial deadzone and a squared curve at GamepadLookSpeed 140°/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-capture turns it off), released by pause (Escape / Start) and recaptured by a click; with the pause menu (the game), pause opens the menu instead (ReleaseMouseOnPause off) and the menu disables the controller while it is open.
  • Move. move_* in the head’s yaw frame; walk 2.5, sprint 5 (sprint held or sprint_toggle, which clears when the stick is released), crouch 1.3 m/s (crouch held or crouch_toggle); Acceleration 14 m/s² on the floor, AirControl 0.3. The controller keeps its own horizontal velocity and, on the floor, moves along the floor plane at that speed (Godot’s floor_constant_speed): reading the speed back from Velocity would lose the part MoveAndSlide removes into a slope on every uphill step (0.7 m/s up a 30° ramp). Walls remove the intent going into them. FloorMaxAngle stays 45°, FloorSnapLength 0.3 m in the scene.
  • Jump. JumpVelocity 4.2 m/s (≈ 0.9 m), Gravity 9.81, CoyoteTime 0.1 s, which is also the jump buffer.
  • Crouch. The capsule becomes CrouchHeight 1.1 m (bottom fixed); the head eases over 0.15 s. Standing up first checks a slightly thinner standing capsule with DirectSpaceState.IntersectShape (preallocated list); if blocked it stays crouched.
  • FOV. HorizontalFov 90° (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, HeadBobAmount 0.035 m scaled by speed and eased out when stopping; HeadBob switches it off.
  • Footsteps. [Signal] Footstep(string surface) once per StrideLength (0.75 m) on the floor and on landing. The surface is "water" when WaterDepthAt(feet) ≥ 0.05 m, else the floor collider’s IFootstepSurface (a ray down from the feet; SurfaceBody3D has an exported Surface), else SurfaceResolver (a Func<Vector3, string?>; the content wave sets it to Terrain3D.SurfaceAt’s layer name), else DefaultSurface (“default”). FootstepAudio plays them (Audio).
  • Wading. World3D.Water (Water): speed × WadeSpeedScale(ImmersionAt(feet)), plus the flow × FlowPush 0.3; more than MaxWadeImmersion 1.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 from settings.json in the user data folder when the game runs under GameHost, until G4’s UserSettings exists.
  • Nothing allocates per frame.

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).

var audio = ForestAudio.Attach(sceneRoot, river, player, fallPositions: [new Vector3(…)]); // or a ForestAudio node in the scene
audio.PineDensity = p => PineDensityAt(p); // optional (0–1): quieter songbirds, more woodpecker in the pines

ForestAudio (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.

PartWhatBus
AmbienceAudiotwo 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 sAmbience
StreamAudiothe 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 candidatesWater
BirdSongsa 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
FootstepAudioFirstPersonController.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 defaultFoley

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):

SceneRMSPeak
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).

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):

Output3Dp50p90p99Sun shadows / SSAO / fog (GPU p50)
2560 × 13081920 × 981 (0.75, TAAU)19.89 / 19.34 ms27.1 / 25.9 ms32.6 / 32.6 ms3.9 / 1.05 / 1.4 ms
2560 × 13082560 × 1308 (native TAA)26.29 / 26.49 ms32.5 / 33.0 ms41.6 / 41.5 ms3.6 / 1.5 / 2.3 ms
1920 × 10801440 × 810 (0.75, TAAU)15.25 ms20.4 ms23.9 ms3.5 / 0.49 / 0.86 ms
1920 × 10801920 × 1080 (native TAA)18.21 ms25.4 ms32.5 ms3.5 / 0.92 / 1.44 ms
With G8e.5’s foliage (ADR 0172) merged:
2560 × 13081920 × 981 (0.75, TAAU)20.99 ms30.5 ms32.5 ms4.6 / 0.95 / 1.36 ms
2560 × 13082560 × 1308 (native TAA)27.95 ms32.6 ms40.9 ms4.4 / 1.7 / 2.3 ms
1920 × 10801440 × 810 (0.75, TAAU)16.86 / 16.74 ms19.9 / 18.7 ms31.7 / 21.4 ms4.5 / 0.57 / 0.88 ms
1920 × 10801920 × 1080 (native TAA)20.93 / 20.60 ms28.5 / 25.2 ms32.5 / 32.5 ms4.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).

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.

The Forest's loading screen

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 screenValley builtGame playable
Before (--sync-load)10.0–10.6 (black window until then)3.6–3.912.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.

The Dev autoload (ForestDev) is idle unless started with game arguments after ++:

  • --autowalk: PathWalker walks the loop from the trailhead (it presses move_forward and 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 --warmup frames (default 1 500: past the first jump, landing and sprint, so the physics’ working lists have grown) with GC.GetAllocatedBytesForCurrentThread on 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]]: ForestBenchmark waits 240 frames, then a camera flies the 15-point ForestBenchmark.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-bench runs it at 1920 × 1080 against Examples/Forest/benchmark-baseline.json.
  • --shot, --view, --set: above; --resolution WxH resizes the window to W × H pixels; --scale s sets the 3D render scale (ADR 0174; the project’s is 0.75, --scale 1 is native). A flag given twice takes its last value, so just forest-bench --resolution 2560x1440 --scale 1 overrides the script’s defaults.
  • --no-capture leaves the mouse free; --no-audio skips the audio ForestDev would attach to a scene without one.
  • Frame rate: ForestDev adds FpsHud (Src/Dev/FpsHud.cs, Content/UI/fps.rml) on its own UiLayer (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).

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.

The pause menu's Graphics page

PageRows
GraphicsDisplay: 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 %)
Audiomaster, ambience (wind, leaves, birds), water (the brook, the falls), footsteps (the Foley bus), mute
Controlsmouse sensitivity, invert look, gamepad look speed, head bob; the keys for reference
CamerasReturn 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

The pause menu's Cameras page

How it works.

  • ForestOptions.All is the table: each ForestOption has 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, and Get/Set delegates on a ForestWorld. Values are floats (switches 0/1, choices an index); Clamp snaps them. PauseMenu.BuildRml() generates the document from the table (styled by Content/UI/menu.rcss), with one data binding per option and one pick(key, value) event for the segmented controls.
  • ForestWorld.Capture(scene, display, settings) finds the sun, the environment (and its PostProcessProfile and lens), the light probes, the terrain, the player, the audio and the readout, and the renderer through IForestDisplay (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 FoliageDensityScale and FoliageDistanceScale are 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 Sun node 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.json in the user data folder (GameHost.UserDataPath): HorizontalFov, the look settings, Audio (with Muted) and Graphics, 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; ForestCameras moves 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-visible only sets visibility: hidden (the space stays), so pages use data-class-hidden; a box-shadow larger 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.

RecipeWhat
just forest *argsdotnet run --project Examples/Forest/Forest.Desktop -c Release -- {{args}}
just forest-testdotnet test Examples/Forest/Forest.Tests
just forest-audiothe 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 headless UiServer (the Menu collection): 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); PathWalker drives 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 under Content/Art is 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, ForestAudioRenderTests over AudioHarness: the controller harness plus an AudioServer on 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).

Water · Audio · Demo · Cameras & input · Physics · Projects & GameHost · future/forest-showcase.md