Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
132 commits
Select commit Hold shift + click to select a range
86927f7
fix(stress-lab): the scoreboard compares engines on one GPU only
jonit-dev Nov 10, 2026
3dc61ad
feat(stress-lab): a keep needs a real-game holdout, the tail is measu…
jonit-dev Nov 10, 2026
f7db10c
feat(stress-lab): a real game runs as a holdout arm in the same fair …
jonit-dev Nov 10, 2026
744a932
fix(core): auto batch scans what arrived, not the whole scene, after …
jonit-dev Nov 10, 2026
c3f8083
fix(stress-lab): a build's identity covers the engine JS, and fps cou…
jonit-dev Nov 10, 2026
7191c0c
chore(stress-lab): ledger rows #313-#322, the laptop baseline, the pa…
jonit-dev Nov 10, 2026
dba89d8
docs(perf): the 2026-11-10 stress lab audit and the auto batch lever
jonit-dev Nov 10, 2026
20362a6
feat(stress-lab): the ledger keeps the GPU frame by pass; rows #323-#…
jonit-dev Nov 10, 2026
4ad2607
chore(stress-lab): ledger rows #332-#333, the laptop GPU frame by pass
jonit-dev Nov 10, 2026
078aae5
perf(shader): a lamp shades only the fragments its range reaches
jonit-dev Nov 11, 2026
0601663
docs(prd): PRD-871, the next stress lab levers; ledger rows #334-#337
jonit-dev Nov 11, 2026
fa8f612
perf(shader): Lambert and Phong lamps shade only the fragments in range
jonit-dev Nov 11, 2026
6dd609e
fix(starter): the low tier draws no shadow pass
jonit-dev Nov 11, 2026
4bd3989
feat(core): TN_FRAME_BUDGET splits the owned engine GPU frame by pass
jonit-dev Nov 11, 2026
b088ae9
docs(prd-871): the lamp range lever holds on a kit; phase 1 done
jonit-dev Nov 11, 2026
a38190b
perf(shadows): a moving rigid caster keeps its vertex box
jonit-dev Nov 11, 2026
7482c20
perf(skinning): a rig shadow box reads each bone stretch alone
jonit-dev Nov 11, 2026
97c33a8
fix(starter): a tier raise that fails waits longer before the next try
jonit-dev Nov 11, 2026
5287641
feat(runtime-native): the V8 player publishes the GPU frame by pass
jonit-dev Nov 11, 2026
f00af7c
docs(stress-loop): desktop window shadow-plan rows #347-#348 (PRD-871…
jonit-dev Nov 11, 2026
b3c9088
fix(starter): a tier raise that failed waits until its cost would fit
jonit-dev Nov 11, 2026
a3ee875
fix(renderer): the uniform upload counter counts the bytes written
jonit-dev Nov 11, 2026
bd1cd88
perf(renderer): a still frame sends no output uniform block
jonit-dev Nov 11, 2026
cac3780
fix(look): the low tier draws no screen-space reflections
jonit-dev Nov 11, 2026
99c719d
fix(starter): the post file fits the 200-line render cap again
jonit-dev Nov 11, 2026
2b1060f
docs(prd): performance PRDs match landed work and carry the Unreal 5.…
jonit-dev Nov 11, 2026
216b255
docs(prd-871): phase 2 box 1 and phase 3 box 2 ticked; AC-1 rises to …
jonit-dev Nov 11, 2026
96d8111
docs(prd): PRD-875, 876 and 877 with an execution order a cheaper age…
jonit-dev Nov 11, 2026
2eff660
docs(prd-871): AC-1 met on the laptop (84.6 and 67.5 fps, rows #349-#…
jonit-dev Nov 11, 2026
d0cc616
perf(PRD-877): interpolation cost is measured under telemetry (Phase 1)
jonit-dev Nov 11, 2026
09fec69
perf(PRD-573): the scoreboard ladder measures the owned engine
jonit-dev Nov 11, 2026
9ddf8f5
perf(PRD-875): a rig is safe to evaluate alone, and the tick is measured
jonit-dev Nov 11, 2026
c6c86d0
chore(stress-lab): rows 351-352, tower-defense holdout for the low-ti…
jonit-dev Nov 11, 2026
7117868
perf(PRD-876): a blocking pipeline create and a program build are cou…
jonit-dev Nov 11, 2026
6c491e5
perf(PRD-875): the tick counter is proved against the real tn_anim_tick
jonit-dev Nov 11, 2026
01923cb
docs(perf): AC-2 is green, and the runbook's renderer build command w…
jonit-dev Nov 11, 2026
daee1c6
chore(stress-lab): rows 353-357, score refresh: ThreeNative 80 vs van…
jonit-dev Nov 11, 2026
59cdf61
chore(stress-lab): rows 358-359, fresh Unreal 5.8.3 rows from the reb…
jonit-dev Nov 11, 2026
3883e79
perf(native): meshes on one skeleton read its bone poses once per sha…
jonit-dev Nov 11, 2026
02a6edc
fix(PRD-875, PRD-876): restore the pose guard across the flush, and m…
jonit-dev Nov 11, 2026
5e48b9e
perf(PRD-573): the ladder shows the owned engine it now measures
jonit-dev Nov 11, 2026
557c355
docs(PRD-876): the 3b gate could not be read on the strata lane
jonit-dev Nov 11, 2026
e4fe59c
perf(three-native): no scene walk for LOD chains until one is registe…
jonit-dev Nov 11, 2026
8c68bb9
docs(perf): PRD-871 plan-floor note and the play LOD walk learning
jonit-dev Nov 11, 2026
35d0dfe
docs(PRD-877): the interpolation sizing gate reads 1.05 ms a frame an…
jonit-dev Nov 11, 2026
99704dd
feat(native-engine): PoseHistory blends and restores a body's pose in…
jonit-dev Nov 11, 2026
4358fbd
docs(perf): PRD-871 Phase 3 close-out: play rows, holdout, plan floor…
jonit-dev Nov 11, 2026
6857799
style(three-native): the LOD-chain spec is printed as biome wants it
jonit-dev Nov 11, 2026
aa3d2bd
feat(native-engine): bind the pose history as __tnPose* for core
jonit-dev Nov 11, 2026
45f3fef
feat(core): physics interpolation blends on the engine path in C++
jonit-dev Nov 11, 2026
da185d6
perf(native): a caster whose inputs did not move costs nothing to pla…
jonit-dev Nov 11, 2026
615c94b
test(native-engine): the V8 host case for the pose history
jonit-dev Nov 11, 2026
3675bad
fix(native-engine): regenerate the native registry with the formatter
jonit-dev Nov 11, 2026
752815d
docs(PRD-877): record the lint bypass and why it was taken
jonit-dev Nov 11, 2026
f95ea2e
perf(native): a rig that moves rigidly keeps the shadow box it built …
jonit-dev Nov 11, 2026
0e582fe
docs(perf): PRD-872's two levers measured — 0.13 ms of the plan, noth…
jonit-dev Nov 11, 2026
657bf4b
perf(native): the plan transforms a rig's bones once, and hands the a…
jonit-dev Nov 11, 2026
7edc092
docs(perf): PRD-872 measured again after the three exact cuts (frame …
jonit-dev Nov 11, 2026
847d9fc
chore(stress-lab): rows 372-373, the AC-1 window after the three exac…
jonit-dev Nov 11, 2026
10b50e5
Merge remote-tracking branch 'origin/perf/chain-875-877' into perf/st…
jonit-dev Nov 11, 2026
81901d2
fix(native-engine): a settled body's pose blend writes nothing, and t…
jonit-dev Nov 11, 2026
200e196
docs(PRD-875): the sizing gate closes the pooled tick; Phase 1 done, …
jonit-dev Nov 11, 2026
51e2777
docs(PRD-876): the forest turn makes no sync creates on the real GPU;…
jonit-dev Nov 11, 2026
41135ff
test(runtime-native): five ungated native-engine cases match the engi…
jonit-dev Nov 11, 2026
788567c
docs(PRD-877): AC-1 met, PRD to done/; PRD-872 AC-1 and PRD-871 AC-2 …
jonit-dev Nov 11, 2026
8c568dc
docs(prd-872): the palette levers measured and stopped under the 0.3 …
jonit-dev Nov 11, 2026
9c699f0
docs(PRD-871, PRD-872): AC-1/AC-2 met at 8.09/8.20 ms; the remaining …
jonit-dev Nov 11, 2026
6ed4234
docs(PRD-878): the walking crowd costs 2.5 ms — filed from PRD-872's …
jonit-dev Nov 11, 2026
209fa76
docs(PRD-878): Phase 3 attributed; the JS side the engine owns is ≈0.…
jonit-dev Nov 11, 2026
59153c6
docs(PRD-878): Phase 3 reopens by owner decision — per-frame engine J…
jonit-dev Nov 11, 2026
573a65a
docs(PRD-879): world cells and terrain tiles run their frame in the e…
jonit-dev Nov 11, 2026
8c691ae
docs(PRD-879): the owner's proof bar and a regression guard box
jonit-dev Nov 11, 2026
5120e23
docs(PRD-879): the measured walk — world cells cost 6.8 ms, terrain t…
jonit-dev Nov 11, 2026
09b844c
perf(runtime-native): projection stops at a chain of bones, which dra…
jonit-dev Nov 11, 2026
ecf60b1
docs(PRD-878): Phase 2 attributed and the bone-chain cut ticks; the r…
jonit-dev Nov 11, 2026
2a81748
perf(world): the main-pass cell cull tests every visibility cell in o…
jonit-dev Nov 11, 2026
5959929
perf(world): the owned engine witnesses the bundle proof's material f…
jonit-dev Nov 11, 2026
ad5a7d0
perf(world): the GPU-scene refilter skips entries only a placementRea…
jonit-dev Nov 11, 2026
e10c12c
docs(PRD-879): the cell cull lands in one engine call; measured boxes…
jonit-dev Nov 11, 2026
641f4e9
fix(runtime-native): a program built behind the startup cover is not …
jonit-dev Nov 11, 2026
b350f33
fix(core): a frame-budget window behind the startup cover reports no …
jonit-dev Nov 11, 2026
cde99ad
perf(runtime-native): a palette batch compacts the frame's draws in p…
jonit-dev Nov 11, 2026
09ff837
perf(native-engine): the animation rate bands run as one engine call …
jonit-dev Nov 11, 2026
ead15e1
perf(three-native): a render writes its handles from kept words, not …
jonit-dev Nov 11, 2026
fe16d87
perf(core): no band call while the engine tracks no rig (PRD-878)
jonit-dev Nov 11, 2026
6fad93f
docs(PRD-878): the rate bands run in C++; Phase 3 box 1 ticks
jonit-dev Nov 11, 2026
b6ed8cd
perf(world): the shared batches' instance records reach the engine in…
jonit-dev Nov 11, 2026
fc843d0
test(world): a walking frame streams in one engine call per system, n…
jonit-dev Nov 11, 2026
eb933fc
perf(world): the refilter's stale scan runs over packed rows in one e…
jonit-dev Nov 11, 2026
dbdebfe
perf(world): the engine keeps the stale scan's rows, sent only when a…
jonit-dev Nov 11, 2026
327f1f6
test(core): the interpolation parity case types its solved pose and b…
jonit-dev Nov 11, 2026
a19e5cb
docs(PRD-879): the drain, the refilter and the guard tick; desktop fo…
jonit-dev Nov 11, 2026
7bff126
perf(runtime-native): a skeleton's pose version moves with every bone…
jonit-dev Nov 11, 2026
9926840
perf(runtime-native): walkers take the root-relative palette and keep…
jonit-dev Nov 11, 2026
c036de5
perf(runtime-native): the shadow plan keeps a rig whose pose held as …
jonit-dev Nov 11, 2026
f3a48c6
perf(runtime-native): a node material that reads no local space keeps…
jonit-dev Nov 11, 2026
0c861bd
fix(runtime-native): the relative slot's graph check admits only know…
jonit-dev Nov 11, 2026
0a80ae8
perf(runtime-native): a palette upload sends only the 4x4s that chang…
jonit-dev Nov 11, 2026
42ad6cf
perf(runtime-native): a crowd palette may take one write per moved ri…
jonit-dev Nov 11, 2026
e6fddf8
fix(runtime-native): the relative slot admits only listed engine unif…
jonit-dev Nov 11, 2026
420b2d4
perf(runtime-native): a skeleton whose pose held skips its world pale…
jonit-dev Nov 11, 2026
c665c9b
docs(PRD-878): Phase 1 lands; pose stamps, relative slots and the hel…
jonit-dev Nov 11, 2026
724e59b
fix(world): a prewarmed key draws the LOD-masked material its placeme…
jonit-dev Nov 11, 2026
4609d6e
perf(three-native): a render reads its frame numbers from one engine …
jonit-dev Nov 11, 2026
4638da4
perf(three-native): a compute dispatch's uniform sync carries the col…
jonit-dev Nov 11, 2026
55a3315
perf(native-engine): a post quad or a dispatch runs only the uniform …
jonit-dev Nov 11, 2026
d65499f
fix(core): a refused pointer lock reports not captured and warns, nev…
jonit-dev Nov 11, 2026
fbc2f1e
docs(PRD-878,879): Phase 3 round 3 recorded; the drain bookkeeping an…
jonit-dev Nov 11, 2026
09c71db
perf(runtime-native): a rig's skin attributes are resolved once per g…
jonit-dev Nov 11, 2026
36ec109
perf(runtime-native): a plain Group or Object3D goes straight to its …
jonit-dev Nov 11, 2026
4771c2f
perf(runtime-native): a skeleton carries the prepare that updated it,…
jonit-dev Nov 11, 2026
1f75a4f
perf(runtime-native): a shadow row keeps its depth program while the …
jonit-dev Nov 11, 2026
7e9e7d5
perf(runtime-native): a rig's lane reads its geometry half once per g…
jonit-dev Nov 11, 2026
2282ea7
perf(runtime-native): the palette batch skips a page caster before it…
jonit-dev Nov 11, 2026
4981bc1
fix(runtime-native): a material an onBeforeRender callback rewrote re…
jonit-dev Nov 11, 2026
2e438f2
docs(PRD-878): Phase 2 round 4 cuts recorded with their A/B figures; …
jonit-dev Nov 11, 2026
86a0690
perf(renderer): retain dense skinned palettes across resource versions
jonit-dev Nov 11, 2026
e9d6e6f
perf(renderer): move absorbed draws straight into batch output
jonit-dev Nov 11, 2026
fa6de03
perf(animation): poll bone weak owners only after a bone dies
jonit-dev Nov 11, 2026
6281ec7
fix(animation): isolate projected rate holds per camera and frame
jonit-dev Nov 11, 2026
bf82ade
perf(core): the refilter sends the engine only the stale-scan blocks …
jonit-dev Nov 11, 2026
e6a565e
perf(core): a placement's box is measured once, in closed form, not a…
jonit-dev Nov 11, 2026
ee22d38
docs(PRD-878,879): round 5 cuts and the world slab pack recorded; the…
jonit-dev Nov 11, 2026
21f4b58
docs(PRD-878): fair window fwfinal recorded — frame p50 9.22 to 7.75 …
jonit-dev Nov 11, 2026
a0e63ef
docs(PRD-878): AC-3 met with a NEUTRAL judge; AC-2 measured at 0.79-0…
jonit-dev Nov 11, 2026
87b4284
docs(PRD-879): AC-2 met (playtest exit 0 x3, forest judge NEUTRAL); A…
jonit-dev Nov 11, 2026
28371fc
docs(PRD-878): the remaining crowd cost attributed by stage; the larg…
jonit-dev Nov 11, 2026
082c680
docs(PRD-878,879): both close at the measured values (owner: option 1…
jonit-dev Nov 12, 2026
734c1ee
ci: fix the four reds on PR 659 — stb pinned to its locked revision, …
jonit-dev Nov 12, 2026
5ae526d
ci: the web engine budget moves from 4 MiB + 64 KiB to 4 MiB + 128 Ki…
jonit-dev Nov 12, 2026
502b29f
docs(verification): regenerate the native coverage report for PR 659'…
jonit-dev Nov 12, 2026
158a50a
Merge origin/develop into perf/stress-lab-audit
jonit-dev Nov 12, 2026
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
4 changes: 2 additions & 2 deletions .github/actions/native-engine-wasm/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ runs:
working-directory: packages/runtime-native
run: |
set -euo pipefail
# 4 MiB + 64 KiB, the number scripts/check-budgets.ts holds (PRD-867); a spec keeps them equal.
allowed_bytes=4259840
# 4 MiB + 128 KiB, the number scripts/check-budgets.ts holds (PRD-867, PR #659); a spec keeps them equal.
allowed_bytes=4325376
# The single-thread engine and the threaded one (PRD-752 phase 4), each under the budget.
for name in tn-native-engine-web tn-native-engine-web-threads; do
for file in "build/web/$name.mjs" "build/web/$name.wasm"; do
Expand Down
1 change: 1 addition & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ f799f51aba375ce12df5eb43db2a7c69b65d96ae:scripts/kit-loop.mjs:generic-api-key:45
# Verified 2026-11-06 at the recorded commit: the generic-api-key heuristic fires on PRD-595 prose
# that names a queue/key set and `keysForShadow`, not a credential. Scope to this finding only.
972d1e371728121b973a085e34ed045ed868b6df:docs/PRDs/strata-terrain-hardening/PRD-595-world-cells-splits-by-seam-and-survives-a-race-fuzz.md:generic-api-key:65
3dc61adc8a80b779e36087b66752dfbe0d1c56ef:scripts/stress-loop.mjs:generic-api-key:324
237 changes: 237 additions & 0 deletions PERFORMANCE-LEARNINGS.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

142 changes: 142 additions & 0 deletions docs/PRDs/done/PRD-875-rigs-evaluate-their-poses-on-every-core.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# PRD-876 — A draw is ready before the frame that first shows it

**Status:** DONE — Phase 1 landed (the two counters and the unregistered case). The sizing gate closed Phases 2 and 3 on 2026-11-11: on the real GPU the forest turn makes 0 synchronous creates and 0 program builds, because the startup warm-up already compiles every pipeline before the reveal (see Decisions).
**Priority:** P1 — the last camera-turn hitch on the Strata forest is a plan spike of about 10 ms in one frame, from programs and pipelines made the frame a mesh is first seen, and no counter reports it (Phases 1-3 open).
**Complexity:** 6 (MEDIUM) — 6-10 implementation files (2), renderer cache state across frames (+2), one build boundary: the C++ engine serves native and web Wasm (+2). Risk override: none.
**Owner:** João
**Depends on:** None to start Phase 1. Phase 2 edits `renderer/render_database.cpp` and `renderer/renderer_frame.cpp`, so it starts after PR #659 (`perf/stress-lab-audit`, PRD-871) merges. It takes over [PRD-573](../performance/PRD-573-unreal-class-performance-in-release-cycles.md) Phase v2.4 box 3.

Executor: read [EXECUTION-ORDER.md](../performance/EXECUTION-ORDER.md) first. Its rules and commands apply to every box here.
All code paths below are under `packages/runtime-native/src/engine/` unless they start with `packages/`.

## Context

`PERFORMANCE-LEARNINGS.md` (2026-11-04, "Strata forest turn") ends with: the last turn hitch is a CPU plan spike,
`planDraws` about 10 ms in one frame. Its steady p50 is 0.07 ms (PRD-774 Decisions). So the cost is not the plan. It is
what the plan creates the first time it meets a draw:

- **Program.** A `DrawCache` miss (`renderer/renderer_frame.cpp:834`) calls `Renderer::program` (`:838`,
`renderer/renderer_program.cpp:100`). The key is kind, vertex variant and light layout (`renderer_program.cpp:103`). The
WGSL build, the renames, the layouts and the bind groups all run synchronously on the calling thread.
- **Pipeline.** `renderer_frame.cpp:866-867` calls `PipelineCache::getOrDefer` only when `deferUncompiled_` is set. In
every other case it calls the synchronous `PipelineCache::get` (`renderer/pipeline_cache.cpp:283-296`).
- **A culled mesh builds nothing** (`renderer/render_database.cpp:930,935-939`, PRD-610 AC-10). The one exception is a compile pass, which keeps off-frustum meshes (`:927-930`, with the reference `UE 5.8.3: Engine/Source/Runtime/Engine/Private/PSOPrecache.cpp:230` already cited in that comment). It gets its record
(`:950`) the first frame it is in view. That is the frame of the turn.

What exists already:

- `Renderer::compileAsync` (`renderer/renderer.cpp:621`) runs the plan with `compilation` set. Programs still build
synchronously, and pipelines go to `PipelineCache::getAsync` (`pipeline_cache.cpp:312`).
- `RenderDatabase::compileAsync` (`render_database.cpp:1903`) sets `compiling_`, which keeps off-frustum meshes in that
one compile (`:927-930`).
- The web loading cover holds the reveal while `pipelinesCompiling() > 0`
(`packages/runtime-native/src/engine/wasm/web_host.cpp:386-392,476`).
- Counters: `PipelineCache::compiles_` counts synchronous and async creates together (`pipeline_cache.cpp:286-287,334`),
`pipelineCreateMicros` (`:372`), `pipelineDeferredDraws` (`:307`), `programKeyBuilds()` and `programLookups()`
(`renderer/renderer.h:960-961`), `textLookups()` (`renderer/pipeline_cache.h:169`).

What is missing: no counter separates a synchronous create from an async one, no counter counts program builds, and
nothing prepares a mesh that sits outside the view after the reveal.

Unreal builds each draw command when the primitive is added, in batches on tasks
(`UE 5.8.3: Engine/Source/Runtime/Renderer/Private/PrimitiveSceneInfo.cpp:51-53`, `r.MeshDrawCommands.CacheMultithreaded` 1).
A frame then only looks the command up. This PRD takes that idea within what our key allows: the light layout, the fog,
the sample count and the pass list are known only at render time, so the engine prepares a mesh on the first frame it
knows the mesh exists, not at `add()`.

## Solution

1. **Count it.** Two debug counters: synchronous pipeline creates and program builds, per frame.
2. **Prepare what is out of view.** After the first presented frame, `RenderDatabase::project` lets a small number of
culled meshes that have no record yet pass into the plan each frame, marked compile-only. They take the existing
`compilation` branch: the program builds, the pipeline goes to `getAsync`, and nothing is drawn. A turn then finds a
`DrawCache` hit and a finished pipeline.
3. **Keep the lazy path.** A mesh that comes into view before its turn in the budget still builds on the spot, exactly
as today. The output must be equal on both paths.

Not in this PRD (each needs its own design and a visual judge): skipping a draw whose pipeline is pending on the native
player, deferring shadow depth pipelines (a caster that appears late must invalidate its virtual shadow pages), building
programs on a pool task (needs an async `JobPool` submit, PRD-774 Phase 3), and the per-frame key work in the shadow rows
and the page clear (`renderer_frame.cpp:1489-1501,1955-1969`), which waits for PR #659.

## Validation scene

- **Consumer and scene:** the Strata forest, `examples/strata-terrain-preview`, a 360-degree camera turn after the reveal.
- **Benefit workload:** the turn. Read per frame: synchronous pipeline creates, program builds, CPU frame time.
- **Small control:** a scene where every mesh is in view at the reveal. The budgeted pass must find nothing and add no
program, no pipeline and no record.
- **Worst case:** 10,000 culled meshes with 10,000 different materials. The budget must bound the work per frame, and
the time to reveal must not grow, because the pass starts only after the first presented frame.
- **Pass:** AC-1 below.

## Acceptance Criteria

- [x] AC-1 [local, measure lane]: On the Strata forest turn on the owned engine, synchronous pipeline creates and program builds in the turn are 0. proof: `TN_FRAME_BUDGET.counters.engine` per window — Evidence (2026-11-11, head `81901d260`, RTX 2080, 69 windows): every create (44) and build (98) is in the load window, before `TN_STARTUP_WARMUP_HELD`; 0 of each, and 0 deferred draws, in every window of the reveal, walk and turn (Decisions). The head-against-develop comparison, the reveal-time bound and the visual judge of the original AC-1 compared a prepare pass that the gate closed, so they are not run.

## Blocked on

- Nothing. AC-1, AC-2 and Phases 2-3 were closed by the sizing gate (Decisions, 2026-11-11).

## Integration Ledger

| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence |
| --- | --- | --- | --- |
| Create counters | Any game frame → `PipelineCache::get`, `Renderer::program` → `TN_DEBUG_COUNTERS` → `TN_FRAME_BUDGET.counters.engine` with `?tnDebug=1` | Adds to `pipelineCompiles`, which stays | Phase 1 |
| Budgeted prepare pass | Any game frame after the first present → `RenderDatabase::project` → the `compilation` branch of the plan | The lazy build stays as the fallback for a mesh that enters view first | AC-1, AC-2 |

## Execution Phases

#### Phase 1: Count synchronous creates and program builds
**Status:** DONE
**Files:** `foundation/waste_counters.h`, `renderer/pipeline_cache.cpp`, `renderer/renderer_program.cpp`, `packages/runtime-native/tests/native-engine/renderer_caches_test.cpp`, `packages/runtime-native/cmake/NativeEngine.cmake`.
**Implementation:**
1. Add `X(pipelineSyncCreates) X(programBuilds)` to the `TN_DEBUG_COUNTERS` list (`foundation/waste_counters.h:31-38`).
2. In `PipelineCache::get` (`renderer/pipeline_cache.cpp:283-296`), add `waste::debugAdd(waste::counters().pipelineSyncCreates);` on the line after `create(device_, vertex, fragment, target)` (`:285`). Do not add it in `getAsync`.
3. In `Renderer::program` (`renderer/renderer_program.cpp:100`), add `waste::debugAdd(waste::counters().programBuilds);` on the path that builds a new program, after the cache lookup misses. Do the same in `depthProgram` (`:277`).
4. Register the unregistered case: `compile_reaches_off_screen` is in `TN_TEST_MAIN` (`renderer_caches_test.cpp:887`) but not in CMake. Add it beside the other `native_engine_renderer_pipeline_*` cases (`cmake/NativeEngine.cmake:453-472`). If it fails, do not fix it here: report the output and leave it unregistered.
- [x] With debug counters on, the first frame that draws a new mesh counts 1 synchronous pipeline create and 1 program build, and the next frame counts 0 of each. With `compileAsync` first, the draw frame counts 0 synchronous creates. proof: a new case `sync_creates_counted` in `renderer_caches_test.cpp`, registered in `NativeEngine.cmake`, `ctest -R '^native_engine_renderer_pipeline_'`. — Result (2026-11-10, re-measured exactly after review): the box's **1 is wrong for a renderer's first draw, which costs 2**. The case now asserts both numbers exactly rather than a lower bound. On a fresh renderer the first draw that shows a mesh counts **2 creates and 1 program build**: one create is the material program's own pipeline, the other is a renderer-level pipeline (post/output) that the first draw of the renderer's life creates — not, as first guessed, one pipeline per pass. On an already-settled renderer a new program key costs **exactly 1 create and 1 program**, and a second material of the same kind on the same key costs **0 of each**, because programs are keyed by kind, vertex variant and light layout rather than by material instance. The next frame adds 0 of each. The `compileAsync`-first arm counts 0 creates on its draw frame, as written. Red first: with the `pipelineSyncCreates` line removed, `CHECK(lazy.first.creates == 2)` and `CHECK(added.creates == 1)` both fail; restored, `PASS sync_creates_counted`. `ctest -R '^native_engine_renderer_pipeline_'`: 9 of 9 passed, exit 0 (7 before, plus this case and `compile_reaches_off_screen`).
Known gap in the counter: some pipelines are made outside `PipelineCache::get` (`traa.cpp`, `renderer_output.cpp`, `renderer_material.cpp`), and on web the main pass may go through `getOrDefer` once `deferUncompiledDraws` is on, which counts `pipelineDeferredDraws` instead. The sizing gate must read those two counters beside this one.
Step 4 also landed: `compile_reaches_off_screen` was in `TN_TEST_MAIN` but no ctest entry named it; registered in the `foreach` list it **passes**, so nothing needed leaving out.
Build note for the next executor: the count cannot be read from `build/tn-linux`. The native-engine test tree is configured only under `-DTN_ENGINE_ONLY=ON` (`CMakeLists.txt:787-790`), so the command in EXECUTION-ORDER.md against `build/tn-linux` finds no such target. Use `cmake -S . -B build/tn-engine-only -G Ninja -DCMAKE_BUILD_TYPE=Release -DTN_ENGINE_ONLY=ON -DMYSTRAL_USE_DAWN=ON` and `--target tn-native-engine-renderer-caches-test` in that directory.

**Sizing gate (measure lane, before Phase 2):** on the forest turn with `?tnDebug=1`, record `pipelineSyncCreates`, `programBuilds` and `pipelineCreateMicros` for each frame of the turn, and the per-frame growth of `programKeyBuilds` on a still camera. Write the numbers under `## Decisions`. If the turn shows 0 synchronous creates and 0 program builds, the spike has another cause: set this PRD to `DEFERRED` and stop.

**Sizing gate attempt (2026-11-10, executor, inconclusive — recorded so the next lane does not repeat it):** the Wasm engine was built from this head (`pnpm wasm:build`, exit 0, both `tn-native-engine-web.wasm` and the threaded pair; `node scripts/web-engine-stamp.mjs --check` exit 0; `animationTickMicros` present in both `.wasm` files, so the build carries this branch's counters). `examples/strata-terrain-preview` was served and `playtests/loop.playtest.json` was run against it with `?tnDebug=1`: **844 frames, exit 1, and the counters the gate needs are not obtainable on that lane**.
- The scenario's own census is unavailable: `reveal.pipelineCensusAvailable` is `false` and `pipelinesSinceReveal` is `-1` on both the before and after reads, so its `equals: 0` assertion is silence rather than a measurement.
- The captured `TN_FRAME_BUDGET.counters.engine` bag carries 97 entries and **none of the renderer counters** — `pipelineSyncCreates`, `programBuilds` and `pipelineCompiles`, `computePasses`, `shadowPageEvictions` are all absent. So this is not this PRD's two counters failing to publish; the lane does not publish the renderer group at all.
The gate therefore still needs a lane that publishes them, or the census path repaired first. `?stress&tnDebug=1` on the stress lab is the documented route and was not attempted here.

#### Phase 2: A culled mesh is prepared inside a per-frame budget
**Status:** NOT BUILT — closed by the sizing gate (Decisions, 2026-11-11). The Solution above stays as the plan if a
scene ever shows synchronous creates after its reveal.

#### Phase 3: Prove it on the forest
**Status:** NOT BUILT — closed with Phase 2.

## Decisions

- 2026-11-10 (Claude, from a read of develop at `d6b1630b8`): the engine prepares on first knowledge, not at `add()`. The program key needs the frame's light layout, fog, sample count and pass list, and none of them exist when a mesh is added.
- 2026-11-10 (Claude): the per-draw `selectiveLights` string (`renderer_frame.cpp:627-646`) is not a box. It returns an empty string at once for a draw with no `lightsNode`, so its cost is unproven. The sizing gate reads `programKeyBuilds` on a still camera, and a box is added only if that number grows each frame.
- 2026-11-11 (Claude, measure lane, sizing gate): **the turn has nothing to prepare, so Phases 2-3, AC-1 and AC-2 are
closed, not built.** The 2026-11-10 attempt read zeros because it served the forest on upstream three: plain
`pnpm dev` does not select the owned engine, `TN_ENGINE=threenative` does (`examples/strata-terrain-preview/vite.config.ts:127`),
and its Xvfb gave SwiftShader, which then lost the device. This read: head `81901d260` (Wasm pair built from it),
`TN_ENGINE=threenative`, `loop.playtest.json` at `?tnDebug=1`, `TN_PLAYTEST_HOST_DISPLAY=1 --headed --browser-recipe
webgpu`, adapter `turing` (RTX 2080), 69 `TN_FRAME_BUDGET` windows. **All 44 synchronous pipeline creates and all 98
program builds fall in window 2, the load.** Every `TN_PROGRAM_LATE` line (44) is logged before
`TN_STARTUP_WARMUP_HELD` (319 of 319 pipelines compiled before the reveal). From window 3 on — the reveal, the
600-tick walk, `turn-half` and `turn` — `pipelineSyncCreates`, `programBuilds`, `pipelineDeferredDraws`,
`pipelineCompiles` and `pipelineCreateMs` are 0 in every window, at 63-64 draws a frame; the turn frame
(`turn.png`) draws the forest. `programKeyBuilds` is not published on this page, so the still-camera growth was
not read; the `selectiveLights` question above stays without a box. The 10 ms turn spike this PRD was filed for
is not a pipeline or program cost on this head: the startup warm-up already does what Phase 2 proposed, before the
reveal instead of in a per-frame budget after it. AC-2 (`pnpm parity`) guarded the prepare pass, which is not
built; Phase 1's counters are covered by the renderer cases (673 of 678 `native-engine` cases on the merged tree).

Loading
Loading