Skip to content

Stress lab to 120 fps: fair comparison, VSM culling, crowd GPU joints, CPU attribution, plan off the main thread - #594

Merged
jonit-dev merged 107 commits into
developfrom
perf/stress-lab-120
Oct 30, 2026
Merged

jonit-dev merged 107 commits into
developfrom
perf/stress-lab-120

Conversation

@jonit-dev

@jonit-dev jonit-dev commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What is this PR about?

The stress lab (sandbox/stress-lab, main.html?stress, real preset) must run at 120 fps, stable, on the develop Wasm engine, with no quality cut. This PR carries the engine fixes and measurement tooling of the batch PRD-770 to PRD-774, in that order.

Why does it matter?

The stress lab is the heavy benchmark for every game: crowds, virtual shadow maps, physics boxes, fires. A frame that fits 8.33 ms there fits it in a real game.

The ladder (CPU frame p95 and GPU p95, fair uncapped window)

Rung Budget CPU p95 (develop → branch) GPU p95 (develop → branch) State
stable 60 16.67 ms 16.35 → 17.07 (stack2 uncapped, rows #75-#76, load ≤ 2.2) 19.27 (6.7 / 31.9 bimodal) → 7.50 ✅ ❌ branch CPU p95 0.4 ms over; develop GPU tail fails
90 11.1 ms 17.07 7.50 ✅ ❌
120 8.33 ms 17.07 7.50 ✅ ❌

CPU p50 on the branch: 12.6 ms (−4.9% vs develop in both pairs), 75 fps uncapped.
GPU: FG1 removed the 34 ms GPU tail (it showed in vsync runs and in some uncapped runs). CPU: FB1 cut the p50 (−4% to −7%), but FG1 adds about 1.3 ms to CPU p95 on granting frames; that cost is lane 773's top item. Develop's CPU p95 moves 16.6-18.2 ms between windows, so a rung counts only from a fair window.

PRDs

  • PRD-770 fair, workload-equal engine comparison — prd:75 (Unreal timing rows, three.js-engine split counter open)
  • PRD-773 VSM per-page instance culling (FG1) — prd:50 (FG1 landed; CPU p95 cost + FG2 open)
  • PRD-772 crowd GPU joints on web (FC1) — native done; web admits 0 rigs (escaped bones), eager-locals mode in progress
  • PRD-771 render-phase CPU attribution — prd:50 (stages landed; jsAlloc counter, owners for engineJs/wasmTargets open)
  • PRD-774 render plan off the main thread (TR1, TR3, TR2) — TR1 rejected (−0.4%/−1.8%); batch-clean skip, TR2 in progress

Note on the diff

The branch starts at local develop (2413b8e), which is 67 commits ahead of origin/develop: the earlier stress-loop rounds (FS1, FT2, FM1-FM3, FM8+FS9, FS11+FS12, dashboard). Those commits show in this diff until develop is pushed.

Results log

Each kept or rejected lever lands as a comment below, with its paired numbers.

…p, KPIs at 120 fps (8.33 ms)

pnpm stress:loop record <report.json> appends a fail-closed validated row; the hub renders
docs/stress-loop/ledger.json on each load.
… copy stays per caster

Under VirtualShadowNode every shadow caster (and every skinned rig) left the batch lanes,
so the stress lab's 600 colour-only props drew 616 main draws instead of 66. The caster now
joins the main-pass batch and keeps its own shadow-only item for the per-page cull and mover
detection.

Stress lab, still pose, static layers, RTX 2080, 2 runs per arm: main draws 616 -> 66,
fps 43.5/44.4 -> 57.4/54.8, GPU p95 17.9/25.1 -> 11.9/10.5 ms. Full real preset: main
draws 2795 -> 1803; frame still ~800 ms (virtual page redraw, next lever).
Visual judge (fresh Sonnet subagent, 2 runs per arm): NEUTRAL both frames, pass.
Tests: render_database 97/97 cases, 2 new red-green cases.
…inned vertices

Each frame the virtual shadow bounds pass skinned every vertex of every rig on the CPU
(getVertexPosition per vertex, a dynamic_cast each). With the stress lab's 120-rig crowd
that cost about 760 ms a frame. SkinnedMesh::boneReachBounds measures once per geometry
revision how far each bone's weighted vertices reach in the bind pose, then fits a box
to the posed bones in O(bones), as UE 5.8.3 fits skinned bounds to bones. The box holds
every vertex whenever weights sum to 1; otherwise it falls back to the vertex scan.

Stress lab, RTX 2080, 1280x720, WebGPU:
- full real preset: 1.38 -> 7.99 fps, frame p50 797 -> 73 ms (single run each)
- crowd, still pose, 2 runs per arm: 1.18/1.18 -> 21.1/21.7 fps, 808/828 -> 45/45 ms
- crowd closeup, 2 runs per arm: 1.30/1.17 -> 23.1/20.8 fps
Visual judge (fresh Sonnet subagent, frozen pose, 2 runs per arm): NEUTRAL, pass;
541 of ~921k scene pixels differ, nearly all by one level.
Tests: render_database 98/98 (new bone_reach_bounds_hold_posed_vertices), VSM ctest 14/14.
Core wires chrome-devtools-mcp 1.11.0 through a shim, like the Blender
server. The shim launches Chrome with the playtest WebGPU recipe, an
isolated profile, and usage statistics and CrUX lookups off. Chrome
starts only on the first tool call.

trace-a-slow-frame.md tells authoring agents to use it after playtest
trace, and not to quote its CPU times: async stacks inflate them.

The registry-install probe checks it by initialize and tools/list only,
because every DevTools tool starts a headed Chrome.
… a slow game

Every kit's AGENTS.md points a slow or stuttering game at the
performance skill. The skill now gives the order: playtest trace
first, then the threenative-devtools MCP, quoting its GPU, heap and
network numbers and not its inflated CPU times. The skill word cap
moves from 260 to 285 for this section.
Any window move redrew every page of the level: the clipmap re-centred at texel
precision, so no page key survived a pan. A pan now moves the window by whole pages
from its previous origin (the first placement and a cut keep the exact texel origin, so
the GPU reference renders still match), and the atlas keeps a page whose slot already
held that world page while the depth mapping and page size are unchanged; dirty pages
still redraw. Pattern from UE 5.8.3 virtual shadow clipmap panning.

Stress lab, full real preset, RTX 2080, 6-window median: 4.8 -> 8.27 fps, frame p95
223 -> 159 ms, GPU p95 125 -> 72 ms, presented p95 533 -> 283 ms.
Visual judge (fresh Sonnet subagent, orbit frozen at 12 s after a pan path, 2 runs per
arm): NEUTRAL, pass; scene differences are 1-2 px edge fringes, no seams.
Tests: new native_engine_vsm_pan_keeps_pages red -> green; VSM ctest 15/15 including
the GPU reference renders; render_database 98/98.
…T.counters.engine

A slow web frame named only `mainPass`; finding which part of the 2,000-line renderPrepared cost
it took a custom checkpoint build. The renderer now keeps running CPU ms (thread CPU where the
platform has it, else wall) for the virtual shadow pages and caster bounds, the draw plan, the
shadow passes' draw lists, the whole prepare half and the encode half. tnw_web_frame fields 66-70
read them, and three-native adds them to __tnEngineCounters, so core reports per-frame p50/p95
whenever debug or the engine-cost knob is on: every game gets the hotspot by name.

Stress lab, full real preset, RTX 2080 (one window): cpuVirtualPagesMs p50/p95 11.3/64.4,
cpuShadowBuildMs 1.9/89.1, cpuPrepareMs 17/113, cpuEncodeMs 2.0/5.7, cpuPlanMs 0.15/0.2.
Checks: VSM ctest 15/15; browser-renderer + frame-counters specs 53/53; native targets build.
…ot by an engine call

Every quaternion write ran three's onChange, which called Euler.setFromQuaternion in the engine:
one crossing per body per frame. Physics interpolation sets every body's quaternion each frame,
so the stress lab paid 1,732 crossings a frame for it. The bridge now computes the rotation with
Euler.cpp's own formulas (update off, as the binding calls it) and writes the lanes, as the
opposite Euler-to-quaternion sync already does. The engine's ieee754 functions are V8's fdlibm,
so Math gives the same bits; a lane offset missing from the registry keeps the call.

Tests: bridge-crossings.spec red on the old bridge (rotation lanes untouched, no export), green
now: 200 quaternion writes cross 0 times and match three's Euler.setFromQuaternion bit for bit;
all six orders and the gimbal-locked branch match three. Spec file 26/26.
Stress lab (plainShadow, RTX 2080): debug census `call Euler.setFromQuaternion` 1,732 -> 0 per
frame. Frame time inconclusive: host load 22.8 (a Codex lane linking), 2 runs per arm.
…start without worker threads

Owner, 2026-10-29: "just force all to use worker threads bro, there's no other way for us".
The page loaded the single-thread engine whenever the threaded module was absent or the page was
not cross-origin isolated, and TN_FRAME_BUDGET only reported `threads: off`. Now the plugin
always imports tn-native-engine-web-threads, the build fails with TN_WASM_THREADS_MISSING when
it is absent, and the page throws TN_WEB_THREADS_OFF when it is not cross-origin isolated or the
job pool starts no worker. The single-thread module stays for Node hosts (tests, ABI check).

Tests: web-engine.spec red on the old loader (no TN_WASM_THREADS_MISSING), 26/26 green.
Stress lab, RTX 2080: threads "on (3)", no errors; ?tnIsolation=0 throws TN_WEB_THREADS_OFF.
…refuses a count of 0

Owner, 2026-10-29: "force on native too" / "we cant run without this, performancen wll suck".
TN_JOB_WORKERS=0 turned the pool off and a one-thread CPU started no worker. Now a count of 0 or
a non-number throws TN_JOB_WORKERS_OFF, and the default is at least 1 worker (at most 3, as
before). Tests still size a pool with resetShared, including 0, to compare walks.

Tests: native_engine_job_pool_is_always_on red on HEAD (CHECK failed: refused), green now;
job pool ctest 4/4. Threaded Wasm build links.
…and a mixer's timeScale are read in place

An AnimationPlayer reads timeScale and paused on every action and the mixer's timeScale every
frame; each read was a bridge crossing (stress lab: 445 + 330 + 360 a frame). The bindings now
declare them as fields, so the Wasm back end reads the engine's memory, as it already does for
the mixer's time and an object's visible flag. The action's block starts at `time`, so the
offsets hold in the 64-bit registry dump and the 32-bit Wasm build (static_assert on the layout).

Tests: bridge-reads.spec red on the old registry (1 crossing), green now (0 across 200 frames).
three-native specs 511/512; the one red (shadow-node.spec, TN_NATIVE_SHADOW_NODE_UNSUPPORTED)
fails on the HEAD registry too.
Stress lab (plainShadow, threaded engine, RTX 2080): debug census 3,696 -> 1,400 crossings a frame.
…33.9 -> 7.7/7.6 ms, CPU frame p50 within 1.3%)
Every frame-budget window now splits the main-thread frame into named
stages: JS update, JS before and after the engine call, Wasm plan (with
virtual-page and shadow-list parts), scene walk, encode, submit, post,
render targets and named remainders. The web host publishes its totals in
one array read in place (tnw_web_frame 78), so a steady frame pays no
host call per stage; a module without it reports the stages as absent,
never zero. Meters on vs off: mean +0.05 ms over 5 pairs.
Stress lab ?stress p50 14.70 ms = plan 4.00 + scene 2.83 + JS before
2.65 + update 2.26 + engineJs 0.81 + targets 0.72 + rest; the p95 tail
is 71% shadow refresh.
…p95 34.0 -> 7.6 ms vsync, CPU p50 -1.7% uncapped, CPU p95 +6-8%
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Progress 14:50Z UTC. Gains since the last update: FG1 landed (judge NEUTRAL, GPU tail gone), PRD-771 stage meters landed, PRD-770 at prd:75, 176 native cases green on the merged branch.

Item Result
✅ FG1 (1b44cdb6e) Fair window rows #67-#70: vsync GPU p95 33.73/33.85 → 7.67/7.62 ms. Judge round 2 NEUTRAL (3 AFTER frames inside BEFORE-vs-BEFORE noise; round 1's odd frame was a different camera pose).
⚠️ FG1 CPU cost CPU frame p95 +1.3 ms on granting frames (16.59 → 17.85 vsync, 17.25 → 18.64 uncapped). The p50 gate hid it. Lane 773: cache the per-level index across frames / move it to the job pool, before FG2.
✅ PRD-771 (38cfc1162) Named CPU stages in TN_FRAME_BUDGET, totals read in place, absent never zero, meters cost +0.05 ms. Review found 3 fail-closed gaps; 2 fixed with specs, 1 not reproducible and pinned by a native case.
⚠️ Stack window (rows #71-#74) FB1+770+FG1 vs develop, uncapped: CPU p50 −1.8% / −1.6%, CPU p95 17.38 → 18.73, GPU p95 vsync 34.0 → 7.6.
✅ Plain-map skinned caster cull (lane 770) Shadow triangles 2.06 → 1.29 M = vanilla (apples to apples). CPU cost being re-measured in isolation on the branch.

Ladder: no rung reached yet. GPU fits 120; CPU p95 is 17-19 ms.

…'s stage totals

PRD-770 and PRD-771 both claimed tnw_web_frame field 73 (shadow triangles and
the post-effect stage); the threaded Wasm did not compile with both. The
census spec now pins field 85 for the frame split.
…0 -4.9% both pairs, GPU p95 7.5 vs develop 6.7/31.9, CPU p95 16.35 -> 17.07
…(PRD-770)

Unreal cannot count its own draws and triangles, so record refused every
Unreal row on mainDraws: null. Only engine unreal may leave mainDraws and
mainTrianglesM null; every owned-engine row stays fail-closed. A row without
main triangles shows as not comparable: main triangles not measured.
…l): shadow triangles 2.06 -> 1.29 M = vanilla, but plainShadow CPU frame p50 +11.9%/+10.6%, GPU flat; rejected as a speed lever
measurePass named run files by order.indexOf(arm), so ABBA gave 0,1,1,0 and
each arm's second run overwrote its first. saveRun now takes the arm's next
free run number.
…rejected row (PRD-770)

The comparison anchored on the latest row named exactly 'stress', so it
showed develop row #59 from before FG1 (GPU p95 34 ms) while the branch
stack read 7.5 ms. A column now shows the latest kept arm of the scene
(arm= tags ignored), else its latest row that was not rejected, and the
header names the arm and build.
…ison rules (PRD-770)

Land the branch's stress-loop and stress-fair scripts on develop so the
:4751 dashboard renders them: columns anchor on the kept arm (rows #77/#79
= stack head 7b212502b66b) instead of develop row #59, not-comparable rows
say why, Unreal rows record without counts. Ledger: stack head rows marked
keep, add-on 5 rows marked reject.
…t (PRD-770)

Vanilla three.js has one plain sun map, so the virtual shadow table can
never rank it. The plain table is now the first comparison, and the virtual
table says it is ThreeNative only.
… compare (TR1 + TR1b, PRD-774)

Stable per-draw slots, and a stamp per instanced draw (store epoch, writes,
version, count, BufferStore::id) that commits only after the upload
succeeds. A still scene copies and uploads 0 instance bytes.

Stress lab on the stack (rows #85-#88): uncapped CPU frame p50 -4.9% and
-7.6%, p95 18.13 -> 17.64 ms, GPU p95 7.52 -> 7.39 ms. Opus judge NEUTRAL.
Red-green: static_instances_are_not_recopied. Learnings also record FG1b
(index kept across frames) as rejected: p95 inside noise.
… PRD-770)

toEngine forwarded only the eight SCALARS typed arrays, so a
Uint8ClampedArray (getImageData pixels) was boxed with Array.from and
written as float64. The stress lab's 4096x2048 sky took 1.56 s of a
1.98 s load. Stress-lab loadMs 1550 -> 542 and 1490 -> 501 ms (rows
#95-#96), below vanilla three's 548.5 ms. Red-green spec.
…, PRD-770)

On engine "threejs" a WorldEnvironment output pipeline draws the world as a
render nested inside RenderPipeline's composite quad, and RenderPassBudget
named the outermost call (the quad) main: the stress lab read 1 draw and
0 M triangles. A call whose scene is three's QuadMesh is now nested, and
the first world call at world depth 0 is main. Red-green spec.
…can (FT2, PRD-770)

record() cleared the dedupe sets but appended to #materials and
#geometries on every re-record, so holds() walked a list that grew by
the scene's material and geometry count each frame (50 entries for 2
after 25 records). The lists now reset with the walk. Hunter
microbench: holds() 0.145 ms after 1 record stays ~0.03 ms after 200.
Red-green spec; 12 importer specs 231/231.
… PRD-770)

#taskCount and #longestTaskMs were never reset at a window boundary, so
every window reported the load phase's long tasks again. The stress lab
summed them over its window ring: 66 = 6 load tasks x 11 windows, while
the browser observer saw 0 long tasks in steady state. Vanilla's report
resets per window, so the fair table compared different metrics.
afterFirstFrameMs stays a run-level startup figure. Red-green spec.
…-4.9%/-7.6%, p95 18.13 -> 17.64, GPU p95 7.52 -> 7.39; KEEP
…18.04 vsync, 18.95 -> 18.83 uncapped, p50 flat; REJECT
…0 -> 542, 1490 -> 501 (-65%), below vanilla 548.5; KEEP
@jonit-dev
jonit-dev marked this pull request as ready for review October 30, 2026 19:02
@jonit-dev
jonit-dev merged commit 1882d60 into develop Oct 30, 2026
jonit-dev added a commit that referenced this pull request Oct 30, 2026
…595)

- renderer_link.cpp: MaterialNodes is a pointer since #594 (native-engine compile).
- native coverage record re-measured on the fixed tree (source digest 2ed6fa…).
- build.spec: the fixture ships the threaded Wasm pair the loader now requires.
- stress-fair.spec: register temp dirs for cleanup (temp-dir guard, test-unit 4/4).
- strict typecheck narrowing in four specs (frame-budget-stages, projection-stability,
  stress-fair, stress-loop); every assertion keeps its meaning.

Local proof: laptop typecheck exit 0, budgets exit 0, 7 failed spec files 136/136,
Wasm renderer-link target builds. template-nonvisual reds were an npm propagation
window for tslib 2.9.1 (published 20:20Z), not a code change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:0% PRD implementation has no verified phase yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant