Skip to content

Web games open about twice as fast on a repeat visit (PRD-860) - #641

Queued
jonit-dev wants to merge 25 commits into
developfrom
perf/prd-860-fast-load
Queued

jonit-dev wants to merge 25 commits into
developfrom
perf/prd-860-fast-load

Conversation

@jonit-dev

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

Copy link
Copy Markdown
Contributor

What is this PR about?

A web game should open fast on the first visit and almost at once on the next one. On develop the collectathon kit is ready in 3.90 s on a first visit and 2.51 s on a repeat visit (20 Mbit/s, 40 ms, Intel Iris Xe laptop, Brotli served). This PR adds a load benchmark (pnpm bench:load) and cuts startup work until the benchmark shows it. Each change stays only when a paired laptop run shows a gain.

Why does it matter?

Players on the web leave while a game loads. Most of the gains are in the engine and the web build, so every game built with ThreeNative gets them with no code change.

Where it stands (laptop medians, develop and this branch paired in one window, three rounds each)

develop now goal
collectathon first visit 3.71-3.92 s 2.48-2.58 s (-33%) ≤ 2.7 s ✅
collectathon repeat 2.39-2.52 s 1.27-1.32 s (-48%) ≤ 1.2 s ❌ 70-115 ms over
minimal first visit (control) 3.45-3.55 s 2.89-3.06 s no slower ✅
minimal repeat (control) 2.16-2.25 s 1.42-1.51 s no slower ✅

The owner asked to merge with AC-1 open. The PRD stays PARTIAL.

What landed

  • Engine: generated content (cloud noise, sky pixels) is stored per build in CacheStorage (ctx.assets.memo); hashed assets are immutable.
  • Engine: InstancedMesh.setMatrixAt/setColorAt write the instance array and send it once before the next engine call, not one engine call an instance (-130 ms on a repeat, paired).
  • Engine: a matrix over shared engine memory is a Float64Array view, not a Proxy that writes each element (49 ms of the collectathon load).
  • Engine: Rapier is its own chunk and downloads beside the engine boot (index.js 864 → 250 KB Brotli).
  • Web build: a first visit starts the engine Wasm with the page and Rapier after it. A repeat visit skips the preload, which would lose V8's code cache.
  • Engine: the PMREM bake takes 4 GPU round trips, not 10; the reach cull walks vertices, not a Box3.
  • Engine: rapier() and recast() initialize while the scene loads; enter() still waits until ctx.physics is set (start gate). A body built in load() throws TN_PHYSICS_NOT_READY (-64 to -179 ms first visit, paired).
  • Kit: collectathon paints its sky and ripples and plants its island with byte-identical output, faster (-150 to -340 ms first visit, paired; sky, ripple and course hashes unchanged).
  • Kits: one scattering table per run; collectathon foliage composes plain matrices and paints its sky while textures download.

Measured and dropped (numbers in the PRD): bake beside the compile, a JS pipeline precache, Rapier as a streamed .wasm alone, two readiness gate changes, an always-on Wasm preload.

Checklist

Phase 1 — benchmark

  • pnpm bench:load reports first-visit and repeat readyMs
  • develop baseline recorded (collectathon + minimal control)

Phase 2 — a repeat run does not redo work

  • hashed assets ship Cache-Control: immutable
  • ctx.assets.memo stores generated arrays; collectathon builds through it (kit playtests on the merged tree: 37 pass; only the no-hitch GPU p95 rows fail, as on develop for collectathon and minimal; racing 18.63 ms has no develop control)
  • the hot paths a repeat run walks cost less

Phase 3 — a first visit downloads less

  • Rapier downloads beside the engine boot
  • the engine Wasm starts with the page on a first visit
  • collectathon paints its sky while its textures download

Acceptance

  • AC-1 repeat ≤ 1.2 s (1.27-1.32 s, merged open by the owner)
  • AC-2 first visit ≤ 2.7 s (2.48-2.58 s)
  • AC-3 minimal control no slower
  • AC-4 visual judge NEUTRAL or better (images in the comments)

PRD: docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md

@jonit-dev jonit-dev added the prd:0% PRD implementation has no verified phase yet label Nov 8, 2026
Every bake group waits for the GPU's completion, about two frames, and a held
startup draws no world until the bake ends: ten groups held the minimal kit
about 350 ms on an Intel iGPU. Roughness-0 packets cost one sample, as the
shader reads; the per-group budget doubles after a group the GPU finished by
the next call, up to 4x the heaviest packet (the old largest submission), and
falls back after a slow group. renderer_lifetime_test environment_bake_round_trips:
11 calls before, 5 after. The bench writes every TN_ marker beside a profile.
…nd add browser switches

TN_LOAD_BENCH_TRACE=1 writes a whole-browser trace beside --cpu-profile, TN_LOAD_BENCH_INIT runs a
probe script before the page, TN_LOAD_BENCH_CHROME_ARGS adds switches for one experiment.
…a ripples are built once

- buildStaticColliders reads the lowest world Y from the position attribute and the matrix's Y row,
  the exact answer Box3.setFromObject(carrier, true) gave, without its per-vertex walk (35 ms of a
  minimal repeat visit). A rotated-sphere case locks the exact cull.
- ctx.assets.memo accepts a build typed by an interface (the kits' ICloudTextures failed tsc).
- The six atmosphere kits build the multiple-scattering table once a run, not once a refresh.
- The collectathon keeps its sea ripple pixels in the sky's memo entry (118 ms a run).
…es without engine or array churn

- Foliage composes instance matrices with Matrix4 and a YXZ Euler instead of a scratch Object3D,
  which crossed into the engine five times a placement (~90 ms of a load). Same matrix: 0 difference
  over 1000 random placements; only setMatrixAt crosses.
- pathDistance loops over the path segments instead of building arrays and spreading them into
  Math.min: same minimum (0 mismatches over 200 000 points), 5x faster, ~65 ms of a load.
…not a trapped mirror

Every engine Matrix3/Matrix4 answered 'elements' with a Proxy over a mirrored array that wrote each
set element through writeDouble: three's compose wrote 16 trapped elements a call, 49 ms of a
collectathon load on a laptop (~6k instance matrices). When engine memory is a SharedArrayBuffer
(the threads build) it never detaches as it grows, so 'elements' is now a Float64Array view onto
the matrix's 16 doubles: one store a write, live reads, no call. Unshared memory keeps the mirror.
…try chunk

The web backend imported @dimforge/rapier3d-compat statically, so its ~2 MB base64 module (~660 KB
Brotli) sat inside index.js: a first visit downloaded it before any engine code ran. The module is
now a dynamic import started when physics is imported and awaited only by initialize(), so the
bundler splits it into its own chunk that downloads while the engine's Wasm compiles. A caller that
inits Rapier itself and hands over a World still works: the module binds when the import resolves.
…ures download

A first visit painted the sky (~600 ms on a laptop) only after loadSurfaces resolved, and the main
thread sat ~50% idle for ~500 ms waiting on those downloads. Both now run under one Promise.all.
The load bench can profile the first visit (TN_LOAD_BENCH_PROFILE=first).
…n Rapier

A first visit's waterfall showed the network idle until the entry script ran: the engine glue asked
for its 1 MB Wasm at ~330 ms (done ~800 ms), and Rapier's chunk waited until ~900 ms for the engine
to boot. The web build now writes a head script that preloads the Wasm the page will run (the
threads build only when the page is isolated) and, once it lands, modulepreloads Rapier, so each
download gets the whole link in turn. The load bench can dump resource timing
(TN_LOAD_BENCH_RESOURCES=1).
…ser has not fetched

c12's preload made a repeat visit ~150-200 ms slower on the laptop: a preloaded response loses V8's
compiled-code cache, so the 4 MB module recompiled every visit. The head script now records the
Wasm URL when its preload lands and skips the preload (and Rapier's) when that URL is recorded, so a
repeat visit fetches through the HTTP cache as before. Storage that throws counts as unrecorded.
@jonit-dev jonit-dev added prd:75% PRD phases in, acceptance open and removed prd:0% PRD implementation has no verified phase yet labels Nov 9, 2026
@jonit-dev

Copy link
Copy Markdown
Contributor Author

AC-4 visual judge: NEUTRAL (fresh read-only subagent). Ready frame at the cover lift, laptop Intel Iris Xe (intel/gen-12lp), headed WebGPU, 20 Mbit/s and 40 ms. Two runs per arm; left = develop 4dd50f4c2 packages, right = this PR at 49c1ae3cb. The judge saw only animation-phase differences (windmill blades, coin spin, grass sway), the same as between two develop runs.

run 1 first visit run 1 repeat
run 2 first visit run 2 repeat

…develop; status PARTIAL with the two open ACs
…er, same bytes

The sRGB byte comes from 255 exact steps, not a power a texel; azimuth terms and the ripple lattice are computed once; path and pickup distances skip what is clearly out of reach and keep Math.hypot at the edge. skyPixels, ripplePixels and the whole built course hash the same before and after.
rapier() and recast() no longer await their Wasm in setup when the runtime offers holdStart: each leaves a start gate, so load() runs while they initialize and enter() still waits until ctx.physics is set. A physics node built before then throws TN_PHYSICS_NOT_READY, not the deprecated-world message. With no holdStart, setup awaits as before.
…load

# Conflicts:
#	packages/create-threenative/__tests__/scaffold.spec.ts
@jonit-dev
jonit-dev marked this pull request as ready for review November 9, 2026 03:41
@jonit-dev
jonit-dev added this pull request to the merge queue Nov 9, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:75% PRD phases in, acceptance open

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant