Skip to content

Streaming without holes: texture levels survive a device loss in the world cache and yield to the pages (#745) - #777

Merged
pasquelin merged 13 commits into
developfrom
745-level-store
Sep 26, 2026
Merged

pasquelin merged 13 commits into
developfrom
745-level-store

Conversation

@pasquelin

@pasquelin pasquelin commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #745

What changed

  • Levels kept across a device loss. The decoded baked texture levels now live in one store
    owned by the world's page cache (PageCache.levels, texture/levelStore.ts). A lost device's
    session is reopened on that cache, so its tiles are cut again from the levels held and no level
    is fetched again. The store keeps one cook's levels (key): another scene's leave as its reader
    is made, and a read landing after a scene switch or after the world is gone keeps nothing (the
    reader names its cook, read.key). A session with no world holds its levels in the page cache
    its streamer reads through (streamer.pageCache), within its own CPU total, cleared as the
    streamer is disposed.
  • Levels yield to the pages. The fixed 192 MiB (LEVEL_CACHE_BYTES) is gone. The cap is three
    quarters of the pages' share of world.budget.cpu (textureLevelShare, published as
    split.textureLevels, 192 MiB at the default total) and follows the total live. The levels count
    against the same total (reservedBytes). They get the room the session's reservations, the kept
    proxy and the pages a frame keeps leave them (PageCache.levelRoom).
  • Yield order, in one step (streaming/cache.ts yieldBeside). When the pages a frame keeps
    or reads do not fit, the levels yield first, least recently read first (page-cache-levels-yielded),
    then the kept proxy. Pages are walked once per evict, and only when something is held beside
    them. shedTo stops at the first entry that fits (through the shared evictOldest).
  • No re-read every frame. A level that cannot fit is not read, checked against its predicted
    bytes (requestedLevelBytes), less the reads in flight. The room is weighed once per frame. A
    level landing without room is not held. Its tile is refused, not waiting, so the frame
    settles; it stays on its coarser level until room comes back.
  • Docs: docs/SDK.md (the CPU split; the lost-device limit no longer says baked texture levels are
    read again), docs/ENGINE.md, and textureLevelCacheBytes (no longer "under a fixed budget",
    English and the 13 API reference translations).

Proof

Behaviour tests (packages/sdk-browser/src/texture/levelStore.test.ts). They fail on develop,
which has no level store in the page cache and no split.textureLevels:

  • after a device loss the texture levels are rebuilt with no level read again: 0 texture
    refetch; a read landing after another cook opens keeps nothing.
  • a level read by both sides of a device loss is counted once.
  • a small CPU total: no page a frame keeps is refused for a texture level, the levels yield first:
    every page stays, 0 evictions, 0 admission blocks, the levels yield and the proxy stays. The
    level that cannot fit is not read again over the following frames, and its request is refused.
  • the texture levels cap follows world.budget.cpu live.
  • worldBudget.test.ts: the default split publishes textureLevels.
  • backendsTextureLevels.test.ts: the baked levels are held in the cache the session's pages are read through (a session with no world).

Gates run in the worktree after merging origin/develop (reviewer): pnpm run check:changed (2608
pass, 0 fail, 1 skipped), pnpm run test:changed (2608 pass, 0 fail), pnpm run validate --group quick (pass), pnpm run validate --group typescript (pass), node scripts/check-pr-size.ts (416
hand-written lines, limit 600). No Chrome, browser
proof, test:gpu, perf or bench was run.

Local review before push

  • Simplification pass: (coder) The real simplify skill ran with 4 agents. What it found and what was fixed:

    • shedTo re-implemented evictOldest; it now uses it.
    • The bitmap byte formula was duplicated; it is now one bitmapBytes.
    • get kept a dead frame parameter; removed.
    • The room formula was written twice; it is now one PageCache.levelRoom.
    • The cook key was tracked twice (reader property and #key suffix); the store now holds one cook and ids carry no key.
    • refused grew across scenes; pending and refused reads are now tracked by the session.
    • evict walked the pages even with nothing beside them; it now skips the walk.
    • take/resize shed only to the share; they now shed to the room left beside the pages.

    Skipped: a generic list of what is held beside the pages (medium refactor), a running counter of the pages kept, removing a no-world session's own store, and an explicit context field for the store.

  • Correctness review: (coder) The real code-review --fix skill found 5 issues; 2 fixed, 3 left:

    • Fixed: take counted a level twice (and leaked its bitmap) when both sides of a device loss read it. It now keeps the held one and closes the copy (new test).
    • Fixed: a level landing between two sessions could evict the pages the next session will keep. With no session holding the cache, every page now counts as kept.
    • Left: no notice for a level skipped for lack of room (a new diagnostic is a product call); per-tile Map reinsertion in get; the page walk behind room() on a landing (both minor).
  • Simplification pass: (reviewer) The real simplify skill ran with 4 agents (reuse, simplification, efficiency, altitude). Fixed:

    • take now owns every level it is handed: it closes a stale cook's, a duplicate and one that cannot fit; the tile layer no longer closes levels or guards the key itself.
    • The level store's drop is private; the two yield notices share one yielded helper.
    • A session with no world held its levels in a second store at 192 MiB, outside any CPU total: it now uses its streamer's own page cache (streamer.pageCache), so they count in its total and yield to its pages (new test in backendsTextureLevels.test.ts).

    Skipped: levelRoom written from budgetBytes (differs when the budget clamps at 0); a generic ordered list of what is held beside the pages (medium refactor); the streaming layer importing the texture share; a running counter of held page bytes and the second page walk on each level landed (minor); get's Map reinsertion; passing the store beside the reader instead of on it.

  • Correctness review: (reviewer) The real code-review --fix skill found 7 issues; 3 fixed, and the reviewer fixed one more doc finding:

    • Fixed: a level that cannot fit left its tile waiting forever, so the frame never settled (texturesPending every frame). request now says so and sources.ts returns refused (assertion added).
    • Fixed: reads in flight were not counted against the room, so up to 6 reads that each fit but not together shed one another as they landed and were read again. They now hold their room.
    • Fixed: the tile layer took the cook key from the shared store at build time, so a session built after another scene's reader could hold its late reads under the new key. The reader now carries its key.
    • Fixed (reviewer): textureLevelCacheBytes still said "under a fixed budget", in English and 13 translations.
    • Left: with no session holding the cache, evict does not shed levels before pages (no path calls keep or lands a page then); an own cache's levels cap is 3/4 of its enlarged total (about 198 MiB, not 192 MiB); split.textureLevels and the page cache compute the share separately; textureLevelsDecoded counts a read discarded on landing.
  • Auditor list: (reviewer) Closes #745; both To-do items of the body delivered, with tests that fail on develop (no texture/levelStore.ts, no split.textureLevels, no store on the reader), on the servedPages fixture, waiting on settled() and the requests, never a fixed delay. Labels bug, geometry, 🔴 critical, in review. Docs and translations follow. No format changed. Streaming without holes: rules and objectives for geometry, memory and shadows #483 rules 1, 4 and 5 held; rule 9's second scene and the browser run are the measurer's. Open: the CTO's third To-do (texture tiles' ms ceiling) is not delivered and must be moved off Texture levels survive a device loss and yield to the pages a frame keeps #745 by the CTO before this body says Closes #745.

#483 checklist

  1. Rules: rule 1 (levels never cost a page a frame keeps) and rule 5 (a lost device is rebuilt
    without reloading, now for texture levels too) are held, with tests that fail on develop.
    Rule 4 (one memory budget): the levels' fixed 192 MiB is deleted and they count in
    world.budget.cpu. Rules 2, 3, 6, 7, 8 and 9 are not moved by this change, and rule 10 is not
    touched (nothing is rebuilt per frame; the room is weighed at most once a frame, only while a
    level waits). Rule 7, main-thread share: not measured here (see below).
  2. Closes #745: the two To-do items of the body are delivered. The CTO's To-do (texture tiles'
    own per-frame ms ceiling) is left out; see below.
  3. No second mechanism: LEVEL_CACHE_BYTES and the tile layer's own LRU (makeRoom) are deleted.
    The levels shed through the shared evictOldest and yield in the page cache's single eviction
    path.
  4. CI and conflicts: branch rebuilt on origin/develop (47bb4ef); CI is the lead's to check.
  5. No Chrome, bench or perf was run. The measurer's device-loss run is the browser proof; the
    issue gets to measure at merge.

Not proven / left out

Lead verification

Read by the geometry lead on head 6f3bead94 against #745 (split from #726). Its third To-do, the texture tiles' ms ceiling, was moved to #751 by the CTO, as the body of #745 says. The measurer proved it before this pull request opened (CTO rule of 26 Sept.).

  • To do, levels kept across a device loss: delivered in packages/sdk-browser/src/texture/levelStore.ts:17 (createTextureLevelStore, held by the world's page cache as PageCache.levels and keyed by the cook key, with keepOnly / close). The session's reader reads through it (world/session/backends.ts). Proved by levelStore.test.ts "after a device loss the texture levels are rebuilt with no level read again" and "a level read by both sides of a device loss is counted once" (fail on develop). The measurer forced a device loss on marble-bust: 0 requests after it, first frame 54 ms after the loss, the image complete.
  • To do, levels yield to the pages, with a cap derived from world.budget.cpu: delivered in streaming/cache.ts:50 (yieldBeside: the levels first, then the proxy) and residency/memoryBudget.ts (textureLevelShare, published as split.textureLevels). The fixed 192 MiB is deleted. A level that cannot fit is refused, and its tile stays on its coarser level (webgpu/tile/sources.ts:119). Proved by "a small CPU total: no page a frame keeps is refused for a texture level, the levels yield first", "the texture levels cap follows world.budget.cpu live" and backendsTextureLevels.test.ts "the baked levels are held in the cache the session's pages are read through". The measurer ran a CPU total just above its floor: admissionBlocked 0, textureTilesRefused 0, tiles coarser, none missing, no hole.
  • Proof, gates: check:changed and test:changed 2608 pass, 0 fail; validate quick and typescript pass (reviewer, 6f3bead94).
  • Proof, measurer (against b02f09ff5): 0 px at full budget on marble-bust (A/A 0); compressed-textures varies against itself on both sides. test:gpu 60/66, the 6 failures develop's known ones. The JS heap exceeds the CPU total by the same amount on base and branch (74.2 and 74.6 MB), because the page and runtime weight is outside what the total covers; this is not the branch's doing.
  • rounds: 1 (coder ↔ reviewer).
  • Before merge 1, whole promise: both To-do items are delivered; the third is moved to The runtime holds only the manifest pages its view needs, and the open world loads #751.
  • Before merge 2, tests that bite: every test fails on develop (no levelStore.ts, no split.textureLevels). They use servedPages and wait on settled requests.
  • Before merge 3, no image loss: 0 px at full budget. Under a small total the levels yield and tiles go coarser, never missing, as declared in docs/SDK.md.
  • Before merge 4, reuse: one cache holds pages, proxy and levels. The tile layer's own LRU and LEVEL_CACHE_BYTES are deleted, and the yield shares one helper with the proxy. A session with no world uses its own page cache's levels, not a second store.
  • Before merge 5, docs: docs/SDK.md (the CPU split; the lost-device limit no longer says levels are read again), docs/ENGINE.md, and textureLevelCacheBytes in sdk-core/src/texture/metricsContracts.ts with the 13 API reference translations. The body closes Texture levels survive a device loss and yield to the pages a frame keeps #745.
  • Before merge 6, measured first: not an optimisation.
  • Before merge 7, path: the moved To-do is written on Texture levels survive a device loss and yield to the pages a frame keeps #745. in review is removed and to measure set at hand-over.
  • Streaming without holes: rules and objectives for geometry, memory and shadows #483 rules / CONTRIBUTING.md §Streaming, memory and shadows: 1 (no page a frame keeps refused for a level); 2 (0 px); 4 (one cache, one yield); 5 (a fixed CPU budget; device loss rebuilt without refetch); 6 (the level share bounded by the CPU total, not by the world); 9 (the tests above, and the measurer's proof). Rules 3, 7, 8, 10 and 11 untouched.
  • Size: 416 hand-written lines.
  • CI fix after the reviewer's OK (c1d7fce56, 66609b1a3, ec4a1021a, develop merged):
    • the streamer hands over only its level store (textureLevels), not its page cache, so PageCache and Holder stay internal (public-types-audit.test.ts:115);
    • world/session/backends.ts is trimmed to 194 lines: its two preparation diagnostics share one context;
    • backend/types.ts is left to Post-processing chain: bloom, depth of field, motion blur, outline, LUT and custom passes after the resolve #349 by the CTO's word.
      There is no behaviour change. The lead re-read it as the short re-review. validate --group native 3377 pass, 0 fail; test:changed 2644 pass (coder).
  • backend/types.ts trimmed to 200 lines at the CTO's word (8d13decee): only comments are joined, with no behaviour change, re-read by the lead.

… device loss, yielding first to the pages kept (#745)
… order, one room rule, one cook per store, reads tracked by the session (#745)
…ce, and a level landing between sessions takes no page's place (#745)
… yield notice, a session with no world holds its levels in its own page cache (#745)
…ght hold their room, the reader names its cook, the level bytes metric says the CPU total (#745)
…ge cache, and the session's reader reads them there (#745)
@pasquelin
pasquelin merged commit 00ce88e into develop Sep 26, 2026
7 of 8 checks passed
@pasquelin
pasquelin deleted the 745-level-store branch September 26, 2026 06:48
@pasquelin pasquelin added the audited Image proved and promise kept (recette) label Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

audited Image proved and promise kept (recette)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant