Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docs/performance/audit-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Performance audit implementation — 7 September 2026

Based on the supplied performance audit and baseline `1b488d99aae81fae615eecf281fb24e2f9995cdf`.

This change removes avoidable synchronization, repeated analytical sampling and retained pass targets. Requested render scale, noise octaves, cloud steps, water settings, textures, props and LOD settings are preserved. Full 3D visual parity and device-specific startup/FPS gains remain unverified; this PR should stay a draft until those checks pass.

## Changes

- **Minimap:** reuse the existing backend-neutral asynchronous readback helper in both canvas and worker paths. Limit work to one pending image, restore the render target immediately, reuse front/back CPU image buffers, and discard results after an edit, changed view, canvas handoff or disposal. Worker responses receive an owned copy so transferring the RGBA buffer cannot detach the cached map. Coalesce refresh requests while a worker result is pending.
- **Analytical maps:** preserve the 128 × 128 sample grid and 256 × 256 RGBA output. Height/water, noise, biome, slope and props views compute only the field their pixels need. Hover continues to use the complete surface sample. Batches stop after 256 samples or a cooperative 4 ms budget. Map grids and camera overlays reuse the cached base image. A hidden minimap or pending readback no longer forces full Studio scene renders through its dirty flag.
- **Shaders:** reuse the exact centre climate in the live Tile height call, including all composed offsets. Neighbour climate/height samples, Manual/Infinite cache semantics and rasterized shoreline classification are preserved. The same fogged plinth-wall branch runs before expensive terrain shading only outside debug/export modes.
- **Boot:** cloud readiness overlaps terrain/water submission and incremental geometry. The final compile stage joins readiness, refreshes the material list/target and compiles newly required variants before water activation and presentation. Isolated shader benchmarks retain their original resource-readiness precondition. Main-thread staggered submission now overlaps driver readiness instead of waiting for each material to finish linking before submitting the next.
- **Renderer ownership:** select/restore targets inside each scheduled submission, including cube face/mip and exception paths. Keep the scheduler at one submission at a time. Let Three deduplicate actual programs instead of coalescing potentially different topology/target jobs by an incomplete application key.
- **Editor loading:** defer the existing SideDrawer chunk until idle capacity after exact scene readiness, with a 1.2 s idle timeout and normal lazy loading on first use. The production chunk is about 191.9 kB / 52.8 kB gzip; this changes when it loads, not total download size. The landing scene remains mounted.
- **Memory and diagnostics:** release disabled post-processing targets and unused low-resolution cloud buffers. Track registered live/peak estimated bytes without rescanning the ledger for every reservation. Add an on-demand inventory for reachable geometry buffers, textures/atlases, retained materials, water/cloud targets and CPU backing arrays, deduplicating shared resources. The inventory is advisory; it does not change admission budgets or dispose shared resources.
- **Comparison data:** graphics and performance exports include resolved settings, scene parameters, actual/base DPR, buffer dimensions, effective scale, boot stages/render key, shader-run flags and minimap counters. Initial geometry reports queue/batch metrics while keeping the complete existing readiness gate.

## Verification performed

- Baseline: 590 tests passed across 71 files; production build passed.
- Updated branch, including the runtime follow-up: 632 tests passed across 74 files; production build passed. Existing Vite warnings about large chunks remain.
- Regression coverage includes single-flight readback, row order, worker buffer ownership, exact cell centres/pixels, cancellation, retries, canvas handoffs, analytical field parity, shader generation for Tile/Manual/Infinite/shared modes, compiler submission overlap and state restoration, cloud failure gating, resource release/recreation, shared-resource counting and reservation rejection.
- CPU benchmark: six map modes × three fixed seeds, non-integer zoom/centre, authored height offsets and varying props masks. All 18 complete RGBA images matched the baseline byte for byte. Raw observations and hashes are in `minimap-audit-results.json`.

The following are medians of three observations per mode in Node v24.19.0 in this environment, rounded to 0.1 ms. Runs are sequential before/after, not randomized; they are a narrow CPU check, not a browser, RTX, M4, mobile or GPU benchmark.

| Map | Before: one blocking task (ms) | After: total including yields (ms) | Largest observed new sample batch (ms) | Differing RGBA bytes |
|---|---:|---:|---:|---:|
| height | 435.4 | 156.3 | 6.6 | 0 |
| water | 446.7 | 158.0 | 6.5 | 0 |
| noise | 493.3 | 158.2 | 4.8 | 0 |
| biome | 434.4 | 113.3 | 1.3 | 0 |
| slope | 424.5 | 327.7 | 4.7 | 0 |
| props | 398.1 | 72.7 | 1.4 | 0 |

The 4 ms budget is cooperative: a single sample, GC or runtime scheduling can exceed it. The observed maximum is reported rather than claiming a strict 4 ms ceiling. Cached requests still copy the transferable RGBA payload but perform no terrain resampling or GPU readback. No FPS or general application-speed multiplier is established by these observations.

Reproduce from a checkout containing the audit baseline commit:

```sh
npm ci --ignore-scripts
npm test
npm run build
node tools/benchmark-minimap.mjs > minimap-results.json
```

The benchmark's default baseline is the pinned audit revision. It uses the current unchanged CPU height sampler and uniforms for both versions to isolate the minimap implementation.

## Pending visual/device checks

The provided browser reported `net::ERR_BLOCKED_BY_CLIENT` for both the local preview and its localhost retry. No 3D screenshots, GPU timings, actual browser cold-start timings, or first-tool latency measurements were obtained. Unit tests and analytical image equality do not establish full scene parity.

Before marking ready, compare main versus this branch at identical seed, camera, effective profile, output size and animation/history state: detailed Tile slopes and walls; above/below-water shorelines; heavy clouds; large assemblies; Manual, imported and graph terrains; Infinite and Planet. Check debug/export modes, rapid edits/resize/retry/context loss, minimap zoom/pan/hover and repeated pass/mode switches. Record ordinary cold and warm starts separately from isolated shader benchmark runs. Confirm the first visible draw introduces no unexpected cold programs.

## Audit items kept gated

The sampled Tile height/climate cache remains disabled: atomic publication alone cannot prove a sampled field matches arbitrary procedural fragment coordinates. The complete initial LOD queue/halo gate remains in place; visible-set metrics need real scene traces before shortening it. Inactive world cache eviction and full atlas/preallocation admission need an ownership and peak-memory trace before changing their policy. The new resource inventory covers known reachable resources, not all driver allocations or measured VRAM. DPR inconsistencies are now observable but this change does not silently reduce requested pixels.

## Runtime follow-up — 8 September 2026

The supplied device trace reports 18,064 ms waiting for `terrain:base` and 19,932 ms in the overall compile stage. It establishes a slow terrain-program startup, not a measurement of texture generation during the later approach. The screenshot separately shows 60 FPS / 1 draw / 1 triangle in the overlay versus 39 FPS / 104 draws / 56K triangles in the status bar.

- **Interaction cadence:** the worker dispatches input to its canvas/document facades, which do not bubble to the window activity listener. Consequently the medium/low-tier idle cadence could remain active during zoom and dragging. Forwarded input now resets the activity timestamp. Continued dragging also resets it on the main thread, and camera settling bypasses idle pacing. Passive hovering still allows idle pacing. Requested quality, pixels and LOD settings are unchanged.
- **Counters:** both displays now use the same elapsed-time-normalized count of rendered scene frames. The profiler receives scene counters before later fullscreen passes can overwrite `renderer.info`; skipped callbacks do not dilute CPU frame timing. Average FPS uses rendered-frame intervals and includes long stalls. Camera-scene counts include the shared opaque and overlay geometry; they are not a sum of every auxiliary GPU pass. CPU frame time and asynchronous GPU timing remain separate measurements.
- **Near-water warmup:** approaching within 120 units of water previously traversed/sorted the whole scene every frame and could compile hidden materials and alternate topology programs. It also detached the Studio cloud while awaiting those compiles. The underwater effect actually samples an already-rendered scene target, so only its fullscreen composite requires preparation here. Warmup now leaves the scene attached, yields before submission, allows one pending job and rejects obsolete material/target/renderer results. When water is enabled at boot, the composite is prepared under the existing loading cover; enabling water later or resizing retains a small-pass fallback. Scene edits and input-target changes alone no longer invalidate this independent composite. The camera pipeline still owns scene rendering.
- **Terrain work:** procedural detail skips the unused projection at exactly planar/triplanar endpoints and skips fully faded detail except in the raw-grain debug view. Planar surface textures retain their original Y projection, UV salts, channels and normal normalization while avoiding the two zero-weight projections. Prop surface readback packs the same rasterized height before evaluating unused fragment climate, analytic heights and normals. Tile debug precedence is retained. No sampled Tile cache or reduced texture/detail setting is introduced.
- **Inactive reflections:** scene revision serialization is lazy and runs only when the Cinematic planar reflection pass can capture. Other water modes retain their existing disabled-pass behavior without serializing the scene every frame. Active reflection revision/cadence behavior is covered for both eager and lazy keys.

Regression coverage includes 60 callbacks producing 30 scene frames, fullscreen-pass counter replacement, long frame stalls, structured-clone transport of diagnostics, worker zoom/long drags versus passive hover, boot composite readiness, repeated near-water requests, and resize/disposal/program changes during compilation. The complete suite passes (632 tests); the production build passes. These checks do not establish a measured GPU speedup or reproduce the reported freeze.

Device verification remains required: compare this branch at the same camera/settings while orbiting down to ground level, crossing the shoreline repeatedly, and reopening the performance overlay. Check textured/detail terrain at near/fade/far distances, planar and triplanar surfaces, raw-grain debug and prop placement. Compare FPS and scene counts at steady state (the two UI polls can briefly straddle a sampling-window update). The exported profiler metrics include `underwaterWarmSubmitMs` and `underwaterWarmTotalMs`. Keep CPU submission, shader waiting and GPU frame time distinct when assessing any remaining stall.
187 changes: 187 additions & 0 deletions docs/performance/minimap-audit-results.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
{
"baseline": "1b488d99aae81fae615eecf281fb24e2f9995cdf",
"environment": "Node v24.19.0, CPU-only",
"conditions": "128x128 exact samples / 256x256 RGBA; five octaves; zoom 2.7; authored height offsets; varied prop masks; sequential before/after (not randomized); no GPU/browser timing",
"runs": [
{
"mode": "height",
"seed": 17,
"beforeSingleTaskMs": 435.409,
"afterTotalMs": 154.847,
"afterMaxSampleBatchMs": 4.012,
"afterCachedMs": 0.211,
"differingBytes": 0,
"rgbaSha256": "da949dfb1ca2fef7747c0840a91bb4e64c5260f91c96a583089b4bb5a35efdd1"
},
{
"mode": "height",
"seed": 42,
"beforeSingleTaskMs": 418.399,
"afterTotalMs": 156.289,
"afterMaxSampleBatchMs": 2.813,
"afterCachedMs": 0.153,
"differingBytes": 0,
"rgbaSha256": "81626d4fea77b2536fa14266ebf7d5a85808593dc7a74e5d3bb81387b0f5ad09"
},
{
"mode": "height",
"seed": 1234,
"beforeSingleTaskMs": 511.514,
"afterTotalMs": 189.114,
"afterMaxSampleBatchMs": 6.608,
"afterCachedMs": 0.24,
"differingBytes": 0,
"rgbaSha256": "ecf1f82480a2a6ce182536aa52cc8f51979c38cbb4aa52ad22b88874fe0ab3fa"
},
{
"mode": "water",
"seed": 17,
"beforeSingleTaskMs": 446.694,
"afterTotalMs": 131.681,
"afterMaxSampleBatchMs": 1.855,
"afterCachedMs": 0.126,
"differingBytes": 0,
"rgbaSha256": "c05f5b947feaf77eb1fddab1bf45b9d433f81546d55f7d179b27c4e1a4cbb0ba"
},
{
"mode": "water",
"seed": 42,
"beforeSingleTaskMs": 444.886,
"afterTotalMs": 177.31,
"afterMaxSampleBatchMs": 6.533,
"afterCachedMs": 0.19,
"differingBytes": 0,
"rgbaSha256": "656731118636540e934b07a3dbda2040fea8aae36b43de44f9f4c4ae27456b7d"
},
{
"mode": "water",
"seed": 1234,
"beforeSingleTaskMs": 499.638,
"afterTotalMs": 158.023,
"afterMaxSampleBatchMs": 3.006,
"afterCachedMs": 0.044,
"differingBytes": 0,
"rgbaSha256": "59ad86f0bd40645d42fb469e7a51352bfb60e4e2a3148e74a8b3a0b46769e596"
},
{
"mode": "noise",
"seed": 17,
"beforeSingleTaskMs": 493.343,
"afterTotalMs": 158.248,
"afterMaxSampleBatchMs": 1.736,
"afterCachedMs": 0.044,
"differingBytes": 0,
"rgbaSha256": "989cec6e3727fb608aa0a05fa239ded74a837d79530f7ff7217baf5cd61bfd23"
},
{
"mode": "noise",
"seed": 42,
"beforeSingleTaskMs": 471.098,
"afterTotalMs": 157.044,
"afterMaxSampleBatchMs": 2.694,
"afterCachedMs": 0.042,
"differingBytes": 0,
"rgbaSha256": "569ed1a8b4ce76277d9a7a6c4169d97c63b268234f8215c12ef12620c84281f5"
},
{
"mode": "noise",
"seed": 1234,
"beforeSingleTaskMs": 513.352,
"afterTotalMs": 183.663,
"afterMaxSampleBatchMs": 4.818,
"afterCachedMs": 0.069,
"differingBytes": 0,
"rgbaSha256": "0efdbb3290e51ffb68b2ac89e336f06fd3bbca21ff180509d7740e2780de917d"
},
{
"mode": "biome",
"seed": 17,
"beforeSingleTaskMs": 476.002,
"afterTotalMs": 122.748,
"afterMaxSampleBatchMs": 1.182,
"afterCachedMs": 0.078,
"differingBytes": 0,
"rgbaSha256": "3b1e3dd2888a7f00e93bcaf2cf0b13a13fe88d84b9799051ad08a19379b764d6"
},
{
"mode": "biome",
"seed": 42,
"beforeSingleTaskMs": 434.388,
"afterTotalMs": 113.271,
"afterMaxSampleBatchMs": 0.854,
"afterCachedMs": 0.034,
"differingBytes": 0,
"rgbaSha256": "e18db5f941f2c25dfd2bb649a323fa0eb3d11fe712f5cdc8d8c92a261b6c4112"
},
{
"mode": "biome",
"seed": 1234,
"beforeSingleTaskMs": 410.745,
"afterTotalMs": 102.411,
"afterMaxSampleBatchMs": 1.343,
"afterCachedMs": 0.112,
"differingBytes": 0,
"rgbaSha256": "bad60861126ecd6bdfe5e5af714d51cda190e7da4b373d2f55b4200fe59b9cbd"
},
{
"mode": "slope",
"seed": 17,
"beforeSingleTaskMs": 395.614,
"afterTotalMs": 338.077,
"afterMaxSampleBatchMs": 4.176,
"afterCachedMs": 0.036,
"differingBytes": 0,
"rgbaSha256": "0c21ac4913fe32d5213fbfaa2ba287fbb66b98fb35621625e5466904127c385c"
},
{
"mode": "slope",
"seed": 42,
"beforeSingleTaskMs": 424.509,
"afterTotalMs": 327.746,
"afterMaxSampleBatchMs": 4.723,
"afterCachedMs": 0.038,
"differingBytes": 0,
"rgbaSha256": "ec9232b88965075cd901edaf262a3a2cf16088e0a31a70d7ee8c73e7d7969b5c"
},
{
"mode": "slope",
"seed": 1234,
"beforeSingleTaskMs": 472.688,
"afterTotalMs": 321.455,
"afterMaxSampleBatchMs": 4.419,
"afterCachedMs": 0.039,
"differingBytes": 0,
"rgbaSha256": "ee802dc46e80c3f0cf1f4e7dc4c7b734040043d3ddaa5ab997dc691ef396812a"
},
{
"mode": "props",
"seed": 17,
"beforeSingleTaskMs": 398.098,
"afterTotalMs": 72.72,
"afterMaxSampleBatchMs": 0.269,
"afterCachedMs": 0.168,
"differingBytes": 0,
"rgbaSha256": "6447bfbbedb16993dd128d5ead4741921ba41d5756891d02ec611c07f92e894b"
},
{
"mode": "props",
"seed": 42,
"beforeSingleTaskMs": 393.03,
"afterTotalMs": 71.25,
"afterMaxSampleBatchMs": 0.237,
"afterCachedMs": 0.034,
"differingBytes": 0,
"rgbaSha256": "6447bfbbedb16993dd128d5ead4741921ba41d5756891d02ec611c07f92e894b"
},
{
"mode": "props",
"seed": 1234,
"beforeSingleTaskMs": 408.419,
"afterTotalMs": 74.762,
"afterMaxSampleBatchMs": 1.373,
"afterCachedMs": 0.03,
"differingBytes": 0,
"rgbaSha256": "6447bfbbedb16993dd128d5ead4741921ba41d5756891d02ec611c07f92e894b"
}
]
}
20 changes: 16 additions & 4 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,10 @@ const MODE_LABEL = { studio: 'Tile', infinite: 'Infinite World', planet: 'Planet
const NODE_PANEL_IDS = ['explode', 'planet', 'water', 'clouds', 'visuals', 'skybox', 'lighting', 'export', 'performance', 'debug'];
const REAL_TERRAIN_PANEL_IDS = ['terrain', 'explode', 'water', 'props', 'clouds', 'visuals', 'skybox', 'lighting', 'export', 'performance', 'history', 'debug'];
const PerformanceOverlay = lazy(() => import('./components/perf/PerformanceOverlay.jsx'));
// Start loading the drawer chunk with the app so the first tool click does
// not have to wait for the lazy module before anything can be shown.
const sideDrawerModule = import('./components/ui/SideDrawer.jsx');
const SideDrawer = lazy(() => sideDrawerModule);
let sideDrawerModule;
const loadSideDrawer = () => (sideDrawerModule ||= import('./components/ui/SideDrawer.jsx')
.catch((error) => { sideDrawerModule = null; throw error; }));
const SideDrawer = lazy(loadSideDrawer);
const loadNodeWorkspace = () => import('./components/nodes/NodeWorkspace.jsx');
const NodeWorkspace = lazy(loadNodeWorkspace);
const MANUAL_LIBRARY_HEIGHT_KEY = 'terrain-studio:manual-library-height';
Expand Down Expand Up @@ -150,6 +150,18 @@ export default function App() {

const loading = useLoading();
const landing = useLanding();
useEffect(() => {
// Keep the landing scene's requests/compilation ahead of editor-only work,
// then warm the drawer before the user reaches the first tool.
if (!landing.bootReady) return undefined;
const preload = () => { void loadSideDrawer().catch(() => {}); };
if (typeof window.requestIdleCallback === 'function') {
const id = window.requestIdleCallback(preload, { timeout: 1200 });
return () => window.cancelIdleCallback(id);
}
const id = setTimeout(preload, 0);
return () => clearTimeout(id);
}, [landing.bootReady]);
const { showPopup, showConfirm, showChoice } = usePopup();
const landingRef = useRef(landing);
landingRef.current = landing;
Expand Down
4 changes: 2 additions & 2 deletions src/components/perf/PerformanceOverlay.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export default function PerformanceOverlay({
</div>
</div>

<GraphCard label="Frame rate" hint="~24 s">
<GraphCard label="Frame rate" hint="Scene frames · ~24 s">
<PerfSparkline
data={history?.fps}
color="var(--success)"
Expand All @@ -149,7 +149,7 @@ export default function PerformanceOverlay({
/>
</GraphCard>

<GraphCard label="Frame time" hint="CPU ms">
<GraphCard label="Frame time" hint="Rendered frame · CPU ms">
<PerfSparkline
data={history?.frameMs}
color="var(--accent)"
Expand Down
Loading