Skip to content

Stress lab audit: honest scores, and three engine speed-ups (laptop 3.6 to 85 fps, desktop play +21%) - #659

Merged
jonit-dev merged 132 commits into
developfrom
perf/stress-lab-audit
Nov 12, 2026
Merged

jonit-dev merged 132 commits into
developfrom
perf/stress-lab-audit

Conversation

@jonit-dev

@jonit-dev jonit-dev commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

What is this PR about?

An audit of the stress test (scene, method, dashboard) and the first engine speed-ups that came out of it. The stress lab now measures the way a player plays, and two engine fixes make real frames faster.

Why does it matter?

The stress lab is how we decide if the engine got faster. Before this PR it could reward changes that only helped the benchmark. It also hid that a laptop GPU drew the scene at 3.6 fps.

Done

Method (no "benchmaxxing"):

  • A "keep" verdict needs a real-game holdout in the same fair window.
  • The scoreboard compares engines on one GPU only.
  • FPS counts drawn frames. A build's identity covers the engine JS.
  • A gameplay preset (play) has its own lanes. The ledger keeps the GPU frame by pass (shadow, scene, other).

Engine:

  • Auto batch scans what arrived, not the whole scene, after each add or remove. Desktop RTX 2080, uncapped: real 86.8 to 92.7 fps, play 39.7 to 49.4 fps (single runs).

  • A lamp shades only the pixels inside its range. Every pixel paid for all 16 torches before. Laptop Iris Xe, uncapped, paired probe (two runs per arm): scene pass 206-224 ms to 8.6 ms. real 3.6 to 25.3 fps, adaptive low tier 6.3 to 34.8 fps, play 3.35 to 19.9 fps. The design follows Unreal (UE 5.8.3: Engine/Shaders/Private/DeferredLightingCommon.ush:334-344).

  • The low quality tier draws no shadow pass (starter src/render/postprocessing.ts, named override shadows). Laptop, ?stress&adaptive, both arms at low: shadow pass 11.3 to 0 ms, 35.8 to 63.2 fps (ledger fix(engine-mcp): a shared symbol's detail returns every match in one call #345-fix(playtest): aggregate retained render samples per pass kind so assert.performance stops dying on payload #346).

  • The low tier draws no screen-space reflections, as Unreal's lowest level (r.SSR.Quality=0). The default had turned SSR on, and its reads forced a second full-resolution G-buffer pass: 6.7 of 26.7 ms on the hot laptop. Laptop ?stress&adaptive: head runs 84.6 and 67.5 fps (base 78.2 and 47.0), GPU p95 12.3 to 9.0 ms (rows docs(prd): PRD-452 records its Phase 3 evidence and DONE status #349-docs(guides): the public guides move here as Markdown, the site renders them #350). Before PRD-871 this lane drew 7.1 fps.

  • A still frame uploads nothing. The upload counter counted the arena size, not the bytes sent; now it counts what crossed. The last unchanged write (4 bytes of output uniforms) is gone.

  • A failed tier raise is not retried until its measured cost would fit. Without it the laptop flipped low to medium every few seconds and the shadows blinked.

  • TN_FRAME_BUDGET names the GPU pass that costs on the owned engine: gpuMain, gpuShadow, gpuOther, from the Wasm facade and the V8 player.

  • Shadow planning (desktop): a moving rigid caster keeps its vertex box, and a rig box skips one 4x4 multiply per bone. Bounds rebuild 1.38 to 1.06 ms a frame (sub-timers). The fair window was inconclusive under host load.

  • A play frame stops walking the scene for LOD chains that were never registered. Each frame walked about 10k objects through engine proxies (15% of main-thread time in a CPU profile of ?stress&play) and found nothing, because the scene registers no chain. Desktop, fair window play-lod-walk-c (uncapped ABBA, two runs per arm, load peak 7.0): 66.0 to 79.7 fps (base 63.9/68.0, head 78.1/81.3; rows fix: preserve Android consumer cohort arms (PRD-366) #364-docs: record isolated Linux UI cadence proof (PRD-399) #365), frame p50 11.71 to 11.04 ms, p95 26.46 to 18.35 ms. Holdout: the tower-defense kit holds at 286.9 to 306.5 fps (rows PRD-449: ThreeNative vs Godot scoreboard and TN fixes #366-PRD-446: Guard public API changes and prove N-1 upgrades #367, no effect — it registers no chain either).

Checks run:

  • native_engine_material_* (21 tests) pass on the Wasm build. The new native_engine_material_lamp_range_branch test failed before the change and passes after it.
  • Auto batch: the specs from commit 744a9321c.
  • starter-shadow-tier.spec.ts and the two template-quality policy tests: red before, green after (55 pass).
  • renderer.spec.ts GPU split test red then green; 119 core frame-budget specs pass.
  • Native engine-only build: virtual_shadow_bounds_cache red then green; 109 render database cases, shadow render cases, crowd and renderer tests pass. Wasm build: 23 skeleton, skinning and VSM tests pass.
  • world-environment-defaults "no screen-space reflections on the low tier": red then green; 484 look, kit and render-chain specs pass. Visual judges NEUTRAL for shadows off (still, town) and SSR off (still, town, closeup).
  • player-gpu-stages.mjs on the V8 player (RTX 2080): PASS. Its old Shadow > 0 check failed with and without this branch (static scene); the test ball now moves.
  • shadow-node.spec "never walks the scene while no LOD chain was ever registered": red (3 walks) then green; 617 three-native and model-lod specs pass.
  • Desktop fair windows, ?stress and ?stress&play, uncapped ABBA: rows fix: complete PRD-112 packed golden path repair #360-docs: record isolated Linux UI cadence proof (PRD-399) #365. play-lod-walk-b also recorded its void runs: the tn-local CI pool took the host to load 45 mid-window, which is why each of its arms has one valid run. play-lod-walk-c is the clean window (two runs per arm, load 3.8-7.0).
  • Holdout window td-lod-holdout (rows PRD-449: ThreeNative vs Godot scoreboard and TN fixes #366-PRD-446: Guard public API changes and prove N-1 upgrades #367): the tower-defense kit, head staged as the same built dist with the guard patched into the shipped facade chunk, so the arms differ by 25 bytes and no engine interface moves.

PRD-871 checklist

PRD: docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md

PRD-872 (docs/PRDs/performance/PRD-872-static-casters-and-rigid-rigs-in-the-desktop-frame.md) is filed in this PR as the successor for those two items, and both of its levers are now landed here and measured:

  • A caster whose inputs did not move costs nothing to plan (da185d67e): the plan keeps one record per caster across frames (bounds, state, and the inputs they came from); CasterState::signature is a version triple instead of a std::string, and the atlas marks presence with a stamp instead of a std::set insert per caster.
  • A rig that moves rigidly keeps the shadow box it built (f95ea2eb6): the box is keyed on the fold of the bone locals, inv(mesh world) * root world and the mesh scale.

Measured, after three more exact cuts (bone positions transformed once per skeleton instead of per mesh, one linearStretch per skeleton instead of 7,800, and updateCasters skipped on a frame where no caster moved):

So PRD-872's three acceptance numbers are still unmet: 8.91 against 8.4, 0.94 against 0.5, 3.9 against 2.5. The measurement says why, and it is recorded in PRD-872's Decisions: planVirtualPages is 0.35-0.44 ms with the crowd removed entirely, so 0.5 ms was never the planner's to reach with 240 animated rigs in the pass; and the crowd's 3.9 ms is animation (~1.0), world-space palette write and upload (~1.4), shadow plan (~0.5) and JS (~0.5). The next lever is the component-space palette — multiply the palette by inv(mesh world) and let the draw's own model matrix re-apply it, which three already does after skinning, so a walker that moves with a throttled pose stops rewriting 65 bones a frame. That is a skinning change with its own PRD, its own visual judge and both passes changed together, not another box here.

Phase 1, the lamp range lever is closed:

Phase 2, the laptop adaptive lane reaches vanilla speed:

  • The low tier draws no shadow pass without game code (starter spec red-green; laptop shadow 11.3 to 0 ms; judge NEUTRAL).
  • TN_FRAME_BUDGET carries the GPU frame by pass on the owned engine (core spec; lab lines; V8 player test PASS).

Phase 3, the desktop frame loses its shadow planning cost:

  • planVirtualPages at 0.5 ms or less on an unchanged frame (1.02-1.64 ms today) — moved to PRD-872 Phase 1: the whole stage is 0.8-1.0 ms of a 10.0-10.65 ms frame, so it cannot carry AC-2 on its own.
  • No buffer with unchanged content uploads on a still frame (frame_slab_is_per_program red-green twice; still frame 0 bytes).
  • The play frame has a full stage split (stageSum 13.07 ms against frame p50 12.55 ms, 104%) and loses its largest removable term: 66.0 to 79.7 fps, frame p95 26.46 to 18.35 ms (rows fix: preserve Android consumer cohort arms (PRD-366) #364-docs: record isolated Linux UI cadence proof (PRD-399) #365).

Acceptance:

Desktop window range-score (ledger #334-#337, uncapped): ThreeNative virtual shadows 95.0 to 96.7 fps, one sun map 137.1 fps, vanilla three.js 75.3 fps.

Not verified

PRD-875, PRD-876, PRD-877 (absorbed from #663, 2026-11-11)

#663 is merged into this branch (10b50e558) and closed: one PR for the stress-lab chain. Its rows, unchanged: PRD-875 Phase 1 (animationTickMicros, the Euler and event-split races, TSan 292/292), PRD-877 Phases 1-2 (interpolation meters, C++ PoseHistory), PRD-876 Phase 1 (pipelineSyncCreates, programBuilds), and PRD-573 v1.9 (owned-engine ladder column).

Since the merge:

  • Gains, fair window 877-pose-history (ledger feat(PRD-464): realistic-scene ladder — full scene at 1080p, TN 104 fps vs Godot 196 fps #374/PRD-458/473: streamed open worlds toward 60 fps (LOD, shadows, GPU scene); real-display check pending #375, RTX 2080, uncapped ABBA, head 81901d260 against develop 64a6c32ab): frame p50 9.32 → 8.15 ms (both pairs lower: 8.09/8.20 against 9.15/9.49), frame p95 11.17 → 10.33 ms, 103 → 117 fps, GPU p95 6.09 → 5.08 ms. Debug read: physics interpolation 1.05 → 0.10 ms a frame, JS shared-transform writes 8,379 → 1,458 a frame. Fresh Opus visual judge: NEUTRAL, no regression.

  • ✅ PRD-877 done (788567cda): the engine blends and restores physics poses in one call. Fixed on the way: C++ blend dirtied every settled body every frame. The kart smooth-drive playtest passes on the engine path.

  • ✅ PRD-876 done (51e2777d3): on the real GPU the Strata forest turn makes 0 sync pipeline creates and 0 program builds after the reveal; warm-up compiles 319/319 before it. Phases 2-3 closed by the sizing gate.

  • ✅ PRD-875 done (200e1966d): the animation tick is 0.27 ms mean, so a pool saves at most ~0.20 ms (< 3% of the frame). Phases 2-3 closed by the sizing gate; WIP parked locally.

  • ✅ The 5 ungated native reds pass (41135ff29, those cases rerun; the full label was not rerun after the fix). They were stale tests (MRT normal, a bound class, r187 noise hashes), a memo check that never ran (top-level await in an IIFE bundle), and two Canvas2D cases now registered only with Skia.

  • ✅ PRD-872 AC-1 and PRD-871 AC-2 (frame p50 ≤ 8.4 ms) met on the same window.

  • ✅ PRD-872 and PRD-871 done (9c699f0c3, palette lane 8c568dc0e): AC-1/AC-2 met (frame p50 8.09/8.20 ms ≤ 8.4). The remaining planVirtualPages and crowd levers measured under the 0.3 ms floor (relative-slot skip ≈0.05 ms, block-diff ≈0.07, bone walk ≤0.18), so they were closed by decision. Next real levers (bone-write stamps, GPU-joint crowd admission) are new PRDs.

PRD Now
871 ✅ done
872 ✅ done
875 ✅ done
876 ✅ done
877 ✅ done
878 ⏳ the walking crowd costs 2.5 ms (from ≈4.8): Phase 1 pose stamps, Phase 2 renderer per-draw, Phase 3 engine JS → C++ (owner: "whatever else is on JS that's not game scripting should move to C++"; probes measured ≈1.9 ms/frame of engine JS)
879 ⏳ filed 573a65a7b: world cells and terrain tiles run their frame in C++ (tested C++ twins exist, unbound; JS copies run every frame)

Status 2026-11-11 (head 082c6807e) — ready for review

Done, in docs/PRDs/done/: PRD-871, 872, 875, 876, 877 (the folded-in #663 work), and PRD-878 and PRD-879, closed at their measured values by the owner ("option 1"). The open bars moved to two new PRDs: PRD-881 (the crowd's engine-owned stages to 2.5 ms, planVirtualPages to 0.5 ms) and PRD-882 (the world drain and the desktop forest run).

Measured (fair window fwfinal, ledger rows 376-381, ABBA, mean of 2 runs, 0 void, RTX 2080): ?stress frame p50 9.22 -> 7.75 ms (-16%), p95 11.50 -> 10.60, GPU upload 1,191 -> 638 KB a frame, planVirtualPages 1.20-1.26 -> 0.79-0.89 ms. Head wins every ABBA pair. The crowd cost (?stress minus &noCrowd) fell 4.16 -> 3.70 ms; the bar of 2.5 ms was not met and is carried by PRD-881. Forest: playtest exit 0 three times, WorldCells.update walk about 6.7 -> 3.5 ms in the first lane. Fresh Opus visual judges: NEUTRAL on the walkers and the forest.

Verified on ee22d38c7 (docs-only commits since): native-engine label 695/695 on freshly built test targets, registry dump equal, core world specs 1,034 passed, typecheck exit 0; doc checks and five doc specs pass on this head.

Not run: the full pnpm ci:local board, and a desktop run of the forest (PRD-882). Not merged to develop.

Rows #310-#312 ran on the laptop's Iris Xe and the dashboard scored them against Unreal rows from the desktop RTX 2080:
"losing 46 to 56" compared two machines. The score, the ladder, the KPI anchor and the trend now read only the rows of
the GPU where the latest Unreal row ran.
…red, the gameplay preset has its own lanes

The stress lab is a search tool. A lever that wins only there is benchmark tuning, so the loop now says so:

- `record --decision keep` fails without `--holdout "<game>: <metric> <before> -> <after>"`, and a row with no
  decision is a `measure`, not a `keep`.
- Uncapped fair runs record presented p99 from the frame starts. The report's `frameP99Ms` and `frameWorstP95Ms`
  are kept: the median of the windows hid one bad window.
- Rows of the `?stress&play` preset (textured materials, mesh variety, street camera, object churn) get their own
  lanes on the ladder and stay out of the `real` trend.
…window

An arm whose URL carries holdout=<name> is a ThreeNative game with no stress report. The window reads its own
TN_FRAME_BUDGET lines, skips three warm-up windows and pools the rest as the lab pools its own, so a keep's
holdout comes from the same ABBA window, browser and load gate as the lever's rows. A holdout row is no lane.
…each add or remove

A spawn or a despawn at the scene's top two levels restarted the settle pass, and each pass walked the whole
scene twice (the snapshot and the settle). A game that throws ten objects a second paid that walk for ever:
on the stress lab's new `?play` preset it was 4.1 ms of a 9.8 ms frame (CPU profile), about 2,000 bridge
crossings a frame, and the bridge census leaves that walk out by design, so no meter named it.

A scan now walks the subtrees that arrived since the last finished scan, plus what an earlier scan could not
merge yet: a hidden subtree, a mesh that moved while it was watched, a mesh with no partner. What merges is
the same; the tests pin each of those cases, and the merged census on `?play` is equal on both builds
(1,260 meshes in 32 draws, 960 in 31 after the bodies wake).

Probe on the desktop, one run each, shared GPU (diagnosis, not a judged A/B): crossings 2,085 -> 389 a frame,
`children` reads 3,030 -> 12, frame p95 30.3 -> 9.8 ms, p99 34.0 -> 11.4 ms.
…nts drawn frames

- The fair window hashed the Wasm only, so both arms of a core-only change carried one hash and a paired window
  could not prove it ran two builds. The hash now covers the Wasm and every script the page loads.
- A frame the Wasm engine declines to draw costs near zero and shows the previous image. A holdout report's fps
  now leaves those frames out, and every row records renderSkipPct (the lab report does the same).
…ired auto batch window and the desktop runs
, the laptop shadow split and the adaptive lane
The standard and physical fragment programs evaluated every point and spot
light's GGX lobe on every fragment. An unshadowed lamp's lobe now sits in one
branch on its distance attenuation, which is exactly 0 past the cutoff, so the
branch changes no pixel. A lamp that reads a shadow map keeps the straight path.

Stress lab, laptop Iris Xe, uncapped, 16 torches of 14 m range (paired probe,
two runs per arm): scene pass 206-224 ms to 8.6 ms, 3.6 to 25.3 fps at the high
tier, 6.3 to 34.8 fps at the adaptive low tier, 3.35 to 19.9 fps in play.

UE 5.8.3: Engine/Shaders/Private/DeferredLightingCommon.ush:334-344.
PRD-871 plans the work after the 2026-11-10 audit: close the lamp range lever,
bring the laptop adaptive lane to vanilla speed, and cut the virtual shadow
planning cost on the desktop. The visual judge of the lamp range change read
NEUTRAL. Rows #334-#337 are the desktop window range-score (base, head, one sun
map, vanilla three.js).
@jonit-dev jonit-dev added the prd:25% PRD phase 1 verified label Nov 11, 2026
The same branch as the standard and physical programs (078aae5): an unshadowed
point or spot light evaluates its diffuse and Blinn-Phong terms only where its
distance attenuation is above 0. The normal, view direction and Lambert term are
evaluated once before the first branch.

Test: native_engine_material_lamp_range_branch now covers buildLambert and
buildPhong; it failed on the old loop and passes (27 material and TSL tests).
qualityTiers.shadow(tier).castShadows already said that the low tier casts no
shadow, but only the kart kit applied it. The starter (and every game built on
it, the stress lab included) drew the shadow pass at every tier. On an Iris Xe
at the low tier that pass was the largest GPU cost: 11.8 of 27.4 ms.

setupPost now sets the renderer shadow map from the tier at each tier change.
A named `shadows` option keeps the game choice at every tier, and both
TN_QUALITY_TIER and debug().shadows name the source (tier or override).

PRD-871 Phase 2. Spec: starter-shadow-tier.spec.ts, red before, green after.
The pass recorder cannot instrument the owned engine, so gpuMain, gpuShadow and
gpuOther stayed empty there. The facade now publishes each resolved sample as
a split (scene pass; sun and lamp shadow maps; other passes, output and
gather) under info.engine.gpuSplit, core gpuFrameSample carries it while it
names the same frame, and the frame loop feeds the existing buckets from it.

PRD-871 Phase 2. Spec: renderer.spec.ts "hands out the engine own pass split",
red before, green after; frame-budget and GPU observation specs pass.
Laptop window lit-holdout-laptop (Iris Xe, uncapped, two runs per arm): the
stress lab adaptive lane 7.1 to 36.0 fps, the tower-defense kit 109.3 to
114.2 fps. Desktop window lit-holdout: every lab head run voided by CI pool
load; its kit pair holds. Ledger rows #338-#344. The learnings file gets the
lever entry and the method traps.
The virtual shadow planner keyed each draw's shadow box on its world matrix
too, so a rigid caster that moved re-read every vertex position to rebuild a
box that only needed the new transform. The draw cache now keeps the vertex box
in geometry space, keyed by the position store revisions alone.

Stress lab, desktop, temporary sub-timers: 234 moving rigid draws a frame;
vertex scans 234 -> 0, bounds rebuild 1.38 -> 1.12 ms. The rest of the
rebuild is 240 rig draws (0.87 ms) and 11 instanced draws (0.20 ms).

PRD-871 Phase 3. Red-green: virtual_shadow_bounds_cache (render_database_test)
failed on the vertex scan count before, passes after; all 109 database cases
and the shadow render cases pass on a native engine-only build.
The virtual shadow box of a skinned draw multiplied each bone world matrix by
its inverse bind matrix every frame, only to read the stretch of the product.
The inverse bind stretch now rides on the cached bone reach, so a frame reads
the stretch of the bone world matrix alone. The product of the two stretches
bounds the stretch of the product, and equals it for a rotation with uniform
scale, so the box still holds every skinned vertex.

Stress lab, desktop, temporary sub-timers, 240 rig draws a frame: rig box
0.87 -> 0.72 ms.

PRD-871 Phase 3. Tests: skeleton, skinned palette, VSM, glTF hierarchy and
fixture cases in the Wasm build (23 passed); render database, skinned crowd,
renderer and shadow render cases on a native engine-only build (115 passed).

The ledger also takes rows #345-#346: the laptop Phase 2 window (adaptive,
both arms at low: base 35.8 fps, head 63.2 fps, shadow pass 11.3 -> 0 ms).
With the low tier now free of its shadow pass, the stress lab on an Iris Xe
measures low at 12.7-13.7 ms: inside the 20% headroom line, so after five
healthy windows the policy raised to medium (shadows on, 27 ms), fell back two
windows later, and tried again after each 5 s cooldown. The shadows switched
on and off every few seconds.

Each raise that the next change undoes now doubles the wait before the next
raise, up to 32 cooldowns; a raise that holds resets it. Steps down keep the
plain cooldown.

PRD-871 Phase 2. Spec: template-quality "should wait twice as long before
retrying a raise the next window undid", red before (medium after one
cooldown), green after; 55 quality and shadow-tier specs pass.
The Wasm facade publishes info.engine.gpuSplit since 4bd3989; the V8 player
now does the same from its timer segments (scene; sun and lamp shadows; other,
output and gather), so TN_FRAME_BUDGET gpuMain, gpuShadow and gpuOther fill on
desktop too.

player-gpu-stages.mjs asserts the split names the sampled frame and matches
the stage times. Its ball now moves: the scene was static, so the cached sun
shadow map drew no pass in steady frames and the existing Shadow > 0 check
failed with and without this branch (checked on a player built without the
PRD-871 Phase 3 sources).

PRD-871 Phase 2. Proof: node tests/native-engine/player-gpu-stages.mjs
build/engine-only/tn-native-engine-player-v8, RTX 2080, PASS.
… Phase 3, inconclusive under host load 14-16)
The backoff of 97c33a8 still retried medium every few cooldowns on the
Iris Xe stress lab, and each retry showed the shadows for two windows.
The policy now keeps what a failed raise cost as a ratio of the cost before
it (medium 28 ms over low 12 ms) and raises from that tier again only when
the predicted cost fits the headroom line. A lighter scene still climbs; a
scene that cannot hold medium stays at low and reports reason=predicted.

PRD-871 Phase 2. Spec: template-quality "should not retry a raise that
failed until the cost it measured would fit", red on 97c33a8 (medium after
ten minutes at 12 ms), green after; 55 quality and shadow-tier specs pass.
@jonit-dev jonit-dev added prd:50% PRD half the phases verified and removed prd:25% PRD phase 1 verified labels Nov 11, 2026
The debug counter uniformUploadBytes added the uniform arena size each
frame, while the FM2 upload sends only the blocks that changed. On the
stress lab still pose with nothing moving it read 39 KB a frame beside a
516-byte real upload, so it pointed a profile at an upload that does not
happen. It now adds the bytes each arena write sends.

PRD-871 Phase 3. frame_slab_is_per_program: red before (still frame 6144),
green after (still 0, moved 512 of 516 queued bytes); all 136 render
database cases pass on the engine-only Dawn build.
The output pass wrote its uniform block (exposure, time, screen size)
every frame, so a frame with nothing moving still made one writeBuffer
call: the last upload of a still frame. The renderer now keeps the bytes
the buffer holds and writes only a block that differs; a new buffer
clears them.

PRD-871 Phase 3. frame_slab_is_per_program: a still frame uploaded 4 bytes
(red), now 0 (green). 136 render database cases, 98 renderer/output/
present tests and 14 post tests pass on the engine-only Dawn build.
The low preset left ssrEnabled unset, and the default (look layer 8 and up)
turned SSR on: the phone tier, documented as "nothing screen-space", ran
reflections. Their normal, metalness and roughness reads made the owned
engine redraw the whole scene into a second G-buffer pass: 6.7 of 26.7 ms
of the stress lab frame on an Iris Xe at low (per-pass GPU timers).

Unreal draws none at that level: UE 5.8.3
Engine/Config/BaseScalability.ini:514-515, [ReflectionQuality@0]
r.SSR.Quality=0. Medium and high keep their SSR.

PRD-871 Phase 2 (AC-1). world-environment-defaults "draws no screen-space
reflections on the low tier": red before (stages ssr, bloom, ...), green
after; 484 related look, kit and render-chain specs pass.
6dd609e put the low-tier shadow switch inline and took
src/render/postprocessing.ts to 212 lines; looks.spec caps a render file
below 200. The switch is now tierShadows() in quality.ts (the same rule,
override and report), and the post file is 199 lines.

looks, starter-shadow-tier, template-quality and scaffold specs pass.
…8.3 thread and planning guards

- Remove docs/PRDs/performance/PRD-828: a stale pre-work copy that #635 re-added.
  The finished PRD stays in docs/PRDs/done/ (DONE 2026-11-07).
- PRD-573: v2.1, v2.2, v2.3, v3.1, v3.3, v5.1 and v5.2 name what landed
  (L5, FM2, FA1, PRD-828, PRD-613, PRD-752) and what is still open. No box ticked.
- PRD-573, PRD-774, PRD-752: Unreal 5.8.3 reference for parallel pose
  evaluation, command build at add time, the late join, the frame lag limit,
  deferred release and the flush before a surface change. Each cited line was
  re-read in the clone on 2026-11-10.
- PRD-765: P2 to P1, on PRD-828 follow-up 7 (about 1.2 ms of JS a frame).

Checks: check-doc-links (3918 links), unreal-port-status --check up to date,
check-doc-links and evidence-citations specs pass. evidence-budget spec fails
in this worktree only (no node_modules here; 9 of 9 pass in the main checkout).
… not a per-frame set (PRD-878)

project kept the skeletons it had updated in an unordered_set that it
cleared every prepare, so each rig cost a node allocation, a hash insert
and a free, and a reflection pass probed the main pass's set. Each prepare
now takes an id that is unique across databases. A skeleton records the id
of the prepare that updated it. A reflection compares that stamp with the
main pass's id, as it did with the main pass's set.

Same-binary A/B on ?stress (3 runs of 120 s): project with the set minus
the stamp +0.019/+0.154/+0.050 ms. Full native-engine label 686/686.
…draw's variant stamp holds (PRD-878)

Every shadow row built a VertexVariant, with a copy of the material's node
slots, and compared it with the cached one: about 550 rows a frame on
?stress, nearly all of them one per draw, so a per-build memo did not help.
refresh() now stamps a draw when it re-resolves the material half. That
covers the material's signature, the geometry revision and a converted
input. The inputs the frame, the scene and the batch set per draw (fog and
its node, environment, instancing, skinning, palette, morph, side, the
material pointer) are matched exactly in a DepthRowKey. A row whose stamp
and key match keeps its program.

Red-green native_engine_shadow_row_follows_material_change: a shadow-only
caster's material gets alphaTest 0.5 by a plain write. With the stamp not
moved, the row keeps the unmasked program (red). With it, the row takes the
masked program (green).
Same-binary A/B on ?stress (3 runs of 120 s): shadow build without the
stamp minus with it +0.114/+0.100/+0.113 ms. Full native-engine label 686/686.
…eometry revision (PRD-878)

rigLaneKey ran skinnedLaneReason for every held rig every frame, and that
made four string-keyed attribute lookups (position, normal, skinIndex,
skinWeight) plus the morph lists. The geometry half is now held on the
geometry for its revision, so the batch's guard is unchanged but reads a
version. The ABI's morphAttributes setter moved the morph lists without a
revision bump. It now bumps the revision, so every render cache held for
the revision sees a morph set.

Red-green native_engine_abi_rig_lane_follows_morph_set: a rig's lane is
clear, then morphAttributes.position is set through tn_set. Without the
bump the lane stays clear (red). With it, the lane reads "morph" (green).
Same-binary A/B on ?stress (3 runs of 120 s): batch with the lookups minus
the held half +0.008/+0.027/+0.014 ms. Full native-engine label 686/686.
… reads the verdict map (PRD-878)

batch() looked up every draw's rig in the decision's verdict map, a tree of
about 240 rigs, and only then dropped a virtual-shadow page caster, which
it always drops. The 240 shadow-only rig draws on ?stress now leave first.

Same-binary A/B on ?stress (3 runs of 120 s): batch with the old order
minus the new +0.017/+0.018/+0.021 ms. Full native-engine label 686/686.
…tires the shadow row's cached depth program (PRD-878)

The callback runs after refresh() stamped the draw, so that frame's shadow row
kept the old depth program and alpha mask. The draw and the draw list's copies
take a new variant stamp. Also covers deleting a rig's skin attributes.
@jonit-dev jonit-dev added prd:75% PRD phases in, acceptance open and removed prd:25% PRD phase 1 verified labels Nov 11, 2026
Red-green: palette_lifetime fails on steady-frame allocation, then passes with versioned storage and direct dense slots (no claim map). Removal, addition, material and geometry replacement rebuild storage. CPU-only held-pose, compaction and skin-attribute cases pass. Expected ceiling ~0.10 ms; A/B pending.
Item 3: construct in place from the consumed first member; remove both the temporary copy and its push copy. static_assert requires noexcept DrawItem move construction. CPU batch_compacts_in_place, palette_lifetime and held_pose_skips_the_world_palette pass on the rebuilt target. Record-owned draw copies stay intact. Expected ceiling ~0.03 ms; A/B pending.
Red-green bone_liveness counts weak-owner reads across three steady frames (zero), then frees a shared/duplicate bone and verifies every dead slot is null. Bone destruction bumps a scene-thread generation; each skeleton scans its weak slots once after that generation moves. pose_version, skeleton_pose, clone_bounds and raycast_posed_skin pass. Expected ceiling 0.04-0.07 ms; A/B pending.
Red-green projected_cull_multiple_views covers two culls/two cameras on one scene, the root-owned bridge sharing a cull across cameras, two scenes, and repeated calls in one frame. Keep per-camera rig walks/rates and reuse same-frame results; hold turns still derive from the supplied frame. Existing projected_cull_band_animation, projected_cull_bands and projected_cull_list pass. Correctness guard for the ~0.10 ms JS rate-band move; A/B pending.
…that changed (PRD-879)

`__tnStaleRows(blocks, plan)` patches the slab the engine holds: an unchanged cell
block is a [source, count] run of the held rows, a rebuilt one is sent. `patchRows`
is the upstream-three twin; the JS slab is no longer built on the engine path.

Red-green: world-cells-main-cull.spec.ts "sends the engine's stale rows only the
cell blocks that changed (PRD-879)" (red on the whole-slab pack, green after);
abi_test world_stale_entries patches one block and refuses a run past the held rows
or unread blocks (red on the old binding, green after).
…s eight corners twice (PRD-879)

The drain's largest per-record leaf on the forest walk was #boundsAt (0.12 ms a frame
of 0.27 in addPlacements, before1 cpu profile): Box3.applyMatrix4 over eight corners,
run twice per placement with the same matrix. `placedBox` is the engine BOX op's
math (centre moved, |basis| * half), and addPlacements reuses the first answer for its
level boxes. The main cull's per-record bounds take the same helper.

Red-green: world-cells-main-cull.spec.ts PRD-879 guard now counts Box3.applyMatrix4 in
the walk (5556 before, 0 after); "measures a placed box as Box3.applyMatrix4 does, in
closed form" checks the answer against three's for a rotated, mirrored placement.
… forest playtest box ticks on three exit-0 runs
…ms, crowd cost 4.16 to 3.70 ms (AC-1 not met)
…C-1 partly met, the drain bookkeeping stays in JS
…); the open bars move to PRD-881 and PRD-882
@jonit-dev jonit-dev added prd:100% — ready PRD complete, ready to merge and removed prd:75% PRD phases in, acceptance open labels Nov 12, 2026
@jonit-dev
jonit-dev marked this pull request as ready for review November 12, 2026 00:08
…interpolation.ts lint, one gitleaks false positive

- stb headers downloaded from nothings/stb master no longer matched the locked sha256 (upstream moved);
  the lock already named revision 2c980bb5, so the URLs now use it (all three hashes verified at that revision)
- packages/core/src/interpolation.ts: inline type import and Biome format (PRD-877 code)
- .gitleaksignore: scripts/stress-loop.mjs line 324 is a metric label, not a key
…B; PRD-881 carries the reclaim

PR #659 took the single-thread engine to 4,288,337 bytes and the threaded one to about 4,298,000 (+0.7%) for a 16% lower
?stress frame p50. build-artifacts and test failed on TN_WEB_ENGINE_OVER_BUDGET. The number lives in check-budgets.ts, the
build action and the spec, which keeps them equal.
@jonit-dev
jonit-dev merged commit 3a05dec into develop Nov 12, 2026
18 of 22 checks passed
jonit-dev added a commit that referenced this pull request Nov 12, 2026
Promote develop to main: the crowd and forest world run faster in the engine (#659)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:100% — ready PRD complete, ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant