Skip to content

[feat] 33 L1: scenes load without freezing the window, with visible progress - #261

Merged
AlexZ005 merged 15 commits into
feat/1.19from
feat/33-scene-load
Oct 2, 2026
Merged

AlexZ005 merged 15 commits into
feat/1.19from
feat/33-scene-load

Conversation

@AlexZ005

@AlexZ005 AlexZ005 commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

33 L1 — scenes load without freezing the window, with visible progress

The user, on a phone: pressing Restore after reloading with Castle Courtyard open hung the whole window, and opening other scenes froze it too.

Measured on a CPU ×6 phone (412×915 isMobile, real GPU, scripts/scene-load-trace.cjs)

load before: longest task / window frozen after: longest task
Castle Courtyard open 525 ms / 2.1 s 123 ms
Castle Courtyard Restore 89 468 ms / 91 s 176 ms
Forest Clearing open 440 ms / 2.2 s 117 ms
Forest Clearing Restore no Restore offered (autosave over the 50 MB cap) 200 ms
Tavern Interior open 536 ms / 2.1 s 99 ms
Tavern Interior Restore 84 301 ms / 85 s 188 ms

Castle autosave snapshot: 51 MB → ~1 MB.

What changed

  • sceneLoader.js (a leaf): a time-sliced build (10 ms slices, one shared clock across loops and queued work), ONE load job at a time (a new scene supersedes a running load cleanly), Cancel hooks.
  • The load bar (SceneLoadBar.svelte, non-modal, in the toast stack's first slot): "Loading Castle Courtyard — 34 / 182 objects" / "Restoring your last session — …", Cancel while building and while models arrive.
  • Restore: a wire copy only when a peer is connected (the 89 s task was toJSON() of every child, textures as PNG data URLs, for nobody); sliced with progress; Cancel clears and re-offers the prompt.
  • The autosave writes pristine kit pieces as stubs (reverses 30c's "autosave stays full"; hollowed for the GLTF export and put back on the exporter's afterParse hook). An edited piece is still written in full.
  • No compile or link in the first frames: environment first, a bounded viewport hold (≤ 2 s, the DOM keeps running), packRefs.warmPrograms (batched twins, canvas + render-target variants, each program's first use off-frame), and the composer's pass shaders through a seam Outline registers; warm-ups bounded so a slow driver never stalls a load.
  • Kit pieces: refill attach sliced, textures uploaded one per slice, grey placeholder blocks while a pack file is on its way (for references that carry the new packRef.box).
  • The object list's plain tree mounts in chunks; the quality governor ignores a load's frames; the debounced autosave waits out a load; "Session loaded" waits for the models.

Verification

  • New suite scene-load (CPU ×6 phone, pack files on a 0.6–2.7 s link): the bar counts up; no long task > 200 ms on open, on Restore (via the real toast button), or building 1 000 ordinary meshes; the logo menu opens mid-load (66 ms vs 26 idle); another scene supersedes cleanly; Cancel takes the load back; the stub autosave; the edited piece survives. ALL PASS.
  • Counterfactuals (guard removed → red): autosave stubs off → 5.2 + 6.3 (51 MB, a 1 212 ms restore task); chunked object list off → 1.4 + 6.3 (307 / 328 ms); time-slicing off → 1.4 + 7.1 (779 ms).
  • Held suites green: settings-autorestore-colors, templates-modal, sdk-game-seams, pack-refs, sessions, level-templates.
  • svelte-check 332/47 (one below the 333 baseline), vitest 378/378, check-storage clean, debug hook 220/220/220.

Owed on a device: a real phone's feel (Restore + opening each level), the held viewport during a load as perceived on screen, the bar in non-dark themes.

🤖 Generated with Claude Code

AlexZ005 and others added 15 commits October 1, 2026 20:44
…ile per long task)

- scripts/scene-load-trace.cjs: a fresh phone context per scene (412x915 isMobile @2.6,
  real GPU, CPU throttle 6), the Templates open path and autosave -> reload -> Restore,
  each reporting wall time, PerformanceObserver long tasks, the longest rAF gap, the CPU
  profile's top self time, app call chains, and the three longest tasks broken down.
- Baseline (c7018be): Castle Courtyard Restore = ONE 89 s task (91 s frozen), open 525 ms
  tasks / 2.1 s frozen; Tavern Restore 84 s; Forest gets NO Restore at all (its autosave
  is over the 50 MB cap). Table in the 33-scene-load handover.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ces warmed and placeheld

- sceneLoader.js (leaf): slice(job) yields when the 10 ms budget is spent and throws
  LoadCancelled at a clean point; schedule(fn) runs a burst of continuations a slice at a
  time; ONE load job (sceneLoad store) - a new load supersedes the running one; cancel
  hooks; throttledPoke (250 ms) for the build loop.
- sessions.applySession: the object loop is time-sliced with progress, kit stubs refill at
  once (counted when they land), every await re-checks the job, Cancel clears what the
  load added (replicated like the load), a load interrupting one mid-build writes no
  "Backup before" of the half scene.
- packRefs: a refill's clone/attach runs through schedule() (every copy of a piece used to
  attach in the one task that resolved its template); warmTemplate uploads textures one
  per slice and compileAsync's the programs against the live scene before any copy draws;
  refills poke at most every 200 ms; fingerprint checksums cached per geometry by buffer
  versions; packRef.box (root-frame bounds, additive) drawn as ONE scene-root InstancedMesh
  of grey placeholder blocks while a piece is on its way.
- App.svelte debug hook: + sceneLoader (220/220/220).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s", Cancel

- SceneLoadBar.svelte in the toast stack's first slot (the spectator banner's spot, so
  arriving toasts never shove it): name, count, a progress bar (indeterminate while
  preparing/reading), Cancel while objects are being BUILT; once only kit models are still
  arriving it keeps counting without Cancel. Non-modal, shown after 250 ms so a small
  scene does not flash it. Ids #scene-load-bar (data-done/total/phase), #scene-load-cancel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ubs, sliced restore

- autosave writes pristine kit pieces as STUBS (packRefs.parkPackPieces: hollowed for the
  GLTF export and put back on the exporter's afterParse hook, before the async encode can
  let a frame draw a hollow castle). Castle Courtyard's snapshot 51 MB -> 86 KB; Forest
  Clearing gets crash recovery again (it was over the 50 MB cap).
- applyRestore: time-sliced with progress ("Restoring your last session"), the wire copy
  built only when a peer is connected (stubs for pristine pieces) - every child's toJSON
  re-encoded its textures as PNG data URLs for nobody, the 89 s task; Cancel clears and
  re-offers the prompt; superseded by another scene cleanly.
- The debounced autosave waits out a load (saveNow does not).
- tests/e2e/scene-load.test.cjs (CPU x6 phone): open + restore with no long task > 200 ms,
  the bar counts, UI answers mid-load, supersede, cancel, stub snapshot, edited piece kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…oser programs, sky first

- Controls.svelte: under the virtualisation threshold the object tree MOUNTS IN CHUNKS (40, then
  16 per frame) — a load handed it ~180 rows in one poke, the longest task left (434-565 ms on a
  CPU x6 phone). svelte-check 333 -> 332 (the inline filter callback was a counted error).
- packRefs.warmTemplate compiles BOTH program variants: the canvas one and the render-target one
  the post-processing composer draws with (three keys a program by tone mapping + output colour
  space) — the composer's programs were linking inside the first frame (260 ms).
- sessions/autosave: the scene's environment is restored BEFORE the objects, so pieces warming
  as their packs land compile for this scene's lights, and the right sky shows at once.
- Cancel stays offered while kit models are still arriving; the bar reads "n / N objects" in
  both phases.
- scene-load suite: a fresh page per measured load (parsed pack files are cached in memory, so
  a same-page re-open was instant), pack files routed with a staggered 0.6-2.7 s delay,
  store-sampled count-up, Cancel hit-tested, the menu timed against an idle reference.
- scene-load-trace: PACK_DELAY=1 for the same delay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d frame hold

- packRefs.warmPrograms(root): one compile per mesh per slice, canvas AND render-target variants,
  then each program's FIRST USE (getUniforms) one per slice — measured: the first frames created
  no program the warm-up had not, the cost was three's first-use link WAIT (no parallel-compile
  extension) plus the environment change re-keying every existing material. warmTemplate uses it.
- sceneLoader.holdFrames()/releaseFrames()/framesHeld() (bounded by HOLD_MAX_MS = 2 s):
  Outline.svelte skips its render while a scene load re-links programs and builds — the canvas
  keeps its last frame, the DOM (bar, menus) carries on. sessions + autosave hold from the
  environment restore until the build is in and warmed.
- SceneLoadBar enters with a CSS animation, not svelte's fly (getComputedStyle on mount forced a
  whole-document layout inside the load's longest task).
- Suite (CPU x6 phone, slow pack links): open max 220 ms (from 525), restore max 220 ms (from
  89 468); menu opens in 102 ms mid-load vs 24 ms idle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…empty frames compile it

- sessions/autosave: environment AND post stack restored before the objects, then nextFrames(2):
  the composite shader and the helpers re-keyed by the new fog/lights compile on a frame with
  almost nothing to draw, not on the first frame of the full level. Then the hold + sliced warm.
- sceneLoader.nextFrames(n) (rAF, 100 ms fallback where rAF does not run).
- Suite: restore max 166 ms (PASS, from 89 468); open max 204 ms (from 525).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… loaded toast waits

- sessions/autosave: environment -> two frames -> post stack -> two frames: each re-keys
  programs on its own near-empty frame (measured ~60 + ~40 ms of links together).
- qualityGovernor ignores frames while a scene load runs (desktop and XR): it answered a
  load's slow frames by dropping the pixel ratio, and that setSize was the longest task of a
  phone Restore (432 ms at CPU x6, tavern).
- 'Session loaded' waits for the kit models (and says nothing for a cancelled/superseded
  load): its fly entrance forced a whole-document layout mid-load.
- CLAUDE.md: the sceneLoader architecture entry.
- Suite (CPU x6 phone, slow links): open max 197 ms, restore max 148 ms, menu 66 ms mid-load
  (26 idle) — 28/28.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… checksum cache

- sessions/autosave: environment -> hold + sliced warmPrograms of the (empty) scene -> post
  stack -> release + two frames for the composer's own shaders (compile() cannot reach them)
  -> hold for the build. The env re-key no longer links inside a frame.
- packRefs: the checksum cache keyed by buffer versions is GONE — pack-refs caught an
  in-place vertex move without needsUpdate reading as pristine, which would save an edited
  piece as a stub (the edit lost on reload). Counterfactual = that pack-refs check, red with
  the cache, green without.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…oader.within)

templates-modal (software GL) went red after the whole-scene warm became awaited before the
build: a software link is hundreds of ms per program, so the scene stayed empty past the
check's wait. The warm carries on in the background; the load never waits on the driver.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… ordinary-objects fixture

- Outline.svelte registers warmComposerPasses through sceneLoader.registerComposerWarm: every
  pass (and each effect's internal passes) compiled against the target it draws into, one per
  slice, then first use per slice. The load paths call it (bounded by within) while the
  viewport is held, after restoring the post stack.
- scene-load section 7 builds 1200 ordinary meshes (300 measured 166 ms even unsliced — too
  small to prove the slicing guard).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ompiles in batches of twins

- sceneLoader.pump keeps the SHARED slice clock: a loop awaiting one scheduled item at a time
  started a fresh pump per item through microtasks, and the whole loop ran as ONE task —
  measured 881 ms of program warm-up on a 1200-mesh scene, and the source of several earlier
  residual tasks inside pump.
- packRefs.warmPrograms: compile(object, camera, scene) walks the whole scene for lights per
  call, so per-mesh compiles were quadratic. Plain meshes compile in batches of 48 throwaway
  twins sharing their geometry + material (which prepares the real material); skinned /
  instanced / other drawables compile as themselves.
- scene-load-trace: SLUGS=many-boxes (MANY=1200) — a synthetic scene of ordinary meshes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…pheres)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@AlexZ005
AlexZ005 merged commit 1b04cf5 into feat/1.19 Oct 2, 2026
4 checks passed
@AlexZ005
AlexZ005 deleted the feat/33-scene-load branch October 2, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant