Repository navigation
Stress lab to 120 fps: fair comparison, VSM culling, crowd GPU joints, CPU attribution, plan off the main thread - #594
Merged
Merged
Conversation
…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.
… kit's virtual shadow recipe
… 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.
… upload, JS alloc; row #2 note corrected
…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.
…forced worker threads
…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%
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.
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
marked this pull request as ready for review
October 30, 2026 19:02
Merged
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
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
Note on the diff
The branch starts at local
develop(2413b8e), which is 67 commits ahead oforigin/develop: the earlier stress-loop rounds (FS1, FT2, FM1-FM3, FM8+FS9, FS11+FS12, dashboard). Those commits show in this diff untildevelopis pushed.Results log
Each kept or rejected lever lands as a comment below, with its paired numbers.