Skip to content

perf(web): PRD-752 run web games in a worker so the page cannot stall them - #576

Closed
jonit-dev wants to merge 29 commits into
developfrom
prd-573-v5-worker-mode
Closed

jonit-dev wants to merge 29 commits into
developfrom
prd-573-v5-worker-mode

Conversation

@jonit-dev

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

Copy link
Copy Markdown
Contributor

What is this PR about?

Web games will run their game code and engine in a background worker, not on the page's main thread. The page keeps only the HUD, input and audio. The plan, the loaded-host baseline and the first core change have landed.

Why does it matter?

On a busy computer every web game collapses today. The shooter kit measured a frame time of 100 ms at p95 with the main thread 100% busy.

What changed

  • New plan: PRD-752, four phases: loaded-host baseline, one host-capability layer in core, worker mode on an OffscreenCanvas, then Wasm threads.
  • The owner decision of 2026-10-26 is recorded: the HUD gets one state snapshot per frame from the worker and sends intents back, and Wasm threads are in scope.
  • PRD-573 Cycle v5 now points to PRD-752, and its "worker HUD contract" blocker is removed.

How I checked it

  • pnpm prd:progress docs/PRDs/performance/PRD-752-…md → exit 0, prd:0%, 4 phases, 14 boxes.
  • pnpm check:docs → 3562 links checked, pass.
  • Not run: any code gate. This commit changes only Markdown.

Risks

  • Breaking change: none, docs only.
  • Performance: none yet. The KPIs come from the phase 1 baseline, not from a guess.
  • Migration: none.
  • Rollback: revert the PR.

Still open

  • Phase 1: loaded-host baseline. Shooter at 24 busy processes: p95 116 ms, main thread 97% busy (3 runs, RTX 2080).
  • Phase 2: core reads window and document only through platform.ts. Native desktop gate passed. The starter HUD works over an async message port (?tnUiLoopback=1).
  • Phase 3: worker mode behind a flag, harness capture, KPI, visual judge.
  • Phase 4: Wasm threads with and without cross-origin isolation, stability gate.

References

PRD progress: 50% — 2/4 phases (10/15 boxes), 0/0 acceptance (generated)

PRD: docs/PRDs/performance/PRD-752-web-games-run-in-a-worker-so-the-page-cannot-stall-them.md

Phase 1: Loaded-host baseline — 2/2

  • A repeatable loaded-host profile exists: it starts N busy-loop processes (N and load recorded), runs the shooter and the starter, and prints main-thread busy %, frame p50 and p95. proof: sh scripts/xvfb.sh pnpm profile:wasm-page -- --url <url> --seconds 10 --warmup-ms 8000 --cpu-work --load 24 --json. Result: --load and busyPercent added, scripts/__tests__/profile-wasm-page.spec.ts 15/15 green, 12 runs recorded in Decisions
  • The baseline for the shooter and the starter is recorded in this PRD under ## Decisions, with load, adapter name (not SwiftShader) and three runs per arm. The v5 KPI numbers are set from it. proof: the ## Decisions entry, adapter.info captured in the run. Result: baseline and KPI entries above, adapter "nvidia turing" in all 12 runs

Phase 2: Host-capability layer — 3/3

  • A red-green spec scans packages/core/src and fails on any read of a global a dedicated worker lacks (type check against the webworker lib, value positions only) outside the host-capability layer packages/core/src/platform.ts. It is red on the current reads and green after the move. proof: pnpm exec vitest run scripts/__tests__/check-core-boundary.spec.ts. Result: red with 19 reads in 5 files, green 32/32 after the move to hostWindow/hostDocument/hostResizeObserver; pnpm exec tsx scripts/check-core-boundary.ts passes; 11 related core and boundary spec files 248/248; core typecheck exit 0
  • The native host runs core unmodified through the same layer, with no dynamic import() in core. proof: pnpm exec vitest run scripts/__tests__/check-core-boundary.spec.ts; pnpm native:verify:desktop. Result: 32/32; native:verify:desktop exit 0 ("desktop core gate passed: 300 frames, 1280x720", UI frame gate passed) after pnpm native:build in this worktree
  • The starter plays through a loopback proxy (main thread talks to the layer over a message channel in the same thread) with unchanged behaviour. proof: packages/core/__tests__/ui-bridge-inbound.spec.ts; a pause round-trip scenario (click button[aria-pressed], hud asserts pause -> resume) on a scaffolded starter at / and at /?tnUiLoopback=1; the starter's own pnpm test (test:templates skips the starter: it runs in the golden-path lane). Result: spec 6/6; round trip exit 0 on both URLs; a Playwright probe reads __tnUiLoopback false at / and true with the flag; kit movement, survives, production-readiness pass. play fails performance.maxFrameMsP95 on GPU time on this host, and the scaffold from before this change fails it too (before 2/2 runs at GPU p95 20.3/17.3 ms, after 1/2 at 18.1 ms; CPU about 7.5 ms in both)

Phase 3: Worker mode and v5 KPI — 5/6

  • Behind a flag, without COOP/COEP, the starter boots in a worker on an OffscreenCanvas; input, pointer lock and audio forward from the main thread, and the HUD takes one state snapshot per frame and sends intents back. proof: pnpm test:templates with the flag, starter scenario green. Result: the flag became the default page (owner, Decisions); a scaffolded starter at / (worker), --browser-recipe webgpu --headed, RTX 2080 ("turing"), the worktree's runner at 7fe753b: movement, survives, play, production-readiness all exit 0; play reports gameThread: "worker"
  • The playtest harness captures the worker canvas (screenshot not blank, frame counter advances). proof: pnpm test:templates with the flag; node packages/playtest/dist/runner/cli.js doctor --url <url> --text. Result: the same runs capture non-blank frames with data-engine provenance from the page canvas (forwarded attribute) and pass their visual and tone assertions; 9 of the 10 starter and shooter scenarios exit 0 on /
  • Main-thread busy falls below the target set in phase 1 (first target: below 0.5x of the main-thread arm) and p50 is no worse, under the same stated load. proof: profile:wasm-page worker URL against the main-thread --control URL, three runs per arm. Result: --url <kit>/ --control <kit>/main.html --cpu-work --vsync --load 0, RTX 2080, host load1 7 to 24, three pairs per kit. Shooter: busy 4.5 / 4.5 / 4.5% against 32.0 / 34.8 / 34.5%; presented p50 42.8 / 45.7 / 34.2 ms against 85.7 / 71.6 / 78.5 ms; p95 69 / 57 / 63 ms against 146 / 130 / 154 ms. Starter: busy 1.4 / 1.3 / 1.5% against 24.8 / 23.0 / 23.7%; presented p50 33.4 / 33.8 / 33.3 ms against 33.6 / 33.4 / 44.7 ms. At --load 24 (load1 38 to 71, unmatched arms) the shooter's worker busy stayed at 0.5 to 1.4% against 44 to 47%, and p50 was lower in 2 of 3 pairs: frame time there is load, per the AFK rule
  • A fresh visual judge subagent returns IMPROVEMENT or NEUTRAL on worker against main-thread frames, at least two runs per arm, --browser-recipe webgpu, adapter.info checked. proof: the verdict in this box and the images on the PR. Result: OVERALL NEUTRAL (fresh Opus judge, 6 sheets: starter after and tone-3, shooter after, runs 1 and 2 per arm, / vs /main.html, --headed, all 8 runs architecture: "turing"). Exposure, tone, shadows, AA, resolution and HUD match; the only differences are gameplay state (starter position 57 vs 59, 60 vs 61). Images: branch prd-752-screenshots at dcf2360
  • The shooter kit plays through the worker with the same checks. proof: pnpm test:templates with the flag on the shooter scenario
  • The shooter's frame on the worker page has no update spikes from scene ray queries: the engine raycast reads a cached triangle tree with hits identical to the brute-force scan. proof: tn-native-engine-raycast-test and tn-native-engine-animation-skeleton-test raycast_posed_skin in packages/runtime-native/build/tn-engine-core (-DTN_ENGINE_CORE_ONLY=ON); the shooter's TN_FRAME_BUDGET in Chrome on the RTX 2080. Result: 2,048 oracle rays 0 differ; 1,000 rays on a 20k-triangle mesh 8,471 ms -> 6.0 ms; a focused ray tests 4 of 20,000 triangles; posed skin hits 1/1 on both paths. Shooter on / (worker), 8 windows of 5 s: update p95 about 90 ms -> 5 to 10 ms, presented p95 100 ms -> 16.8 ms, 28 to 37 fps -> 56 to 60 fps

Phase 4: Wasm threads and gate — 0/4

  • A TN_WEB_THREADS build costs at most 2% on one thread against the non-threaded build. proof: profile:wasm-page A/B, three runs per arm
  • Without cross-origin isolation the engine reports threads: off and runs unchanged; with isolation (COOP/COEP) it reports the pool size. proof: native_engine_wasm_browser_backend for both isolation states
  • The job pool cuts engine frame CPU on the threaded build. proof: profile:wasm-page A/B on the shooter, threaded against not
  • Stability gate S is green on web with and without isolation headers. proof: the S commands in PRD-573

Files Cycle v5 of PRD-573 as its own PRD (worker mode, Wasm threads),
records the owner's 2026-10-26 worker HUD decision and clears the
PRD-573 blocker.
@jonit-dev jonit-dev added the prd:0% PRD implementation has no verified phase yet label Oct 27, 2026
Keeps N busy-loop processes alive for the whole page run and stamps the
count on the report, so a loaded-host baseline is reproducible.
…baseline (PRD-752)

- check-core-boundary type-checks packages/core/src against the webworker
  lib and fails on any value read of a global a worker lacks outside
  platform.ts (red on 19 reads in 5 files, green after the move).
- platform.ts gains hostWindow/hostDocument/hostResizeObserver; audio,
  game, hot, renderer and viewport use them. The renderer now throws
  TN_RENDERER_NO_CANVAS instead of a ReferenceError with no document.
- profile:wasm-page reports main-thread busy percent.
- PRD-752 phase 1 baseline and v5 KPIs recorded; owner scope decision.
@jonit-dev jonit-dev added prd:25% PRD phase 1 verified and removed prd:0% PRD implementation has no verified phase yet labels Oct 27, 2026
…752)

Both ends stay in one realm but every frame arrives a task later, the
hop a game in a worker adds. Installed as the host globals by whichever
end connects first, so the game and the HUD do not change.
…named WebGPU failure (PRD-752)

A scratch starter booted in a worker on an OffscreenCanvas on the Wasm engine.
These are the engine-layer fixes that spike needed:

- core: defineGame takes a platform source a worker host installs
  (__tnGamePlatform) when the portable entry names none.
- core: TN_NO_RENDERER names the WebGPU failure instead of swallowing it.
- create-threenative: worker bundles get the engine resolver and ES output.
- runtime-native: the web module is built for web and worker environments.
- three-native: the renderer tolerates an OffscreenCanvas (no setAttribute).
- PRD: the spike result and the phase 3 design.
… an OffscreenCanvas (PRD-752)

serveWorkerGame (worker) and startWorkerGame (page) speak a small message
contract: a ready handshake before the canvas transfer, the platform source
the game boots on, page-relative URLs, forwarded keyboard/pointer/wheel input
in canvas coordinates, the UI bridge on its own port, and the playtest bridge
mirrored onto the page as async calls. 9 linked-pair tests.
…d (PRD-752)

A worker has no Web Audio API. The worker gets a stand-in AudioContext whose
nodes are handles; calls and property writes cross a MessagePort in one batch
per task and run on the page's real graph, so three's audio classes and
AudioBus work unchanged. AudioBus finds its gesture target in a worker, canvas
gestures reach it as they would by bubbling, and a page gesture resumes the
page's context. 7 remote-audio tests, 2 worker-host tests, 1 platform test.
… module loads (PRD-752)

The starter's game.ts calls game.ui.onIntent at top level, so in a worker the
game end connected before the page's port arrived and stayed on the in-process
broker: a blank HUD. @threenative/core/worker now installs the game end's host
globals when it loads in a worker, before the game module evaluates, and holds
frames each way until init attaches the port. 3 tests; headed GPU probe shows
the full HUD on the worker page.
src/worker.ts serves the unchanged game.ts; src/worker-page.ts keeps the
canvas, input and the same GameUi on the page. index.html stays the default,
so one build carries both arms of the busy-page A/B. Headed GPU probe: same
frame and HUD as index.html.
A worker page's requestAnimationFrame times the page, not the game. The probe
now reads frame intervals in the worker that holds the game's platform source,
else on the page, and names that thread in the result (gameThread). Main-thread
busy stays the page's CPU profile. 1 test.
…D-752)

An OffscreenCanvas cannot lock the pointer. In a worker its requestPointerLock
and exitPointerLock ask the page, which locks its own canvas within the click's
activation window and forwards each pointerlockchange with the page's verdict
as captured; a refused lock is reported as captured false. InputMap takes that
flag where no document exists, unchanged on a page. 2 tests.
A worker's requestAnimationFrame does not follow what the game presents, and
on a loaded page rAF ticks at 60 Hz while the game presents far fewer frames.
The probe now also times the game's getCurrentTexture calls (the gpuCalls
divisor) on the game's own thread, in both arms, as presented; it shims tsx's
__name in the game worker, and fails closed with a timeout. 1 test.
…sions (PRD-752)

The shooter kit gets the starter's worker page: src/worker.ts serves the
unchanged game.ts, src/worker-page.ts keeps the canvas, input and GameUi.
Headed probe on the RTX 2080: town, soldiers, rifle and the live HUD with
minimap and clock draw from the worker. The PRD logs gaps 7 to 9, the
presented-frame timing, the loaded-host rule and the measured busy share
(starter 0.9 to 1.9% on worker.html against 45.7 to 86.9% on the page).
…nner (PRD-752)

With a runner announced, the playtest plugin holds the boot until the runner
calls the bridge, so a mirror sent after start never reached the page and the
harness reported TN_PLAYTEST_BRIDGE_MISSING. The worker now posts each mirror
the moment the game installs the global. 1 test.
index.html now runs the game in a web worker; main.html is the named override
that runs it on the page thread. The worker canvas forwards setAttribute to the
page canvas, so the harness reads the renderer's data-engine for capture
provenance (was TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING). 1 test.
Mesh::raycast tested every triangle of the mesh. The shooter's merged town
meshes hold about 430k triangles, so a few soldier line-of-sight rays cost
about 90 ms in one update, which was the stutter left on the worker page.
The tree is built on first use, rebuilt when positions, index or drawRange
change, and skipped for morphed meshes and meshes that pose their vertices
(SkinnedMesh). Candidates run the unchanged hit code in triangle order, so
results stay identical: 2,048 oracle rays, 0 differ. 1,000 rays on a 20k
mesh: 8,471 ms -> 6.0 ms; a focused ray tests 4 of 20,000 triangles.
… and profiler (PRD-752)

Worker: startup errors reject ready, stop removes the canvas and releases
pointer lock and capture, pointerlockerror, 0x0 clamp, HUD wheel stays on
the HUD, motion coalesced per page frame, one HUD snapshot in flight,
gamepads forwarded, a lazy playtest mirror with _clockEpoch.
Audio: the clock freezes while suspended, fluent AudioParam calls reuse
their handle, ended one-shots release theirs, numeric param defaults,
control ops settle from the page. Profiler: probes run in the game realm.
Each item has a red-green spec.
… A/Bs (PRD-752)

The default flags unthrottle the page's requestAnimationFrame, not a worker's
OffscreenCanvas frames, so a worker arm read 37 ms frames against 10 ms on the
page. With vsync on, the shooter worker ran 16.7 ms frames against 33.3 ms on
the page thread. Also types the memory probe's realm (Page | Worker union).
…PRD-752)

Under a private Xvfb the runner adds --disable-frame-rate-limit so a page
game's frames escape the compositor's cadence. The same flag throttles a
worker's OffscreenCanvas frames (shooter: 36 ms presented with it, 16.7 ms
without). The runner now sees a worker game once the page opens and relaunches
once with that thread's flags; the report says gameThread: worker.
…quests (PRD-752)

A wall-clock advance answers a one-tick request with the ticks the host's pump
ran: two per frame at 30 fps on a 60 Hz step. advanceFixedStep counted
requests, so a 169-tick key hold ran about 338 ticks and the starter's player
overshot into the gap on the worker page. The bridge client now returns the
reported count and the loop counts it; a fixed-step bridge still runs exactly
one tick per request. Starter play passes on / (worker) and /main.html.
@jonit-dev jonit-dev added prd:50% PRD half the phases verified and removed prd:25% PRD phase 1 verified labels Oct 27, 2026
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Visual judge: NEUTRAL. The worker page (/) renders the same as the page-thread page (/main.html).

A fresh judge compared 6 frames (two runs per arm, RTX 2080, headed WebGPU). Exposure, tone, shadows, anti-aliasing, resolution and the HUD match. The only differences are gameplay state: the starter's character is 1 to 2 position units apart at the capture tick.

Frame Left: page thread, right: worker
Starter, end of run, run 1
Starter, end of run, run 2
Starter, spawn (tone check), run 1
Shooter, end of run, run 1
Shooter, end of run, run 2

All sheets: branch prd-752-screenshots at dcf2360.

@jonit-dev

Copy link
Copy Markdown
Contributor Author

From the PRD-752 lane (#576), for the #574 coordinator. #576 is ready for you to merge into #574. It stays a draft, and nothing went to develop.

Head SHA: 6639da5 (branch prd-573-v5-worker-mode).

Merge check: git merge-tree origin/pr563-followup 6639da583 conflicts only in packages/create-threenative/tests/scaffold.spec.ts (your kit hashes, yours to re-pin). The other 13 shared files auto-merge.

Gates run, with real results:

  • Unit specs: playtest runner.spec plus related 220/220 (includes the new live-clock hold red-green), worker.spec 33/33, worker-audio.spec 17/17, profile-wasm-page.spec 21/21, browser-input.spec 9/9; tsc on @threenative/playtest clean; pre-push ci:fast pass.
  • Starter kit on / (worker, default page), headed WebGPU, RTX 2080: movement, survives, play, production-readiness all exit 0.
  • Shooter kit on /: survives, input-control, five-soldiers, hold-to-fire, shot-tracks-crosshair exit 0.
  • Visual judge (fresh Opus, 2 runs per arm, / vs /main.html): NEUTRAL. Images are on perf(web): PRD-752 run web games in a worker so the page cannot stall them #576.
  • Not run: full pnpm test, pnpm test:templates, CI (draft PR).

One risk for your CI, decision yours: the shooter's performance.playtest.json fails on / under the runner's private Xvfb (minFps 15-20, cost p95 17.7 ms). Xvfb holds a worker's OffscreenCanvas frames to about 25/s at 1920x1080, and no scheduler or Chrome flag lifts it (probe on #576). On the real display the worker matches the page thread (update p95 8.0 vs 7.3 ms, render p95 10.8 vs 10.4 ms). There, BOTH arms fail the kit's own budgets the same way: 796 draw calls > 320, frame p95 16.8-17.2 ms > 16.7. Under Xvfb both arms skip most draws (median draw calls per sample 0), which is why /main.html looked green. Options: (1) run that scenario with --url .../main.html, (2) run it on a real-display lane, (3) a harness-wide virtual-display rule like perf's TN_PERF_VIRTUAL_DISPLAY. I did not soften the evaluator: packages/playtest/AGENTS.md forbids it.

Files to keep (all from #576): packages/core/src/{worker,worker-audio,platform,ui-bridge,game,input,audio,hot,renderer,viewport}.ts; templates/{starter,shooter}/{index.html,main.html,src/worker.ts,src/worker-page.ts,vite.config.ts,AGENTS.md}; packages/playtest/src/runner/{runner,steps,bridgeClient,browser,shared,androidRunner}.ts; the BVH raycast in packages/runtime-native/src/engine/scene/raycaster.*; scripts/profile-wasm-page.ts (--vsync).

Waste found while profiling, for the "waste first" order (not done here): tn_context::share(std::string, ...) is 8.7% of Midway's main thread, and projection::contentClassOf is 5.1% and looks cacheable.

Phase 4 (Wasm threads) stays open on PRD-752: it waits on PRD-573 v3.1's job pool. Measured: a -pthread build costs 1-5% on one thread; the Midway job-pool ceiling is about 19% at 4 threads.

@jonit-dev

Copy link
Copy Markdown
Contributor Author

Correction to the ready report above: the threaded-build cost is about 1.5-3% on one thread (Midway, 5 runs per arm, both arms from this PR's sources: median render p50 16.65 vs 17.11 ms). The earlier 1-5% A/B is void: its single-thread arm came from a stale build/web pair built from another tree. Head is now c4fe5ef.

@jonit-dev

Copy link
Copy Markdown
Contributor Author

Folded into #574 (branch pr563-followup, merge e2462de, head c4fe5ef). On the merged tree: repo typecheck 0; scaffold spec 74/74 (starter + shooter hashes re-pinned); worker, ui, animation, HUD-audit and quality-rules specs 89/89; web Wasm rebuilt from the merged C++ (stamp check passes). Worker shooter, headed WebGPU: hands-authored-rate and breakable-shatters exit 0; HUD sampled every 25 ms from load showed 0 of 320 samples under the loading cover. Your performance.playtest.json Xvfb note is logged on #574 and gets rerun after the auto-batching lane lands. Closing this PR as folded into #574.

@jonit-dev jonit-dev closed this Oct 27, 2026
jonit-dev added a commit that referenced this pull request Oct 27, 2026
…erial merge by default (PRD-748)

Coordinator takeover: the lane stalled after its two commits. Conflict in the shooter's Play.ts:
kept the sight index from HEAD and dropped collapseTown, which AutoBatch replaces. Shooter hash
re-pinned. core/src/worker.ts snapshotInFlight -> snapshotPending: the visual-concerns scan in
constraints.spec matched 'light' inside 'InFlight' (red since the #576 merge, not this lane).
All core specs 276 files, 4309 tests pass; typecheck 0; scaffold 74/74.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:50% PRD half the phases verified

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant