From c7cb2fa8c72336b75aef04448de6f054c484cb56 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 04:44:23 +0200 Subject: [PATCH 01/11] refactor(streaming): the arrival drain spends the frame's budget and never opens it (#404) --- .../src/page/integration/arrivalQueue.ts | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/sdk-browser/src/page/integration/arrivalQueue.ts b/packages/sdk-browser/src/page/integration/arrivalQueue.ts index 0f19e601bb..e04a50d2b1 100644 --- a/packages/sdk-browser/src/page/integration/arrivalQueue.ts +++ b/packages/sdk-browser/src/page/integration/arrivalQueue.ts @@ -22,12 +22,13 @@ * enter. * * That ceiling is the frame's one integration budget (`./frameBudget.ts`, CONTRIBUTING.md - * §Streaming rule 4): `open` starts its clock, what else a frame integrates before the - * drain — the cells of a partitioned scene (`scene/partition/cells.ts`) — spends from it, - * and the drain spends the rest and closes it. + * §Streaming rule 4), handed in by the session: `open` starts its clock, what else a frame + * integrates before the drain — the cells of a partitioned scene (`scene/partition/cells.ts`) — + * spends from it, the drain spends the rest, and the WebGPU row records written after it + * (`webgpu/row/claims.ts`) whatever is left. */ import { planArrival, planArrivalHere, type ArrivalPlan } from './host.ts'; -import { createFrameBudget } from './frameBudget.ts'; +import { createFrameBudget, type FrameClock } from './frameBudget.ts'; /** What the queue requires of a target: a way to receive a page before the next render, and * the catalogue sheet for the request — the integers the plan is deduced from, and nothing else. */ @@ -54,18 +55,17 @@ type Arrival = { */ const MAX_PLAN_WAITS = 2; -export function createArrivalQueue(byteBudget: number, countBudget: number, msBudget = 2) { +export function createArrivalQueue( + byteBudget: number, + countBudget: number, + budget: FrameClock = createFrameBudget(2), +) { const items: Arrival[] = []; // The same page may be seen by the cache then by the end of its download: while it waits, // it is queued only once per target. The wait is forgotten as soon as it is delivered. const waiting = new Map>(); - let head = 0, - opened = false; - const { admits, spend, ...budget } = createFrameBudget(msBudget); - const open = () => { - opened = true; - budget.open(); - }; + let head = 0; + const { admits, spend } = budget; /** Delivers an arrival with its plan, and removes its address from the waiting pages. */ const deliver = (item: Arrival) => { item.done = true; @@ -73,8 +73,8 @@ export function createArrivalQueue(byteBudget: number, countBudget: number, msBu item.target.acceptPage?.(item.url, item.array, item.plan); }; return { - /** Opens the frame's budget: every integration until the drain shares its clock. */ - open, + /** Opens the frame's budget: every integration of the frame shares its clock. */ + open: () => void budget.open(), admits, spend, /** Arrivals still waiting to drain. */ @@ -119,13 +119,11 @@ export function createArrivalQueue(byteBudget: number, countBudget: number, msBu }, /** * Delivers arrivals up to the budget — at most `countBudget` pages, `byteBudget` index bytes - * and what the frame's budget still admits (opened here unless `open` was called) —, then - * closes that budget. The following render synchronizes residency; calling `syncResident` - * here could draw a second frame. Returns the pages delivered. + * and what the frame's budget, opened by `open`, still admits. The following render + * synchronizes residency; calling `syncResident` here could draw a second frame. Returns the + * pages delivered. */ drain() { - if (!opened) open(); - opened = false; let bytes = 0, count = 0; while (head < items.length && bytes < byteBudget && count < countBudget && admits()) { From bc7910bfdfdf2020a8461947c219d5252f5c7abe Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 04:44:24 +0200 Subject: [PATCH 02/11] fix(streaming): the WebGPU row records spend the frame's one integration budget (#404) --- bench/perf/browser/arrival-admission.perf.ts | 7 +++- packages/sdk-browser/src/backend/types.ts | 4 +++ .../src/page/integration/arrivalQueue.test.ts | 30 ++++++++++++---- .../src/page/integration/frameBudget.ts | 11 +++--- .../sdk-browser/src/webgpu/pages/services.ts | 1 + .../sdk-browser/src/webgpu/row/claims.test.ts | 36 +++++++++++++++++-- packages/sdk-browser/src/webgpu/row/claims.ts | 33 ++++++++--------- packages/sdk-browser/src/webgpu/row/slots.ts | 7 ++-- packages/sdk-browser/src/webgpu/row/sync.ts | 14 ++++++-- .../sdk-browser/src/world/render/hostFrame.ts | 10 ++++-- .../sdk-browser/src/world/scene/streaming.ts | 12 ++++--- .../sdk-browser/src/world/session/backends.ts | 12 +++++-- .../sdk-browser/src/world/session/prepare.ts | 3 +- 13 files changed, 133 insertions(+), 47 deletions(-) diff --git a/bench/perf/browser/arrival-admission.perf.ts b/bench/perf/browser/arrival-admission.perf.ts index eb10b4aad8..717716ac9c 100644 --- a/bench/perf/browser/arrival-admission.perf.ts +++ b/bench/perf/browser/arrival-admission.perf.ts @@ -63,6 +63,8 @@ interface FabriqueFile { url: string, array: Uint32Array, ): unknown; + /** Opens the frame's budget: a frame opens it before its drain. */ + open?(): void; drain(): number; } @@ -77,7 +79,10 @@ function arrivees(fabrique: (byteBudget: number, countBudget: number) => Fabriqu const octets = new Uint32Array(16); for (let i = 0; i < 5000; i++) file.queue(cibles[i % 8], `page-${i % 900}.bin`, octets); let livrs = 0; - for (let d = 0; d < 4; d++) livrs += file.drain(); + for (let d = 0; d < 4; d++) { + file.open?.(); + livrs += file.drain(); + } return { livrees, livrs }; } diff --git a/packages/sdk-browser/src/backend/types.ts b/packages/sdk-browser/src/backend/types.ts index af6ab13958..cad091a36f 100644 --- a/packages/sdk-browser/src/backend/types.ts +++ b/packages/sdk-browser/src/backend/types.ts @@ -193,6 +193,10 @@ export interface BackendContext { /** Host-owned, validated page reader for the initial complete GPU fallback. */ readPage?: (url: string) => Promise; readGeometryPage?: (url: string) => Promise; + /** The session's one integration budget per frame (CONTRIBUTING.md §Streaming rule 4): its + * frame opens it, the cells and the arrival drain spend from it, then the engine's own + * per-frame integration. Absent — an engine driven with no session frame —, nothing bounds it. */ + frameBudget?: import('../page/integration/frameBudget.ts').FrameClock; } export type BackendFactory = (context: BackendContext) => RenderBackend; export type { MeasuredWorldOptions, PointOfInterest } from '../world/session/options.ts'; diff --git a/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts b/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts index 3b542f90e5..6b3ba17e3a 100644 --- a/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts +++ b/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts @@ -4,6 +4,7 @@ import { Group } from '../../../../sdk-core/src/world/object/object3d.ts'; import { createPartitionCells } from '../../scene/partition/cells.ts'; import { placedMesh } from '../../scene/partition/rows.ts'; import { createArrivalQueue, type ArrivalTarget } from './arrivalQueue.ts'; +import { createFrameBudget } from './frameBudget.ts'; import { referenceArrivalQueue } from '../../../../../bench/oracles/browser/arrival-admission.ts'; function target() { @@ -26,7 +27,7 @@ function target() { test('a drain stops at the byte budget and the next one resumes where it left off', () => { const a = target(); // Three 8-byte pages for a budget of 12: the second exceeds the budget and closes the drain. - const queue = createArrivalQueue(12, 64, Infinity); + const queue = createArrivalQueue(12, 64, createFrameBudget(Infinity)); for (const url of ['p0', 'p1', 'p2']) assert.equal(queue.queue(a, url, new Uint32Array(2)), true); assert.equal(queue.pending, 3); assert.equal(queue.drain(), 2, 'the byte budget stops the drain'); @@ -43,7 +44,7 @@ test('a drain stops at the byte budget and the next one resumes where it left of test('a page already waiting for a target is queued once, and each target keeps its own residency', () => { const a = target(), b = target(); - const queue = createArrivalQueue(1 << 20, 2, Infinity); + const queue = createArrivalQueue(1 << 20, 2, createFrameBudget(Infinity)); const page = new Uint32Array(1); assert.equal(queue.queue(a, 'p0', page), true); assert.equal(queue.queue(a, 'p0', page), false, 'already waiting for this target'); @@ -74,7 +75,7 @@ test('many duplicate targets across a drain deliver exactly like the reference', const targets = Array.from({ length: 8 }, (_, c) => ({ acceptPage: (url: string) => delivered.push(`${c}:${url}`), })); - const queue = create(1 << 20, 4096, Infinity); + const queue = create(1 << 20, 4096, createFrameBudget(Infinity)); const bytes = new Uint32Array(4); // Round-robin over the eight targets so the touched list sees many repeats before a drain. for (let i = 0; i < 500; i++) queue.queue(targets[i % 8], `page-${i % 50}.bin`, bytes); @@ -99,16 +100,31 @@ test('the default time budget yields at its boundary and resumes in arrival orde }; const queue = createArrivalQueue(1 << 20, 64); for (const url of ['p0', 'p1', 'slow', 'p3']) queue.queue(receiver, url, new Uint32Array(1)); - assert.equal(queue.drain(), 2, 'two 1 ms deliveries reach the default 2 ms ceiling'); + const frame = () => (queue.open(), queue.drain()); + assert.equal(frame(), 2, 'two 1 ms deliveries reach the default 2 ms ceiling'); assert.deepEqual(accepted, ['p0', 'p1']); assert.equal(queue.pending, 2); - assert.equal(queue.drain(), 1, 'an over-budget first delivery still makes progress'); + assert.equal(frame(), 1, 'an over-budget first delivery still makes progress'); assert.deepEqual(accepted, ['p0', 'p1', 'slow']); assert.equal(queue.pending, 1); - assert.equal(queue.drain(), 1); + assert.equal(frame(), 1); assert.deepEqual(accepted, ['p0', 'p1', 'slow', 'p3']); assert.equal(queue.pending, 0); - assert.equal(queue.drain(), 0); + assert.equal(frame(), 0); +}); + +test('a drain spends what its frame left of the budget, and never opens it again', (t) => { + let now = 0; + t.mock.method(performance, 'now', () => now); + const budget = createFrameBudget(2); + const queue = createArrivalQueue(1 << 20, 64, budget); + queue.queue({ acceptPage() {} }, 'p0', new Uint32Array(1)); + queue.open(); + budget.spend(); + now = 3; // the frame's cells spent it + assert.equal(queue.drain(), 0, 'the page waits for the next frame'); + queue.open(); + assert.equal(queue.drain(), 1); }); test('the cells a frame places and the pages it drains spend one budget, on one clock', async (t) => { diff --git a/packages/sdk-browser/src/page/integration/frameBudget.ts b/packages/sdk-browser/src/page/integration/frameBudget.ts index 119239ab59..5c93b4a185 100644 --- a/packages/sdk-browser/src/page/integration/frameBudget.ts +++ b/packages/sdk-browser/src/page/integration/frameBudget.ts @@ -4,12 +4,15 @@ * longer than the ceiling still goes through, then while the clock since `open` is within it —, * and one counted. * - * The arrival queue opens one per frame and drains within it (`./arrivalQueue.ts`), as do the - * WebGPU row claims (`../../webgpu/row/claims.ts`) and texture tiles (`../../webgpu/tile/streamer.ts`); - * the WebGPU residency queue opens one per turn of the event loop and yields past it - * (`../../webgpu/residency/residentEnsurer.ts`). + * A session holds one integration budget per frame (`BackendContext.frameBudget`): its frame opens + * it, the cells and the arrival drain spend from it (`./arrivalQueue.ts`), then the WebGPU row + * records (`../../webgpu/row/claims.ts`). Texture tiles keep their own upload ceiling + * (`../../webgpu/tile/streamer.ts`); the WebGPU residency queue opens one per turn of the event + * loop and yields past it (`../../webgpu/residency/residentEnsurer.ts`). */ export type FrameBudget = { admits(): boolean; spend(): void }; +/** A budget with its clock: the frame that owns it opens it. */ +export type FrameClock = ReturnType; /** `now` is the clock the budget is read on: `performance.now` unless a test drives it. */ export function createFrameBudget(ms: number, now = () => performance.now()) { diff --git a/packages/sdk-browser/src/webgpu/pages/services.ts b/packages/sdk-browser/src/webgpu/pages/services.ts index dc874bf24a..1fc57f7ab5 100644 --- a/packages/sdk-browser/src/webgpu/pages/services.ts +++ b/packages/sdk-browser/src/webgpu/pages/services.ts @@ -79,6 +79,7 @@ export function createWebgpuPagesServices(rt: WebgpuPagesCore) { ), // A blended caster's opacity moved: the shadow pages under it are drawn again. (rec) => noteResidenceChange(rt.lights, rec), + context.frameBudget, ); /** * The bytes one pool slot holds for a cluster: its quantized geometry page, read from the diff --git a/packages/sdk-browser/src/webgpu/row/claims.test.ts b/packages/sdk-browser/src/webgpu/row/claims.test.ts index 3dfc03b5eb..e323ccb32e 100644 --- a/packages/sdk-browser/src/webgpu/row/claims.test.ts +++ b/packages/sdk-browser/src/webgpu/row/claims.test.ts @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createWebgpuRowClaims, serveClaims } from './claims.ts'; +import { createArrivalQueue } from '../../page/integration/arrivalQueue.ts'; +import { createFrameBudget } from '../../page/integration/frameBudget.ts'; /** A row write slower than the per-image budget: the clock always runs out after one row. */ const slowPlace = () => { @@ -16,9 +18,11 @@ const owed = () => { }; test('an image writes owed rows within its time budget and leaves the rest owed', () => { - const claims = owed(); + const claims = owed(), + budget = createFrameBudget(2); + budget.open(); assert.equal( - serveClaims(claims, () => true, slowPlace), + serveClaims(claims, () => true, slowPlace, budget), 0, ); assert.equal(claims.count, 3, 'one row goes through, three wait for the next image'); @@ -32,9 +36,35 @@ test('a barrier image lifts the time budget and writes every owed row', () => { claims, () => true, (page) => (placed.push(page), slowPlace()), - false, ); assert.equal(denied, 0); assert.equal(claims.count, 0); assert.deepEqual(placed, [0, 1, 2, 3]); }); + +test('the rows a frame writes spend what its arrivals left of its one budget, on one clock', () => { + let now = 0; + const budget = createFrameBudget(2, () => now); + const arrivals = createArrivalQueue(1 << 20, 64, budget); + const receiver = { acceptPage: () => void (now += 1) }; + for (const url of ['p0', 'p1']) arrivals.queue(receiver, url, new Uint32Array(1)); + const claims = owed(); + arrivals.open(); + assert.equal(arrivals.drain(), 2, 'two 1 ms arrivals spend the 2 ms frame'); + serveClaims( + claims, + () => true, + () => true, + budget, + ); + assert.equal(claims.count, 3, 'one row goes through, three wait for the next frame'); + arrivals.open(); + arrivals.drain(); + serveClaims( + claims, + () => true, + () => true, + budget, + ); + assert.equal(claims.count, 0, 'a frame with nothing to drain writes every row within it'); +}); diff --git a/packages/sdk-browser/src/webgpu/row/claims.ts b/packages/sdk-browser/src/webgpu/row/claims.ts index 98c3fa1c40..0c7a7b0295 100644 --- a/packages/sdk-browser/src/webgpu/row/claims.ts +++ b/packages/sdk-browser/src/webgpu/row/claims.ts @@ -1,14 +1,13 @@ import { sortPages } from '../../../../sdk-core/src/index.ts'; -import { createFrameBudget } from '../../page/integration/frameBudget.ts'; -import { ARRIVAL_BUDGET_MS } from '../../backend/common.ts'; +import type { FrameBudget } from '../../page/integration/frameBudget.ts'; /** * Pages that claim the write of a row record and have not yet received it. * * Writing a row costs a material, a hash, wrap modes, a shadow sphere, eight corners and five row * words: a burst of arrivals pays that many times, on the main thread, in the same image. This list - * is what remains to do, and the rank allocator consumes only a time budget per image — the rest waits - * for the next image, in the same order. + * is what remains to do, and the rank allocator spends only what the frame's one integration budget + * has left — the rest waits for the next image, in the same order. * * A page enrols only once: per-page marking is what guarantees it, so a page claimed twice before it * is served does not double the work. The list is sorted before it is served, because the residency @@ -20,8 +19,6 @@ export function createWebgpuRowClaims(pageCount: number) { let count = 0; return { pages, - /** Time an image grants to writing rows (`serveClaims`): this list's own clock. */ - budget: createFrameBudget(ARRIVAL_BUDGET_MS), get count() { return count; }, @@ -52,27 +49,25 @@ export function createWebgpuRowClaims(pageCount: number) { export type WebgpuRowClaims = ReturnType; /** - * Serves the queue in increasing page order up to the time budget: the arrival drain's ceiling - * (`ARRIVAL_BUDGET_MS`) on its own `FrameBudget`, opened once per image — the clock is reread after - * each row and the rest waits for the next image, in the same order. At least one row always goes - * through, or a page would never be written. `release` says again whether the - * page still claims a row — it may have left since it enrolled, and then leaves the queue costing - * nothing —, `place` writes it and returns `false` when the table is full. + * Serves the queue in increasing page order within `budget`: the frame's one integration budget + * (`BackendContext.frameBudget`), which its frame opened and its cells and arrivals spent from + * first — the clock is reread after each row and the rest waits for the next image, in the same + * order. At least one row always goes through, or a page would never be written. `release` says + * again whether the page still claims a row — it may have left since it enrolled, and then leaves + * the queue costing nothing —, `place` writes it and returns `false` when the table is full. * * Returns the number of pages a table overflow leaves without a rank: never those the time budget - * alone deferred, which overflow nothing. `bounded` false lifts the time budget: a barrier image, - * outside the measured loop, writes every owed row. + * alone deferred, which overflow nothing. No `budget` — a barrier image, outside the measured + * loop, or an engine driven with no frame — writes every owed row. */ export function serveClaims( claims: WebgpuRowClaims, release: (page: number) => boolean, place: (page: number) => boolean, - bounded = true, + budget?: FrameBudget, ) { if (!claims.count) return 0; claims.sort(); - const budget = claims.budget; - budget.open(); let served = 0, denied = 0; while (served < claims.count) { @@ -83,8 +78,8 @@ export function serveClaims( break; } served++; - budget.spend(); - if (bounded && !budget.admits()) break; + budget?.spend(); + if (budget && !budget.admits()) break; continue; } served++; diff --git a/packages/sdk-browser/src/webgpu/row/slots.ts b/packages/sdk-browser/src/webgpu/row/slots.ts index 129f002f7a..d374a56348 100644 --- a/packages/sdk-browser/src/webgpu/row/slots.ts +++ b/packages/sdk-browser/src/webgpu/row/slots.ts @@ -5,6 +5,7 @@ import type { PageRec } from '../../page/selection/selection.ts'; import { rowHasGeometry, type createPageRowWriter } from './pageRow.ts'; import { awaitsPageBytes } from './pageSlots.ts'; import type { createWebgpuRowState } from './state.ts'; +import type { FrameBudget } from '../../page/integration/frameBudget.ts'; type Rows = ReturnType; type Writer = ReturnType; @@ -34,6 +35,8 @@ export function createWebgpuRowSlots( drawSlots: number, writePageRow: Writer, onResidenceChange: (rec: PageRec) => void, + /** The frame's one integration budget; absent, an image writes every owed record. */ + budget?: FrameBudget, ) { /** Ranks this pass gave back, waiting for a taker or a fill. */ const free = { rows: new Int32Array(Math.max(1, drawSlots)), count: 0 }; @@ -157,7 +160,7 @@ export function createWebgpuRowSlots( }; /** What the image owes the row table: the pages the cache named, and what the record queue - * left behind, within the time budget unless `bounded` is false. */ + * left behind, within the frame's budget unless `bounded` is false. */ const apply = (bounded = true) => { written.changed = false; denied = 0; @@ -172,7 +175,7 @@ export function createWebgpuRowSlots( const page = rows.touched.pages[i]; if (release(page)) claims.add(page); } - denied = serveClaims(claims, release, place, bounded); + denied = serveClaims(claims, release, place, bounded ? budget : undefined); closeFreeRows(); } rows.clearTouched(); diff --git a/packages/sdk-browser/src/webgpu/row/sync.ts b/packages/sdk-browser/src/webgpu/row/sync.ts index 957ef2259a..86e5b72f4a 100644 --- a/packages/sdk-browser/src/webgpu/row/sync.ts +++ b/packages/sdk-browser/src/webgpu/row/sync.ts @@ -6,6 +6,7 @@ import { createWebgpuRowSlots } from './slots.ts'; import { rowHasGeometry } from './pageRow.ts'; import { awaitsPageBytes } from './pageSlots.ts'; import { createBlendCasterRows } from './blendCasters.ts'; +import type { FrameBudget } from '../../page/integration/frameBudget.ts'; type Rows = ReturnType; type Mirror = ReturnType; @@ -24,8 +25,17 @@ export function createWebgpuRowSync( onResidenceChange: (rec: PageRec) => void = () => {}, /** Called when a blended caster's row is written again with another coverage. */ onCoverageChange: (rec: PageRec) => void = () => {}, + /** The frame's one integration budget the owed records spend from (`claims.ts`). */ + budget?: FrameBudget, ) { - const slots = createWebgpuRowSlots(rows, packedPages, drawSlots, writePageRow, onResidenceChange); + const slots = createWebgpuRowSlots( + rows, + packedPages, + drawSlots, + writePageRow, + onResidenceChange, + budget, + ); /** The blended clusters' caster rows, behind the visibility rows: they follow the residency the * mirror reports (`follow`), and the table's age here, whichever cut draws the image. */ const blendCasters = createBlendCasterRows(rows, packedPages, writePageRow, onCoverageChange); @@ -33,7 +43,7 @@ export function createWebgpuRowSync( * Rows for the drawable set. What the image owes the table now depends only on the pages whose * cache slot just changed, and on what the previous image's time budget left to write: the whole * catalogue is walked again only on a rebuild, which the rank allocator decides alone, and never - * again because a list overflowed. `bounded` false lifts the per-image time budget (a barrier image). + * again because a list overflowed. `bounded` false lifts the frame's budget (a barrier image). */ const syncRows = (bounded = true) => { if (!cacheReady() || !rows.pageTableFloats) return; diff --git a/packages/sdk-browser/src/world/render/hostFrame.ts b/packages/sdk-browser/src/world/render/hostFrame.ts index de673b56c5..9d2b06c9a3 100644 --- a/packages/sdk-browser/src/world/render/hostFrame.ts +++ b/packages/sdk-browser/src/world/render/hostFrame.ts @@ -18,7 +18,7 @@ type Inputs = { export function createExplorerHostFrame(session: ExplorerSession, inputs: Inputs) { const { options, metadata } = session; const { prepared, host, backends } = inputs; - const { camera, directGpu, pageSources, partitions } = prepared; + const { camera, directGpu, pageSources, partitions, frameBudget } = prepared; const { geometryUrls, pageIdByUrl, streamer } = pageSources; const { state, @@ -44,7 +44,13 @@ export function createExplorerHostFrame(session: ExplorerSession, inputs: Inputs effectBytes: compose.effectBytes(), }), ); - const streaming = createExplorerStreaming(session, { streamer, geometryUrls, backends, state }); + const streaming = createExplorerStreaming(session, { + streamer, + geometryUrls, + backends, + state, + budget: frameBudget, + }); const drawBackend = createExplorerDraw(session, { camera, geometryUrls, diff --git a/packages/sdk-browser/src/world/scene/streaming.ts b/packages/sdk-browser/src/world/scene/streaming.ts index e59580231c..6d86d2ac7e 100644 --- a/packages/sdk-browser/src/world/scene/streaming.ts +++ b/packages/sdk-browser/src/world/scene/streaming.ts @@ -1,5 +1,6 @@ import { createArrivalQueue } from '../../page/integration/arrivalQueue.ts'; -import { ARRIVAL_BUDGET_MS, ARRIVAL_QUEUE_BATCH } from '../../backend/common.ts'; +import type { FrameClock } from '../../page/integration/frameBudget.ts'; +import { ARRIVAL_QUEUE_BATCH } from '../../backend/common.ts'; import { decodePageOffThread } from '../../page/decode/host.ts'; import { PRIORITY_VISIBLE } from '../../streaming/priority.ts'; import type { RenderBackend } from '../../backend/types.ts'; @@ -12,11 +13,13 @@ type Inputs = { geometryUrls: Set; backends: RenderBackend[]; state: Pick; + /** The session's one integration budget per frame (`BackendContext.frameBudget`). */ + budget: FrameClock; }; export function createExplorerStreaming(session: ExplorerSession, inputs: Inputs) { const { signal, scope, emit, diagnose } = session; - const { streamer, geometryUrls, backends, state } = inputs; + const { streamer, geometryUrls, backends, state, budget } = inputs; let streamingError: string | null = null, lastPrefetch = 0; let streamingPromise: Promise | null = null, @@ -28,10 +31,11 @@ export function createExplorerStreaming(session: ExplorerSession, inputs: Inputs // Page arrivals no longer enter the frame that discovers them: the queue stacks them and a // single bounded drain, at the head of `render()`, makes them resident before selection of // the next frame. The ceiling is TIME — 2 ms of integration per frame, the one budget the cells - // of a partitioned scene spend from too (`partitionFrame.ts`); 512 KiB of index and 64 pages + // of a partitioned scene (`partitionFrame.ts`) and the engine's row records spend from too, in + // that order around the drain (`BackendContext.frameBudget`); 512 KiB of index and 64 pages // double it without ever replacing it, because a streaming packet carries a cluster count // unknown in advance and no byte count then bounds the duration. - const arrivals = createArrivalQueue(512 * 1024, 64, ARRIVAL_BUDGET_MS); + const arrivals = createArrivalQueue(512 * 1024, 64, budget); // What a frame queues at most. The queue delivers only a handful per frame: stacking // thousands ahead would only add, every frame, as many cache reads — and each read moves // its address to the head of the least-recently-used order. The rest leaves on the next diff --git a/packages/sdk-browser/src/world/session/backends.ts b/packages/sdk-browser/src/world/session/backends.ts index 0293dcc8ce..e897dda32b 100644 --- a/packages/sdk-browser/src/world/session/backends.ts +++ b/packages/sdk-browser/src/world/session/backends.ts @@ -1,5 +1,11 @@ import type { HostTexture } from '../../host/resources.ts'; -import { DEFAULT_CLEAR_COLOR, isCancelled, pixelRatioOf } from '../../backend/common.ts'; +import { + ARRIVAL_BUDGET_MS, + DEFAULT_CLEAR_COLOR, + isCancelled, + pixelRatioOf, +} from '../../backend/common.ts'; +import { createFrameBudget } from '../../page/integration/frameBudget.ts'; import { createSceneLightStore, dagWarningsDiagnostic } from '../../../../sdk-core/src/index.ts'; import { createSceneProxyReader } from '../../scene/proxyLoad.ts'; import { createTextureLevelReader } from '../../texture/levelReader.ts'; @@ -71,6 +77,7 @@ export async function prepareExplorerBackends(session: ExplorerSession, inputs: kind: 'preparation', ...dagWarnings.context, }); + const frameBudget = createFrameBudget(ARRIVAL_BUDGET_MS); const context: BackendContext = { source, metadata, @@ -125,6 +132,7 @@ export async function prepareExplorerBackends(session: ExplorerSession, inputs: readTextureLevel: createTextureLevelReader(metadata.textures, base, signal), sceneLights, importedLightIds, + frameBudget, }; for (const factory of factories) { const backend = factory(context); @@ -194,5 +202,5 @@ export async function prepareExplorerBackends(session: ExplorerSession, inputs: if (!backends.length) throw new Error('No backend'); // The engines' host tables, which follow the view, come out of the decoded pages' CPU share. streamer.reserve(() => backends.reduce((bytes, b) => bytes + (b.hostTableBytes?.() ?? 0), 0)); - return { viewport, context }; + return { viewport, context, frameBudget }; } diff --git a/packages/sdk-browser/src/world/session/prepare.ts b/packages/sdk-browser/src/world/session/prepare.ts index 7f8fe8292b..d0be514c45 100644 --- a/packages/sdk-browser/src/world/session/prepare.ts +++ b/packages/sdk-browser/src/world/session/prepare.ts @@ -143,7 +143,7 @@ export async function prepareExplorer(session: ExplorerSession, inputs: Inputs) cells: loadedScene.partitions.map((cells) => cells.stats()), }); } - const { viewport, context } = await prepareExplorerBackends(session, { + const { viewport, context, frameBudget } = await prepareExplorerBackends(session, { source, sceneLightingSource: loadedScene.sceneLightingSource, associations: loadedScene.associations, @@ -164,6 +164,7 @@ export async function prepareExplorer(session: ExplorerSession, inputs: Inputs) directGpu, viewport, context, + frameBudget, ...cameraState, }; } From c20f7ea6a06ee1690083c95630ac4e6b75d9c121 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 04:46:48 +0200 Subject: [PATCH 03/11] fix(geometry): the cell reach follows the camera's zoom and a sheared root's least stretch (#404) --- .../sdk-browser/src/scene/partition/boxes.ts | 5 +- .../src/scene/partition/plan.test.ts | 28 ++++++++- .../sdk-browser/src/scene/partition/plan.ts | 58 +++++++++++-------- .../src/world/scene/partitionFrame.test.ts | 37 ++++++++++++ 4 files changed, 100 insertions(+), 28 deletions(-) diff --git a/packages/sdk-browser/src/scene/partition/boxes.ts b/packages/sdk-browser/src/scene/partition/boxes.ts index 7edcfd3a04..d26a01fbc3 100644 --- a/packages/sdk-browser/src/scene/partition/boxes.ts +++ b/packages/sdk-browser/src/scene/partition/boxes.ts @@ -25,8 +25,9 @@ const rootWorld = new Float64Array(MATRIX_VALUES), relative = new Float64Array(MATRIX_VALUES), inverse = new Float64Array(MATRIX_VALUES); -/** The least and the most `matrix` stretches a distance; a flattened frame stretches it by 0. */ -function stretchOf(matrix: ArrayLike): Stretch { +/** The least and the most `matrix` stretches a distance — its smallest and largest singular + * values —; a flattened frame stretches it by 0. */ +export function stretchOf(matrix: ArrayLike): Stretch { if (determinantMatrix4(matrix) === 0) return [0, maxStretch(matrix)]; return [1 / maxStretch(invertMatrix4(inverse, matrix)), maxStretch(matrix)]; } diff --git a/packages/sdk-browser/src/scene/partition/plan.test.ts b/packages/sdk-browser/src/scene/partition/plan.test.ts index 2c9a902ff0..d6485c24ba 100644 --- a/packages/sdk-browser/src/scene/partition/plan.test.ts +++ b/packages/sdk-browser/src/scene/partition/plan.test.ts @@ -1,15 +1,37 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { AHEAD, boxDistance, cellReach, KEEP, planCells } from './plan.ts'; +import { AHEAD, boxDistance, cellReach, inCellFrame, KEEP, planCells } from './plan.ts'; -const optics = { fov: 60, aspect: 16 / 9, far: 1e6 }; +const optics = { fov: 60, aspect: 16 / 9, far: 1e6, zoom: 1 }; const cell = (x: number) => ({ bounds: [x, 0, 0, x + 1, 1, 1], meshes: [[0, 1] as const] }); test('a cell is read up to the far plane, met on the frustum diagonal', () => { const tangent = Math.tan(Math.PI / 6); const widen = 1 + tangent * tangent * (1 + optics.aspect ** 2); assert.equal(cellReach({ ...optics, far: 1000 }), 1000 * Math.sqrt(widen)); - assert.equal(cellReach({ ...optics, orthographic: {} }), Infinity); +}); + +test('the reach follows the zoom: its frustum, and an orthographic box, widen as it shrinks', () => { + const slope = Math.tan(Math.PI / 6) / 0.5; + const widen = 1 + slope * slope * (1 + optics.aspect ** 2); + assert.equal(cellReach({ ...optics, far: 1000, zoom: 0.5 }), 1000 * Math.sqrt(widen)); + // The box `[-10, 10] × [-5, 5]` at zoom 0.5 sees `[-20, 20] × [-10, 10]` up to its far plane. + const box = { left: -10, right: 10, top: 5, bottom: -5 }; + const orthographic = { ...optics, far: 1000, zoom: 0.5, orthographic: box }; + assert.equal(cellReach(orthographic), Math.hypot(1000, 20, 10)); +}); + +test('a sheared root reads every cell the world reach holds, by its least singular value', () => { + // `(x, y, z) → (x + y, y, z)`: every column is at least 1 long, yet it shrinks the direction + // `(1, −0.618…, 0)` by 0.618…, the golden ratio's inverse. + const shear = [1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const local = [1, (1 - Math.sqrt(5)) / 2, 0]; + const world = Math.hypot(local[0] + local[1], local[1], local[2]); + const { eye, reach } = inCellFrame(shear, [0, 0, 0], world); + assert.ok(Math.hypot(...local) <= reach * (1 + 1e-12), `${Math.hypot(...local)} > ${reach}`); + assert.deepEqual(eye, [0, 0, 0]); + const flat = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + assert.equal(inCellFrame(flat, [0, 0, 0], 1).reach, Infinity, 'a flattened root reads all'); }); test('the distance to a cell is the distance to its box', () => { diff --git a/packages/sdk-browser/src/scene/partition/plan.ts b/packages/sdk-browser/src/scene/partition/plan.ts index 744b3e2440..5890f4167c 100644 --- a/packages/sdk-browser/src/scene/partition/plan.ts +++ b/packages/sdk-browser/src/scene/partition/plan.ts @@ -1,13 +1,14 @@ /** * WHICH CELLS OF A PARTITIONED SCENE ARE READ, derived from the camera, never from the scene (#404). * - * A cell is read while the camera can draw any of it: until its box is past the far plane — met - * on the frustum's diagonal at `far·√(1 + tan²θ)`, `θ` the half diagonal of the field, the same - * off-axis majorant the certified cluster error takes (`screenErrorBound.ts`). Nothing coarser - * stands for a cell that is not read (its merged proxy is #23), so its objects are drawn wherever - * the far plane lets them be, however small they project: dropping one below the error target - * would leave it out of the image for good, not replace it by a coarser one. An orthographic - * camera does not shrink what it sees with distance, so every cell of its scene is read. + * A cell is read while the camera can draw any of it: until its box is past the frustum's farthest + * corner — for a perspective camera `far·√(1 + tan²θ)`, `θ` the half diagonal of the field its + * zoom narrows or widens (`perspectiveSlope`), the same off-axis majorant the certified cluster + * error takes (`screenErrorBound.ts`); for an orthographic one the far corner of its zoomed box + * (`orthographicView`). Nothing coarser stands for a cell that is not read (its merged proxy is + * #23), so its objects are drawn wherever the far plane lets them be, however small they project: + * dropping one below the error target would leave it out of the image for good, not replace it by + * a coarser one. * * A frame asks for the cells within the reach first, then — at the prefetch priority — those * within `AHEAD` of it past it, and a read cell leaves once its box is `KEEP` of the reach past it, @@ -22,13 +23,20 @@ */ import { invertMatrix4, MATRIX_VALUES } from '../../../../sdk-core/src/index.ts'; import { boxPointDistance } from '../../../../sdk-core/src/math/primitives/box.ts'; +import { + orthographicView, + perspectiveSlope, +} from '../../../../sdk-core/src/math/primitives/camera.ts'; +import type { CameraOptics } from '../../camera/engineCamera.ts'; +import { stretchOf } from './boxes.ts'; import type { TableCell } from '../../../../sdk-core/src/scene/core/tablePartition.ts'; /** A cell as the plan reads it: its boxes in the scene root's frame now, six values per parent * (`boxes.ts`), and how many nodes of each mesh it places. */ export type BoxedCell = { bounds: ArrayLike; meshes: TableCell['meshes'] }; -const inverse = new Float64Array(MATRIX_VALUES); +const inverse = new Float64Array(MATRIX_VALUES), + view = new Float64Array(4); /** How far past the reach, as a fraction of it, a cell is read ahead at the prefetch priority. */ export const AHEAD = 0.25; @@ -36,34 +44,38 @@ export const AHEAD = 0.25; * ahead is not dropped by the next step. */ export const KEEP = 0.5; -/** What the reach reads of a camera: its optics and whether it is orthographic. */ -export type PartitionOptics = { - fov: number; - aspect: number; - far: number; - orthographic?: unknown; -}; +/** What the reach reads of a camera: the optics its projection is composed from. */ +export type PartitionOptics = Pick< + CameraOptics, + 'fov' | 'aspect' | 'far' | 'zoom' | 'orthographic' +>; -/** The distance past which nothing `optics` sees is drawn. */ +/** The distance past which nothing `optics` sees is drawn: the frustum's farthest corner. */ export function cellReach(optics: PartitionOptics) { - if (optics.orthographic) return Infinity; - const tangent = Math.tan((optics.fov * Math.PI) / 360); - return optics.far * Math.sqrt(1 + tangent * tangent * (1 + optics.aspect * optics.aspect)); + const { far, orthographic } = optics, + zoom = optics.zoom || 1; // as the projection reads it (`writeEngineCamera`) + if (orthographic) { + const [x, y, halfWidth, halfHeight] = orthographicView(orthographic, zoom, view); + return Math.hypot(far, Math.abs(x) + halfWidth, Math.abs(y) + halfHeight); + } + const slope = perspectiveSlope(optics.fov, zoom); + return far * Math.sqrt(1 + slope * slope * (1 + optics.aspect * optics.aspect)); } /** * `eye` and `reach` in the frame the cells' boxes are written in — the scene root's, whose world - * matrix `world` a world may pose, turn and scale. A world distance is at least the root's smallest - * stretch times the distance there: what this reach reads holds every cell the world's reach needs. + * matrix `world` a world may pose, turn, scale and shear. A world distance is at least the root's + * least stretch (`stretchOf`: its smallest singular value, not its shortest column, which a shear + * lengthens) times the distance there: what this reach reads holds every cell the world's reach + * needs; a flattened root, which stretches some distance by 0, reads every cell. */ export function inCellFrame(world: ArrayLike, eye: ArrayLike, reach: number) { - const least = Math.min(...[0, 4, 8].map((c) => Math.hypot(world[c], world[c + 1], world[c + 2]))); invertMatrix4(inverse, world); const local = [0, 1, 2].map( (a) => inverse[a] * eye[0] + inverse[4 + a] * eye[1] + inverse[8 + a] * eye[2] + inverse[12 + a], ); - return { eye: local, reach: reach / least }; + return { eye: local, reach: reach / stretchOf(world)[0] }; } /** Distance from `eye` to the nearest box `[minX, minY, minZ, maxX, maxY, maxZ]` of `bounds`, diff --git a/packages/sdk-browser/src/world/scene/partitionFrame.test.ts b/packages/sdk-browser/src/world/scene/partitionFrame.test.ts index 4b6fc9803b..c7f03f7901 100644 --- a/packages/sdk-browser/src/world/scene/partitionFrame.test.ts +++ b/packages/sdk-browser/src/world/scene/partitionFrame.test.ts @@ -102,6 +102,43 @@ test('a pebble far below any error target is read while the far plane lets it be assert.deepEqual(asked, [[['https://cache.test/key/pebble.json'], PRIORITY_VISIBLE]]); }); +test('a camera zoomed out reads the cells its wider frustum sees', () => { + // At zoom 0.5 the frustum is twice as wide: a pebble 560 m aside, 290 m ahead, is within its far + // plane's corner, past the reach — and the read-ahead — of the same camera at zoom 1. + const bounds = [560, 0, -290, 560.01, 0.01, -289.99]; + const cells = createPartitionCells({ + partition: { + version: 1, + bounds, + meshes: [0], + cells: [ + { + url: 'aside.json', + sha256: '', + bytes: 1, + meshes: [[0, 1] as const], + parents: [[null, bounds] as const], + }, + ], + }, + base: 'https://cache.test/key/', + root: new Group(), + parents: [], + meshes: new Map([[0, placedMesh([{ meshes: 0, primitives: 0 }])]]), + }); + const { port, asked } = streamer(); + const camera = hostFramingCamera(60, 16 / 9, 0.1, 300); + camera.zoom = 0.5; + createPartitionFrame({ + partitions: [cells], + streamer: port, + camera, + active: () => ({}) as RenderBackend, + budget, + })!(); + assert.deepEqual(asked, [[['https://cache.test/key/aside.json'], PRIORITY_VISIBLE]]); +}); + test('a reach past the rows sized at open asks the owner to open the session again', () => { const renew = () => {}; const { cells, seen } = recording(); From 3717400d250be408dc231e60c09e34ff315c6895 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 04:50:27 +0200 Subject: [PATCH 04/11] fix(geometry): a parent scaled down grows the partition's rows in place, reopening nothing (#404) --- .../src/scene/partition/cells.test.ts | 42 ++++++++++++-- .../sdk-browser/src/scene/partition/cells.ts | 55 +++++++++---------- .../sdk-browser/src/scene/partition/rows.ts | 36 ++++++++++-- .../src/world/scene/partitionFrame.ts | 6 +- .../world-partition-parents.test.ts | 52 ++++++++++++++++++ 5 files changed, 152 insertions(+), 39 deletions(-) diff --git a/packages/sdk-browser/src/scene/partition/cells.test.ts b/packages/sdk-browser/src/scene/partition/cells.test.ts index c00219142c..5f255ac3be 100644 --- a/packages/sdk-browser/src/scene/partition/cells.test.ts +++ b/packages/sdk-browser/src/scene/partition/cells.test.ts @@ -10,9 +10,9 @@ import { createPartitionCells, type PartitionCells } from './cells.ts'; type PartitionIo = Parameters[2]; import { placedMesh, type RowLink } from './rows.ts'; -/** Two cells of one mesh, one near the origin and one 5 km away; the second hangs under a moved - * core node, or under the scene root when `far` is null. */ -function world(far: number | null = 0) { +/** Two cells of one mesh, one near the origin and one 5 km away; each hangs under a moved core + * node, or under the scene root when its `far` or `near` is null. */ +function world(far: number | null = 0, near: number | null = null) { const node = (x: number, parent: number | null) => ({ parent, mesh: 7, @@ -22,7 +22,7 @@ function world(far: number | null = 0) { scale: [2, 2, 2], }); const bodies: Record = { - 'near.json': { version: 1, nodes: [node(1, null), node(3, null)] }, + 'near.json': { version: 1, nodes: [node(1, near), node(3, near)] }, 'far.json': { version: 1, nodes: [node(5000, far)] }, }; const partition: TablePartition = { @@ -34,7 +34,7 @@ function world(far: number | null = 0) { url: 'near.json', sha256: '', bytes: 1, - parents: [[null, [0, 0, 0, 5, 5, 5]]], + parents: [near === null ? [null, [0, 0, 0, 5, 5, 5]] : [0, [0, -10, 0, 5, -5, 5]]], meshes: [[7, 2]], }, { @@ -165,6 +165,38 @@ test('the rows are sized at open for the reach, and a reach past them tells the assert.deepEqual(cells.stats(), { cells: 2, held: 2, waiting: 0, rows: 4 }); }); +test('a parent scaled down grows the rows in place, on an engine that can, and reopens nothing', async () => { + // Shrunk a thousand times, the core node both cells hang under brings the one 5 km off to 5 m: + // both are within 100 m, three nodes on rows sized for the near cell's two. + for (const grows of [true, false]) { + const { cells, links, core, bytes } = world(0, 0); + const { port, held, outgrown } = io(bytes); + const grown: [PlacementRows, PlacementRows][] = []; + if (grows) port.grow = (from, to) => void grown.push([from, to]); + ['near.json', 'far.json'].forEach((name) => held.add(`https://cache.test/key/${name}`)); + await opened(cells, 100); + const before = links.map((link) => link.placements!); + core.scale.set(1e-3, 1e-3, 1e-3); + cells.frame([0, 0, 0], 100, port, noBudget); + cells.frame([0, 0, 0], 100, port, noBudget); + const { held: placed, waiting } = cells.stats(); + if (!grows) { + assert.deepEqual([placed, waiting, outgrown.count], [1, 1, 1], 'it asks its owner to reopen'); + continue; + } + assert.deepEqual([placed, waiting, outgrown.count], [2, 0, 0]); + assert.deepEqual( + grown, + [0, 1].map((at) => [before[at], links[at].placements!]), + ); + for (const link of links) + assert.equal( + link.placements!.live.reduce((a, b) => a + b, 0), + 3, + ); + } +}); + test('a world that poses the scene root reads the cells its camera sees there', async () => { // The far cell stands at the world's origin once the root is moved back 5 km and shrunk ten // times: a camera there asks for it, and not for the near cell, now 500 m away. diff --git a/packages/sdk-browser/src/scene/partition/cells.ts b/packages/sdk-browser/src/scene/partition/cells.ts index ca31b19d5d..2462be3326 100644 --- a/packages/sdk-browser/src/scene/partition/cells.ts +++ b/packages/sdk-browser/src/scene/partition/cells.ts @@ -9,7 +9,9 @@ * rewrites the rows under it. `prime`, before the first frame, sizes the rows for every node the * reach can hold at once wherever the parents stand (`sizing.ts`; every node when no owner can * reopen the session) and reads the cells it needs. Moved, turned or scaled up, parents never run - * the rows short; a reach past them, or a parent scaled down or stretched unevenly, asks to reopen. + * the rows short; a reach past them, or a parent scaled down or stretched unevenly, sizes them + * again in place, through the engine's growth contract (`placement/growth.ts`) — only an engine + * that grows no buffer in place asks its owner to reopen the session. */ import { MATRIX_VALUES, multiplyMatrix4 } from '../../../../sdk-core/src/index.ts'; import { @@ -22,8 +24,8 @@ import { hostWorldChainInto } from '../../host/world/chain.ts'; import { createCellBoxes } from './boxes.ts'; import { inCellFrame, planCells } from './plan.ts'; import { holdsEvery, outstretched, residentRows, sizedStretch, type Stretch } from './sizing.ts'; -import { capacityOf, releaseRow, rowLocal, rowsFree, sizeRows, takeRow } from './rows.ts'; -import type { PlacedMesh, RowLink } from './rows.ts'; +import { capacityOf, createTouchedRows, releaseRow, rowLocal, rowsFree } from './rows.ts'; +import { sizeRows, takeRow, type PlacedMesh } from './rows.ts'; type Placement = { mesh: PlacedMesh; row: number; parent: Object3D; local: Float64Array }; type Inputs = { @@ -44,7 +46,7 @@ export function createPartitionCells(inputs: Inputs) { const held = new Map(); /** The world matrix each parent in use had when its rows were written. */ const worlds = new Map(); - const touched = new Map(); + const touched = createTouchedRows(); /** Cells short of rows; whether the owner was asked to reopen; the reach, in the cells' frame, * the rows are sized for (∞: all); the largest a frame asked; each parent's stretch sized for. */ let waiting = 0, @@ -57,12 +59,6 @@ export function createPartitionCells(inputs: Inputs) { if (!world) worlds.set(node, (world = hostWorldChainInto(new Float64Array(16), node))); return world; }; - const touch = (link: RowLink, row: number) => { - const range = touched.get(link) ?? { from: row, to: row }; - range.from = Math.min(range.from, row); - range.to = Math.max(range.to, row); - touched.set(link, range); - }; const write = (placement: Placement) => { const { mesh, row } = placement; multiplyMatrix4(product, worldOf(placement.parent), placement.local); @@ -70,7 +66,7 @@ export function createPartitionCells(inputs: Inputs) { const rows = link.placements!; rows.matrices.set(product, row * 16); rows.live[row] = 1; - touch(link, row); + touched.touch(link, row); } }; /** Places `cell` from its bytes; false when a mesh is short of rows (a reach past `sized`). */ @@ -90,7 +86,7 @@ export function createPartitionCells(inputs: Inputs) { const leave = (cell: number) => { for (const { mesh, row } of held.get(cell)!) { releaseRow(mesh, row); - for (const link of mesh.links) touch(link, row); + for (const link of mesh.links) touched.touch(link, row); } held.delete(cell); }; @@ -104,9 +100,15 @@ export function createPartitionCells(inputs: Inputs) { for (const placement of placements) if (placement.parent === node) write(placement); } }; - const flush = (update: (rows: PlacementRows, from: number, to: number) => void) => { - for (const [link, { from, to }] of touched) update(link.placements!, from, to); - touched.clear(); + /** Sizes the rows for a reach `bound` and the parents' stretch now; `grown` hands each buffer + * replaced to the engine drawing it, absent before the engines read them. */ + const resize = (bound: number, grown?: (from: PlacementRows, to: PlacementRows) => void) => { + // Sized for any place of the parents; rows that hold every node are never short. + stretched = sizedStretch(boxes.stretch); + const rows = residentRows(partition.cells, bound, stretched); + sizeRows(meshes, rows, grown); + sized = holdsEvery(rows, cells) ? Infinity : bound; + short = false; }; return { /** Every cell as the streamer's catalogue reads it. */ @@ -124,12 +126,14 @@ export function createPartitionCells(inputs: Inputs) { eye: ArrayLike, reach: number, /** The streamer's verified bytes, whether it reads an address, a request (`ahead`: read - * before needed), rows written, and the owner told once the reach outgrew the rows. */ + * before needed), rows written, a buffer grown in place — where the engine can — and, where + * it cannot, the owner told once the reach outgrew the rows. */ io: { bytes(url: string): Uint8Array | undefined; loading(url: string): boolean; request(urls: readonly string[], ahead: boolean): void; update(rows: PlacementRows, from: number, to: number): void; + grow?: (from: PlacementRows, to: PlacementRows) => void; outgrown?: () => void; }, /** The frame's one integration budget (`FrameBudget`, the arrival queue's). */ @@ -141,8 +145,11 @@ export function createPartitionCells(inputs: Inputs) { const beyond = local.reach > Math.max(sized, wanted); if (beyond) wanted = local.reach; if (beyond || (!short && sized < Infinity && outstretched(boxes.stretch, stretched))) { - short = true; - io.outgrown?.(); + if (io.grow) resize(Math.max(sized, wanted), io.grow); + else { + short = true; + io.outgrown?.(); + } } plan.leave.forEach(leave); waiting = 0; @@ -165,7 +172,7 @@ export function createPartitionCells(inputs: Inputs) { } if (ask.length) io.request(ask, ahead); } - flush(io.update); + touched.flush(io.update); return later; }, /** Before the engines read the rows: sizes them for the camera at `eye` or the largest reach a @@ -179,15 +186,7 @@ export function createPartitionCells(inputs: Inputs) { const local = inCellFrame(hostWorldChainInto(rootWorld, root), eye, reach); const plan = planCells(boxes(), local.eye, local.reach, new Set(held.keys())); plan.leave.forEach(leave); - const bound = owned ? Math.max(local.reach, wanted) : Infinity; - if (sized < Infinity) { - // Sized for any place of the parents; rows that hold every node are never short. - stretched = sizedStretch(boxes.stretch); - const rows = residentRows(partition.cells, bound, stretched); - sizeRows(meshes, rows); - sized = holdsEvery(rows, cells) ? Infinity : bound; - short = false; - } + if (sized < Infinity) resize(owned ? Math.max(local.reach, wanted) : Infinity); const { visible } = plan; const bodies = await Promise.all(visible.map((cell) => read(cells[cell].url))); visible.forEach((cell, at) => place(cell, bodies[at])); diff --git a/packages/sdk-browser/src/scene/partition/rows.ts b/packages/sdk-browser/src/scene/partition/rows.ts index 20d2186850..3a14d7e5a0 100644 --- a/packages/sdk-browser/src/scene/partition/rows.ts +++ b/packages/sdk-browser/src/scene/partition/rows.ts @@ -3,7 +3,8 @@ * one host mesh per primitive whose association carries an instance buffer (`placement/rows.ts`): * a cell's node takes the same row in every one of them, and gives it back — parked — when its * cell leaves. The buffers are sized when a session opens, before its engines read them - * (`sizeRows`), for every node its reach can hold at once (`plan.ts`): none grows while it draws. + * (`sizeRows`), for every node its reach can hold at once (`plan.ts`): only a reach or a parent's + * stretch past that grows them, in place, under the engine that draws them. */ import { createPlacementRows, @@ -43,14 +44,23 @@ export function placedMesh(links: readonly RowLink[]): PlacedMesh { export const capacityOf = (mesh: PlacedMesh) => mesh.links[0]?.placements?.capacity ?? 0; /** Sizes every buffer of each mesh rank of `needed` to hold that many rows at least, its rows - * kept: before its session's engines read them, never under one that draws. */ -export function sizeRows(meshes: ReadonlyMap, needed: Map) { + * kept: before its session's engines read them, or — `grown` handed each buffer replaced — under + * one that grows them in place (`placement/growth.ts`). */ +export function sizeRows( + meshes: ReadonlyMap, + needed: Map, + grown?: (from: PlacementRows, to: PlacementRows) => void, +) { for (const [rank, rows] of needed) { const mesh = meshes.get(rank); if (!mesh) continue; // placing its cell refuses it (`PREPARED_SCENE_MISMATCH`) const held = capacityOf(mesh); if (rows <= held) continue; - for (const link of mesh.links) link.placements = growPlacementRows(link.placements!, rows); + for (const link of mesh.links) { + const from = link.placements!; + link.placements = growPlacementRows(from, rows); + grown?.(from, link.placements); + } for (let row = capacityOf(mesh) - 1; row >= held; row--) mesh.free.push(row); } } @@ -83,3 +93,21 @@ export function releaseRow(mesh: PlacedMesh, row: number) { for (const link of mesh.links) link.placements!.live[row] = 0; mesh.free.push(row); } + +/** The range of rows each buffer had written since the last `flush`, which tells the engine. */ +export function createTouchedRows() { + const touched = new Map(); + return { + touch(link: RowLink, row: number) { + const range = touched.get(link) ?? { from: row, to: row }; + range.from = Math.min(range.from, row); + range.to = Math.max(range.to, row); + touched.set(link, range); + }, + flush(update: (rows: PlacementRows, from: number, to: number) => void) { + for (const [link, { from, to }] of touched) update(link.placements!, from, to); + touched.clear(); + }, + clear: () => touched.clear(), + }; +} diff --git a/packages/sdk-browser/src/world/scene/partitionFrame.ts b/packages/sdk-browser/src/world/scene/partitionFrame.ts index 6566b0b22e..96dfc3bb00 100644 --- a/packages/sdk-browser/src/world/scene/partitionFrame.ts +++ b/packages/sdk-browser/src/world/scene/partitionFrame.ts @@ -49,8 +49,9 @@ type Inputs = { /** The frame's one integration budget, the arrival queue's: cells spend from it before the * drain spends the rest. */ budget: FrameBudget; - /** Asked once the camera's reach outgrew the rows sized at open: the owner opens the session - * again, sized for it. Absent, a cell past those rows waits. */ + /** Asked once the camera's reach, or a parent's stretch, outgrew the rows sized at open on an + * engine that grows no buffer in place: the owner opens the session again, sized for it. + * Absent, a cell past those rows waits. */ renew?: () => void; }; @@ -87,6 +88,7 @@ export function createPartitionFrame(inputs: Inputs) { request, update: (...range: Parameters>) => backend.updatePlacements?.(...range), + grow: backend.growPlacements?.bind(backend), outgrown: renew, }; const reach = cellReach(camera); diff --git a/tests/integration/world-partition-parents.test.ts b/tests/integration/world-partition-parents.test.ts index 424a6e7c9b..077e5278fe 100644 --- a/tests/integration/world-partition-parents.test.ts +++ b/tests/integration/world-partition-parents.test.ts @@ -97,3 +97,55 @@ test( assert.ok(near > 100, `${near} nodes within reach`); }, ); + +test( + 'a page that shrinks the parent of placed nodes grows their rows in place: no reopen, no wait', + { skip: !existsSync(compiler) }, + async (t) => { + const root = await mkdtemp(join(tmpdir(), 'world-partition-shrink-')); + t.after(() => rm(root, { recursive: true, force: true })); + const pointer = await compiled(root, world(384, 'district')); + machine(t, pointer); + const model = await loadModel(pointer.href, { textureSource: 'host', scope: 'full' }); + const [cells] = model.record.scene.partitions; + const streamer = createPageStreamer(cells.pages, model.record.base); + // The camera stands where the middle of the district will be once it is four times smaller. + const camera = hostFramingCamera(60, 16 / 9, 0.1, 300); + camera.position.set(48 * SPACING, 2, 48 * SPACING); + camera.updateMatrixWorld(); + await primePartitions([cells], camera, streamer, true); + const scene = new Object3D(); + scene.add(model); + let renewed = 0, + grown = 0; + const frame = createPartitionFrame({ + partitions: [cells], + streamer, + camera, + active: () => + ({ + updatePlacements() {}, + growPlacements: () => void grown++, + }) as Partial as RenderBackend, + renew: () => void renewed++, + budget: { admits: () => true, spend() {} }, + })!; + const settle = async () => { + for (let step = 0; step < 16; step++) { + frame(); + if (!(await frame.pending())) break; + } + return cells.stats(); + }; + const before = await settle(); + const district = model.getObjectByName('district')!; + district.scale.set(0.25, 0.25, 0.25); + const poses = createWorldPoses(); + poses.moved(district); + poses.apply(scene, new Map(), new Map(), () => {}); + const after = await settle(); + assert.ok(grown > 0 && after.rows > before.rows, JSON.stringify({ before, after, grown })); + assert.deepEqual([after.waiting, renewed], [0, 0]); + assert.ok(after.held > before.held, 'four times as many cells are within reach'); + }, +); From 51a8055fe2331aecaa224aedecc867587d526cae Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 04:56:10 +0200 Subject: [PATCH 05/11] refactor(streaming): the session owns the frame budget, the arrival queue only spends it; in-place row growth keeps headroom (#404) --- bench/perf/browser/arrival-admission.perf.ts | 12 +++++++----- .../src/page/integration/arrivalQueue.test.ts | 16 +++++++++------- .../src/page/integration/arrivalQueue.ts | 18 +++++++----------- .../sdk-browser/src/scene/partition/cells.ts | 17 +++++++++-------- .../sdk-browser/src/scene/partition/sizing.ts | 9 ++++++--- .../sdk-browser/src/webgpu/frame/cost.test.ts | 3 ++- .../sdk-browser/src/webgpu/row/claims.test.ts | 4 ++-- packages/sdk-browser/src/webgpu/row/claims.ts | 5 +++-- .../sdk-browser/src/world/render/hostFrame.ts | 3 ++- .../sdk-browser/src/world/render/render.ts | 6 +++++- .../src/world/render/triangles.test.ts | 3 ++- .../sdk-browser/src/world/session/backends.ts | 14 ++++---------- .../sdk-browser/src/world/session/prepare.ts | 7 ++++++- 13 files changed, 64 insertions(+), 53 deletions(-) diff --git a/bench/perf/browser/arrival-admission.perf.ts b/bench/perf/browser/arrival-admission.perf.ts index 717716ac9c..102860825f 100644 --- a/bench/perf/browser/arrival-admission.perf.ts +++ b/bench/perf/browser/arrival-admission.perf.ts @@ -2,6 +2,10 @@ import { findAdmissible } from '../../../packages/sdk-browser/src/streaming/queueOrder.ts'; import { sortStreamJobs } from '../../../packages/sdk-browser/src/streaming/queueOrder.fixture.ts'; import { createArrivalQueue } from '../../../packages/sdk-browser/src/page/integration/arrivalQueue.ts'; +import { createFrameBudget } from '../../../packages/sdk-browser/src/page/integration/frameBudget.ts'; + +/** The frame budget the engine's queue spends, opened before each drain as a frame opens it. */ +const frame = createFrameBudget(2); import { graine, mesure, stress, rapport } from '../../core/index.ts'; import { referenceAdmission, @@ -63,8 +67,6 @@ interface FabriqueFile { url: string, array: Uint32Array, ): unknown; - /** Opens the frame's budget: a frame opens it before its drain. */ - open?(): void; drain(): number; } @@ -80,7 +82,7 @@ function arrivees(fabrique: (byteBudget: number, countBudget: number) => Fabriqu for (let i = 0; i < 5000; i++) file.queue(cibles[i % 8], `page-${i % 900}.bin`, octets); let livrs = 0; for (let d = 0; d < 4; d++) { - file.open?.(); + frame.open(); livrs += file.drain(); } return { livrees, livrs }; @@ -102,14 +104,14 @@ const resArrivees = await mesure({ name: 'arrival queue', fichier: 'packages/sdk-browser/src/page/integration/arrivalQueue.ts', cas: [{ name: '5 000 arrivals on 8 targets', input: null, size: 5000 }], - calcul: () => arrivees(createArrivalQueue), + calcul: () => arrivees((bytes, count) => createArrivalQueue(bytes, count, frame)), attendu: () => arrivees(referenceArrivalQueue), options: { tours: 60, budgetMs: 1500 }, }); await stress({ name: 'createArrivalQueue extremes', - calcul: (size: number) => createArrivalQueue(size, 4096), + calcul: (size: number) => createArrivalQueue(size, 4096, frame), extremes: [ { name: 'small', input: 4096 }, { name: 'large', input: 1 << 28 }, diff --git a/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts b/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts index 6b3ba17e3a..5c234337cb 100644 --- a/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts +++ b/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts @@ -98,9 +98,10 @@ test('the default time budget yields at its boundary and resumes in arrival orde now += url === 'slow' ? 3 : 1; }, }; - const queue = createArrivalQueue(1 << 20, 64); + const budget = createFrameBudget(2); + const queue = createArrivalQueue(1 << 20, 64, budget); for (const url of ['p0', 'p1', 'slow', 'p3']) queue.queue(receiver, url, new Uint32Array(1)); - const frame = () => (queue.open(), queue.drain()); + const frame = () => (budget.open(), queue.drain()); assert.equal(frame(), 2, 'two 1 ms deliveries reach the default 2 ms ceiling'); assert.deepEqual(accepted, ['p0', 'p1']); assert.equal(queue.pending, 2); @@ -119,11 +120,11 @@ test('a drain spends what its frame left of the budget, and never opens it again const budget = createFrameBudget(2); const queue = createArrivalQueue(1 << 20, 64, budget); queue.queue({ acceptPage() {} }, 'p0', new Uint32Array(1)); - queue.open(); + budget.open(); budget.spend(); now = 3; // the frame's cells spent it assert.equal(queue.drain(), 0, 'the page waits for the next frame'); - queue.open(); + budget.open(); assert.equal(queue.drain(), 1); }); @@ -176,11 +177,12 @@ test('the cells a frame places and the pages it drains spend one budget, on one }; const accepted: string[] = []; const receiver = { acceptPage: (url: string) => void (accepted.push(url), (now += 1)) }; - const queue = createArrivalQueue(1 << 20, 64); + const budget = createFrameBudget(2); + const queue = createArrivalQueue(1 << 20, 64, budget); for (const url of ['p0', 'p1']) queue.queue(receiver, url, new Uint32Array(1)); const frame = () => { - queue.open(); - cells.frame([0, 0.5, 0.5], 100, io, queue); + budget.open(); + cells.frame([0, 0.5, 0.5], 100, io, budget); queue.drain(); return [cells.stats().held, accepted.length]; }; diff --git a/packages/sdk-browser/src/page/integration/arrivalQueue.ts b/packages/sdk-browser/src/page/integration/arrivalQueue.ts index e04a50d2b1..986d907617 100644 --- a/packages/sdk-browser/src/page/integration/arrivalQueue.ts +++ b/packages/sdk-browser/src/page/integration/arrivalQueue.ts @@ -22,13 +22,13 @@ * enter. * * That ceiling is the frame's one integration budget (`./frameBudget.ts`, CONTRIBUTING.md - * §Streaming rule 4), handed in by the session: `open` starts its clock, what else a frame + * §Streaming rule 4), handed in by the session: its frame opens it, what else the frame * integrates before the drain — the cells of a partitioned scene (`scene/partition/cells.ts`) — * spends from it, the drain spends the rest, and the WebGPU row records written after it * (`webgpu/row/claims.ts`) whatever is left. */ import { planArrival, planArrivalHere, type ArrivalPlan } from './host.ts'; -import { createFrameBudget, type FrameClock } from './frameBudget.ts'; +import type { FrameBudget } from './frameBudget.ts'; /** What the queue requires of a target: a way to receive a page before the next render, and * the catalogue sheet for the request — the integers the plan is deduced from, and nothing else. */ @@ -58,14 +58,14 @@ const MAX_PLAN_WAITS = 2; export function createArrivalQueue( byteBudget: number, countBudget: number, - budget: FrameClock = createFrameBudget(2), + /** The frame's one integration budget, which its frame opens (`./frameBudget.ts`). */ + budget: FrameBudget, ) { const items: Arrival[] = []; // The same page may be seen by the cache then by the end of its download: while it waits, // it is queued only once per target. The wait is forgotten as soon as it is delivered. const waiting = new Map>(); let head = 0; - const { admits, spend } = budget; /** Delivers an arrival with its plan, and removes its address from the waiting pages. */ const deliver = (item: Arrival) => { item.done = true; @@ -73,10 +73,6 @@ export function createArrivalQueue( item.target.acceptPage?.(item.url, item.array, item.plan); }; return { - /** Opens the frame's budget: every integration of the frame shares its clock. */ - open: () => void budget.open(), - admits, - spend, /** Arrivals still waiting to drain. */ get pending() { return items.length - head; @@ -119,14 +115,14 @@ export function createArrivalQueue( }, /** * Delivers arrivals up to the budget — at most `countBudget` pages, `byteBudget` index bytes - * and what the frame's budget, opened by `open`, still admits. The following render + * and what the frame's budget still admits. The following render * synchronizes residency; calling `syncResident` here could draw a second frame. Returns the * pages delivered. */ drain() { let bytes = 0, count = 0; - while (head < items.length && bytes < byteBudget && count < countBudget && admits()) { + while (head < items.length && bytes < byteBudget && count < countBudget && budget.admits()) { const item = items[head]; if (!item.ready) { // Order is priority: an arrival whose plan has not returned holds back those that @@ -141,7 +137,7 @@ export function createArrivalQueue( } head++; deliver(item); - spend(); + budget.spend(); bytes += item.array.byteLength; count++; } diff --git a/packages/sdk-browser/src/scene/partition/cells.ts b/packages/sdk-browser/src/scene/partition/cells.ts index 2462be3326..381d5f493e 100644 --- a/packages/sdk-browser/src/scene/partition/cells.ts +++ b/packages/sdk-browser/src/scene/partition/cells.ts @@ -27,6 +27,7 @@ import { holdsEvery, outstretched, residentRows, sizedStretch, type Stretch } fr import { capacityOf, createTouchedRows, releaseRow, rowLocal, rowsFree } from './rows.ts'; import { sizeRows, takeRow, type PlacedMesh } from './rows.ts'; +type Grow = (from: PlacementRows, to: PlacementRows) => void; type Placement = { mesh: PlacedMesh; row: number; parent: Object3D; local: Float64Array }; type Inputs = { partition: TablePartition; @@ -100,11 +101,10 @@ export function createPartitionCells(inputs: Inputs) { for (const placement of placements) if (placement.parent === node) write(placement); } }; - /** Sizes the rows for a reach `bound` and the parents' stretch now; `grown` hands each buffer - * replaced to the engine drawing it, absent before the engines read them. */ - const resize = (bound: number, grown?: (from: PlacementRows, to: PlacementRows) => void) => { - // Sized for any place of the parents; rows that hold every node are never short. - stretched = sizedStretch(boxes.stretch); + /** Sizes the rows for any place of the parents within a reach `bound` and their stretch now, + * widened `by`; `grown` hands each buffer replaced to its engine, absent before one reads it. */ + const resize = (bound: number, grown?: Grow, by = 1) => { + stretched = sizedStretch(boxes.stretch, by); const rows = residentRows(partition.cells, bound, stretched); sizeRows(meshes, rows, grown); sized = holdsEvery(rows, cells) ? Infinity : bound; @@ -133,10 +133,10 @@ export function createPartitionCells(inputs: Inputs) { loading(url: string): boolean; request(urls: readonly string[], ahead: boolean): void; update(rows: PlacementRows, from: number, to: number): void; - grow?: (from: PlacementRows, to: PlacementRows) => void; + grow?: Grow; outgrown?: () => void; }, - /** The frame's one integration budget (`FrameBudget`, the arrival queue's). */ + /** The frame's one integration budget (`FrameBudget`, the session's). */ budget: { admits(): boolean; spend(): void }, ) { followParents(); @@ -145,7 +145,8 @@ export function createPartitionCells(inputs: Inputs) { const beyond = local.reach > Math.max(sized, wanted); if (beyond) wanted = local.reach; if (beyond || (!short && sized < Infinity && outstretched(boxes.stretch, stretched))) { - if (io.grow) resize(Math.max(sized, wanted), io.grow); + // Twice what is asked, as buffers grow: an ongoing zoom or shrink resizes O(log) times. + if (io.grow) resize(Math.max(2 * sized, wanted), io.grow, 2); else { short = true; io.outgrown?.(); diff --git a/packages/sdk-browser/src/scene/partition/sizing.ts b/packages/sdk-browser/src/scene/partition/sizing.ts index f88ca0df3e..64564bc24a 100644 --- a/packages/sdk-browser/src/scene/partition/sizing.ts +++ b/packages/sdk-browser/src/scene/partition/sizing.ts @@ -102,10 +102,13 @@ export function residentRows( * page sets: a parent turned and moved back keeps its stretch within it. */ const SLACK = 2 ** -20; -/** The stretch rows are sized for: each parent's `now`, widened by the rounding. */ -export const sizedStretch = (now: ReadonlyMap) => +/** The stretch rows are sized for: each parent's `now`, widened by the rounding and `by` more. */ +export const sizedStretch = (now: ReadonlyMap, by = 1) => new Map( - [...now].map(([rank, [least, most]]) => [rank, [least * (1 - SLACK), most * (1 + SLACK)]]), + [...now].map(([rank, [least, most]]) => [ + rank, + [(least * (1 - SLACK)) / by, most * (1 + SLACK) * by], + ]), ) as ReadonlyMap; /** Whether a parent stretches its cells' frame, `now`, past what rows `sized` for hold: a least diff --git a/packages/sdk-browser/src/webgpu/frame/cost.test.ts b/packages/sdk-browser/src/webgpu/frame/cost.test.ts index dc80e53231..08c4f9a811 100644 --- a/packages/sdk-browser/src/webgpu/frame/cost.test.ts +++ b/packages/sdk-browser/src/webgpu/frame/cost.test.ts @@ -4,6 +4,7 @@ import * as G from '../../host/graph/graph.fixture.ts'; import { webgpuPagesBackend } from '../pages/pages.ts'; import { exactPagesBackend } from '../../../../../bench/witnesses/exact/backend.ts'; import { createArrivalQueue } from '../../page/integration/arrivalQueue.ts'; +import { createFrameBudget } from '../../page/integration/frameBudget.ts'; import { collectClusterPages } from '../../page/selection/selection.ts'; import { packDagSelection } from '../../gpu/dag/selection.ts'; import { mockGpu } from '../../../../../tests/kit/gpu/mockGpu.ts'; @@ -86,7 +87,7 @@ test('queued page arrivals are integrated by the next render without a second GP const view = camera(); backend.render(view); mock.submits.length = 0; - const arrivals = createArrivalQueue(512 * 1024, 64); + const arrivals = createArrivalQueue(512 * 1024, 64, createFrameBudget(2)); arrivals.queue(backend, '0', fixture.indices.get('0')!); assert.equal(arrivals.drain(), 1); assert.equal(mock.submits.length, 0, 'accepting bytes must not render an image'); diff --git a/packages/sdk-browser/src/webgpu/row/claims.test.ts b/packages/sdk-browser/src/webgpu/row/claims.test.ts index e323ccb32e..30d1d0e972 100644 --- a/packages/sdk-browser/src/webgpu/row/claims.test.ts +++ b/packages/sdk-browser/src/webgpu/row/claims.test.ts @@ -49,7 +49,7 @@ test('the rows a frame writes spend what its arrivals left of its one budget, on const receiver = { acceptPage: () => void (now += 1) }; for (const url of ['p0', 'p1']) arrivals.queue(receiver, url, new Uint32Array(1)); const claims = owed(); - arrivals.open(); + budget.open(); assert.equal(arrivals.drain(), 2, 'two 1 ms arrivals spend the 2 ms frame'); serveClaims( claims, @@ -58,7 +58,7 @@ test('the rows a frame writes spend what its arrivals left of its one budget, on budget, ); assert.equal(claims.count, 3, 'one row goes through, three wait for the next frame'); - arrivals.open(); + budget.open(); arrivals.drain(); serveClaims( claims, diff --git a/packages/sdk-browser/src/webgpu/row/claims.ts b/packages/sdk-browser/src/webgpu/row/claims.ts index 0c7a7b0295..566c268354 100644 --- a/packages/sdk-browser/src/webgpu/row/claims.ts +++ b/packages/sdk-browser/src/webgpu/row/claims.ts @@ -78,8 +78,9 @@ export function serveClaims( break; } served++; - budget?.spend(); - if (budget && !budget.admits()) break; + if (!budget) continue; + budget.spend(); + if (!budget.admits()) break; continue; } served++; diff --git a/packages/sdk-browser/src/world/render/hostFrame.ts b/packages/sdk-browser/src/world/render/hostFrame.ts index 9d2b06c9a3..537752e69f 100644 --- a/packages/sdk-browser/src/world/render/hostFrame.ts +++ b/packages/sdk-browser/src/world/render/hostFrame.ts @@ -68,7 +68,7 @@ export function createExplorerHostFrame(session: ExplorerSession, inputs: Inputs camera, active: () => state.active, renew: options.onRowsOutgrown, - budget: streaming.arrivals, + budget: frameBudget, }); const render = createExplorerRender(session, { check, @@ -79,6 +79,7 @@ export function createExplorerHostFrame(session: ExplorerSession, inputs: Inputs lookAtTarget, setPose, streaming, + frameBudget, drawBackend, ensureTarget, directGpu, diff --git a/packages/sdk-browser/src/world/render/render.ts b/packages/sdk-browser/src/world/render/render.ts index 165e9d0f24..ec1045de08 100644 --- a/packages/sdk-browser/src/world/render/render.ts +++ b/packages/sdk-browser/src/world/render/render.ts @@ -8,6 +8,7 @@ import type { BoundTarget, ExplorerHostState } from './hostState.ts'; import type { WebglRenderTarget } from '../../webgl/core/renderTarget.ts'; import type { ExplorerSession } from '../session/session.ts'; import type { createExplorerStreaming } from '../scene/streaming.ts'; +import type { FrameClock } from '../../page/integration/frameBudget.ts'; import type { createPageStreamer } from '../../streaming/pageStreamer.ts'; import type { EngineProfiler } from '../../diagnostic/telemetry.ts'; import type { ComparisonLayout } from '../../measurement/comparison.ts'; @@ -26,6 +27,8 @@ type Inputs = { lookAtTarget: { x: number; y: number; z: number }; setPose: (pose: CameraPose) => void; streaming: ReturnType; + /** The frame's one integration budget, opened before the cells and the arrivals spend it. */ + frameBudget: FrameClock; drawBackend: (backend: RenderBackend, target: WebglRenderTarget | null) => void; ensureTarget: (target?: BoundTarget) => BoundTarget; directGpu: boolean; @@ -67,6 +70,7 @@ export function createExplorerRender(session: ExplorerRenderSession, inputs: Inp lookAtTarget, setPose, streaming, + frameBudget, drawBackend, ensureTarget, directGpu, @@ -96,7 +100,7 @@ export function createExplorerRender(session: ExplorerRenderSession, inputs: Inp if (pose) setPose(pose); // One integration budget per frame: the cells placed, then the arrivals drained, both // outside the frame they would have lengthened. - streaming.arrivals.open(); + frameBudget.open(); followCells?.(); guides?.follow(); const arrivalStart = performance.now(); diff --git a/packages/sdk-browser/src/world/render/triangles.test.ts b/packages/sdk-browser/src/world/render/triangles.test.ts index 8288b98f4d..942e244297 100644 --- a/packages/sdk-browser/src/world/render/triangles.test.ts +++ b/packages/sdk-browser/src/world/render/triangles.test.ts @@ -37,7 +37,8 @@ function harness(options: { directGpu: boolean; counted?: number | null; order?: camera: {} as never, lookAtTarget: { x: 0, y: 0, z: 0 }, setPose: () => {}, - streaming: { arrivals: { open: () => {}, drain: () => {} } } as never, + streaming: { arrivals: { drain: () => {} } } as never, + frameBudget: { open: () => 0, admits: () => true, spend() {} }, drawBackend: () => options.order?.push('draw'), ensureTarget: ((target?: unknown) => target) as never, directGpu: options.directGpu, diff --git a/packages/sdk-browser/src/world/session/backends.ts b/packages/sdk-browser/src/world/session/backends.ts index e897dda32b..d164560ffe 100644 --- a/packages/sdk-browser/src/world/session/backends.ts +++ b/packages/sdk-browser/src/world/session/backends.ts @@ -1,11 +1,5 @@ import type { HostTexture } from '../../host/resources.ts'; -import { - ARRIVAL_BUDGET_MS, - DEFAULT_CLEAR_COLOR, - isCancelled, - pixelRatioOf, -} from '../../backend/common.ts'; -import { createFrameBudget } from '../../page/integration/frameBudget.ts'; +import { DEFAULT_CLEAR_COLOR, isCancelled, pixelRatioOf } from '../../backend/common.ts'; import { createSceneLightStore, dagWarningsDiagnostic } from '../../../../sdk-core/src/index.ts'; import { createSceneProxyReader } from '../../scene/proxyLoad.ts'; import { createTextureLevelReader } from '../../texture/levelReader.ts'; @@ -30,6 +24,7 @@ type Inputs = { backends: RenderBackend[]; /** Manifest url base: that is what locates the resident-proxy cache object. */ base: string; + /** The session's one integration budget per frame. */ frameBudget: BackendContext['frameBudget']; }; export async function prepareExplorerBackends(session: ExplorerSession, inputs: Inputs) { @@ -77,7 +72,6 @@ export async function prepareExplorerBackends(session: ExplorerSession, inputs: kind: 'preparation', ...dagWarnings.context, }); - const frameBudget = createFrameBudget(ARRIVAL_BUDGET_MS); const context: BackendContext = { source, metadata, @@ -132,7 +126,7 @@ export async function prepareExplorerBackends(session: ExplorerSession, inputs: readTextureLevel: createTextureLevelReader(metadata.textures, base, signal), sceneLights, importedLightIds, - frameBudget, + frameBudget: inputs.frameBudget, }; for (const factory of factories) { const backend = factory(context); @@ -202,5 +196,5 @@ export async function prepareExplorerBackends(session: ExplorerSession, inputs: if (!backends.length) throw new Error('No backend'); // The engines' host tables, which follow the view, come out of the decoded pages' CPU share. streamer.reserve(() => backends.reduce((bytes, b) => bytes + (b.hostTableBytes?.() ?? 0), 0)); - return { viewport, context, frameBudget }; + return { viewport, context }; } diff --git a/packages/sdk-browser/src/world/session/prepare.ts b/packages/sdk-browser/src/world/session/prepare.ts index d0be514c45..4b4143b610 100644 --- a/packages/sdk-browser/src/world/session/prepare.ts +++ b/packages/sdk-browser/src/world/session/prepare.ts @@ -8,6 +8,8 @@ import { createExplorerCamera } from '../camera/camera.ts'; import { createExplorerPageSources } from './pageSources.ts'; import { loadPreparedScene } from '../scene/scene.ts'; import { primePartitions } from '../scene/partitionFrame.ts'; +import { ARRIVAL_BUDGET_MS } from '../../backend/common.ts'; +import { createFrameBudget } from '../../page/integration/frameBudget.ts'; import type { ExplorerSession } from './session.ts'; import type { WebglSurface } from '../../webgl/core/surface.ts'; import type { HostCamera } from '../../camera/world.ts'; @@ -143,7 +145,9 @@ export async function prepareExplorer(session: ExplorerSession, inputs: Inputs) cells: loadedScene.partitions.map((cells) => cells.stats()), }); } - const { viewport, context, frameBudget } = await prepareExplorerBackends(session, { + // The frame's one integration budget: cells, arrivals, then the engine's row records. + const frameBudget = createFrameBudget(ARRIVAL_BUDGET_MS); + const { viewport, context } = await prepareExplorerBackends(session, { source, sceneLightingSource: loadedScene.sceneLightingSource, associations: loadedScene.associations, @@ -155,6 +159,7 @@ export async function prepareExplorer(session: ExplorerSession, inputs: Inputs) factories: choice.factories, backends, base, + frameBudget, }); return { source, From 5ac46ad6ff70b9fd3712f2500882c12afce5c369 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 05:09:42 +0200 Subject: [PATCH 06/11] fix(streaming): the frame budget counts integration time only; row growth doubles what outgrew; the orthographic reach covers a negative near (#404) --- bench/perf/browser/arrival-admission.perf.ts | 6 +- docs/FORMAT.md | 18 ++-- packages/sdk-browser/src/backend/types.ts | 4 +- .../src/page/integration/arrivalQueue.test.ts | 17 ++-- .../src/page/integration/frameBudget.ts | 17 ++-- .../src/scene/partition/cells.fixture.ts | 95 +++++++++++++++++++ .../src/scene/partition/cells.test.ts | 94 +----------------- .../sdk-browser/src/scene/partition/cells.ts | 26 ++--- .../src/scene/partition/plan.test.ts | 4 +- .../sdk-browser/src/scene/partition/plan.ts | 6 +- .../sdk-browser/src/webgpu/row/claims.test.ts | 37 ++++---- packages/sdk-browser/src/webgpu/row/claims.ts | 8 +- packages/sdk-browser/src/webgpu/row/slots.ts | 4 +- packages/sdk-browser/src/webgpu/row/sync.ts | 4 +- .../sdk-browser/src/world/render/render.ts | 3 +- .../src/world/render/triangles.test.ts | 3 +- .../src/world/scene/partitionFrame.test.ts | 74 +++++---------- .../src/world/scene/partitionFrame.ts | 4 +- .../sdk-browser/src/world/session/backends.ts | 2 +- tests/integration/world-partition.test.ts | 2 +- 20 files changed, 213 insertions(+), 215 deletions(-) create mode 100644 packages/sdk-browser/src/scene/partition/cells.fixture.ts diff --git a/bench/perf/browser/arrival-admission.perf.ts b/bench/perf/browser/arrival-admission.perf.ts index 102860825f..64b5d2d8e2 100644 --- a/bench/perf/browser/arrival-admission.perf.ts +++ b/bench/perf/browser/arrival-admission.perf.ts @@ -3,15 +3,15 @@ import { findAdmissible } from '../../../packages/sdk-browser/src/streaming/queu import { sortStreamJobs } from '../../../packages/sdk-browser/src/streaming/queueOrder.fixture.ts'; import { createArrivalQueue } from '../../../packages/sdk-browser/src/page/integration/arrivalQueue.ts'; import { createFrameBudget } from '../../../packages/sdk-browser/src/page/integration/frameBudget.ts'; - -/** The frame budget the engine's queue spends, opened before each drain as a frame opens it. */ -const frame = createFrameBudget(2); import { graine, mesure, stress, rapport } from '../../core/index.ts'; import { referenceAdmission, referenceArrivalQueue, } from '../../oracles/browser/arrival-admission.ts'; +/** The frame budget the engine's queue spends, opened before each drain as a frame opens it. */ +const frame = createFrameBudget(2); + /** The fields `findAdmissible`/`sortStreamJobs` read: a lighter shape than the engine's `Job`. */ interface TravailAdmission { url: string; diff --git a/docs/FORMAT.md b/docs/FORMAT.md index dd0a079219..bd72181f63 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -216,19 +216,20 @@ there would carry, proven against the host loader on `site/assets/examples/ten-t move a core parent (`getObjectByName`): the rows under it are rewritten, and the cell's boxes are its parents' boxes under their current matrices (`boxes.ts`), so the cell is read where its placements stand, at the distance of its nearest box. A cell is read while the camera can draw any of it: its **reach** is the far plane met on the frustum's -diagonal, `far·√w`, with `w = 1 + tan²(fov/2)·(1 + aspect²)` the off-axis stretch of the frustum. +diagonal, `far·√w`, with `w = 1 + (tan(fov/2)/zoom)²·(1 + aspect²)` the off-axis stretch of the frustum. The error target does not shorten it: nothing coarser stands for a cell that is not read (the proxy of #23), so an object dropped below the target would be missing from the image, not -replaced. An orthographic camera reads every cell. Before its first frame a session reads the cells within +replaced. An orthographic camera reads up to the far corner of its zoomed box. Before its first frame a session reads the cells within the reach of the camera the page draws with (a world hands its camera to the session it opens; a bare explorer, which has none, reads for its framing camera, which sees the whole scene), and nothing else. Then, before every frame, cells within the reach are asked for nearest first, those within `1.25 × reach` at the prefetch priority, and a read cell leaves once its box is past `1.5 × reach` (`AHEAD` and `KEEP` in `plan.ts`): margins of the reach, never of the cell, so a cell cut wider than the view is kept only while its box meets that sphere. The cells are read through the session's page streamer -— one request queue — and placed within the frame's one integration budget, the arrival queue's +— one request queue — and placed within the frame's one integration budget, the session's (`ARRIVAL_BUDGET_MS`, `FrameBudget`): its clock starts once per frame, the cells spend from it -first and the page arrivals drain the rest; the first integration of a frame always goes through. +first, the page arrivals drain from what is left, then the WebGPU row records; the first +integration of a frame always goes through. The rows are sized once, when a session opens and before its engines read them, for every placement its camera's reach can hold at once **wherever the page moves the core parents** (`sizing.ts`). A held cell has a box within `1.5 × reach` of the eye; the boxes one parent carries @@ -239,10 +240,11 @@ move together, so those held at once are close in that parent's own frame — ce parent, summed over the parents and never past every placement, bounds each mesh's rows — set by the reach, the cells' size and the parents' count, not by the world or where its parents stand. Parents moved together never run the rows short, so they never reopen the session nor leave a -placement undrawn (CONTRIBUTING.md §Streaming rule 10). Nothing grows under a drawing engine: a -camera whose reach later outgrows the rows, or a parent scaled down or stretched more unevenly -than at opening (moved, turned or scaled up, it holds), asks -the session's owner, once, to open it again sized for them (the world does). A session no owner +placement undrawn (CONTRIBUTING.md §Streaming rule 10). A camera whose reach later outgrows the +rows, or a parent scaled down or stretched more unevenly than at opening (moved, turned or scaled +up, it holds), grows them in place, to twice what is asked, on an engine that follows the growth +contract (`placement/growth.ts`); on one that does not, it asks the session's owner, once, to +open it again sized for them (the world does). A session no owner can open again (a bare explorer) sizes its rows for every placement, and rows that hold every placement never ask. A session drawing on demand draws again, camera still, until the cells it asked for within reach are read and placed. A partitioned scene is not diff --git a/packages/sdk-browser/src/backend/types.ts b/packages/sdk-browser/src/backend/types.ts index cad091a36f..71733af095 100644 --- a/packages/sdk-browser/src/backend/types.ts +++ b/packages/sdk-browser/src/backend/types.ts @@ -193,9 +193,7 @@ export interface BackendContext { /** Host-owned, validated page reader for the initial complete GPU fallback. */ readPage?: (url: string) => Promise; readGeometryPage?: (url: string) => Promise; - /** The session's one integration budget per frame (CONTRIBUTING.md §Streaming rule 4): its - * frame opens it, the cells and the arrival drain spend from it, then the engine's own - * per-frame integration. Absent — an engine driven with no session frame —, nothing bounds it. */ + /** The session's one integration budget per frame (`frameBudget.ts`); absent, nothing bounds it. */ frameBudget?: import('../page/integration/frameBudget.ts').FrameClock; } export type BackendFactory = (context: BackendContext) => RenderBackend; diff --git a/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts b/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts index 5c234337cb..503ec8860d 100644 --- a/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts +++ b/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts @@ -70,12 +70,13 @@ test('a page already waiting for a target is queued once, and each target keeps // Delivery preserves exact pages and order despite removing implicit rendering. test('many duplicate targets across a drain deliver exactly like the reference', () => { - function arrivals(create: typeof createArrivalQueue) { + type Queue = Pick, 'queue' | 'drain'>; + function arrivals(create: (byteBudget: number, countBudget: number) => Queue) { const delivered: string[] = []; const targets = Array.from({ length: 8 }, (_, c) => ({ acceptPage: (url: string) => delivered.push(`${c}:${url}`), })); - const queue = create(1 << 20, 4096, createFrameBudget(Infinity)); + const queue = create(1 << 20, 4096); const bytes = new Uint32Array(4); // Round-robin over the eight targets so the touched list sees many repeats before a drain. for (let i = 0; i < 500; i++) queue.queue(targets[i % 8], `page-${i % 50}.bin`, bytes); @@ -83,12 +84,14 @@ test('many duplicate targets across a drain deliver exactly like the reference', for (let d = 0; d < 3; d++) livrs += queue.drain(); return { delivered, livrs }; } - const optimisee = arrivals(createArrivalQueue); - const reference = arrivals(referenceArrivalQueue as typeof createArrivalQueue); + const optimisee = arrivals((bytes, count) => + createArrivalQueue(bytes, count, createFrameBudget(Infinity)), + ); + const reference = arrivals(referenceArrivalQueue); assert.deepEqual(optimisee, reference); }); -test('the default time budget yields at its boundary and resumes in arrival order', (t) => { +test('the frame budget yields at its boundary and resumes in arrival order', (t) => { let now = 0; t.mock.method(performance, 'now', () => now); const accepted: string[] = []; @@ -102,7 +105,7 @@ test('the default time budget yields at its boundary and resumes in arrival orde const queue = createArrivalQueue(1 << 20, 64, budget); for (const url of ['p0', 'p1', 'slow', 'p3']) queue.queue(receiver, url, new Uint32Array(1)); const frame = () => (budget.open(), queue.drain()); - assert.equal(frame(), 2, 'two 1 ms deliveries reach the default 2 ms ceiling'); + assert.equal(frame(), 2, 'two 1 ms deliveries reach the 2 ms ceiling'); assert.deepEqual(accepted, ['p0', 'p1']); assert.equal(queue.pending, 2); assert.equal(frame(), 1, 'an over-budget first delivery still makes progress'); @@ -129,7 +132,7 @@ test('a drain spends what its frame left of the budget, and never opens it again }); test('the cells a frame places and the pages it drains spend one budget, on one clock', async (t) => { - // Reading a cell's bytes costs 1.5 ms and a page 1 ms, against the default 2 ms ceiling. + // Reading a cell's bytes costs 1.5 ms and a page 1 ms, against the 2 ms ceiling. let now = 0; t.mock.method(performance, 'now', () => now); const body = (x: number) => diff --git a/packages/sdk-browser/src/page/integration/frameBudget.ts b/packages/sdk-browser/src/page/integration/frameBudget.ts index 5c93b4a185..8fb4bd2908 100644 --- a/packages/sdk-browser/src/page/integration/frameBudget.ts +++ b/packages/sdk-browser/src/page/integration/frameBudget.ts @@ -1,12 +1,13 @@ /** * The main thread's budget, one definition for every stage that spends it (CONTRIBUTING.md * §Streaming rule 4): whether one more piece of work is admitted — the first always is, so a piece - * longer than the ceiling still goes through, then while the clock since `open` is within it —, - * and one counted. + * longer than the ceiling still goes through, then while the clock since `open`, stopped between + * two stages (`pause`, `resume`), is within it —, and one counted. * * A session holds one integration budget per frame (`BackendContext.frameBudget`): its frame opens - * it, the cells and the arrival drain spend from it (`./arrivalQueue.ts`), then the WebGPU row - * records (`../../webgpu/row/claims.ts`). Texture tiles keep their own upload ceiling + * it, the cells and the arrival drain spend from it (`./arrivalQueue.ts`), and it pauses while the + * engine does its other work, then the WebGPU row records spend what is left + * (`../../webgpu/row/claims.ts`). Texture tiles keep their own upload ceiling * (`../../webgpu/tile/streamer.ts`); the WebGPU residency queue opens one per turn of the event * loop and yields past it (`../../webgpu/residency/residentEnsurer.ts`). */ @@ -17,14 +18,18 @@ export type FrameClock = ReturnType; /** `now` is the clock the budget is read on: `performance.now` unless a test drives it. */ export function createFrameBudget(ms: number, now = () => performance.now()) { let started = 0, + used = 0, spent = 0; return { /** Starts the clock: every piece until the next `open` shares it. Returns the time it read. */ open() { - spent = 0; + spent = used = 0; return (started = now()); }, - admits: () => spent === 0 || now() - started < ms, + /** Stops the clock: what runs until `resume` is not integration and spends none of it. */ + pause: () => void (used += now() - started), + resume: () => void (started = now()), + admits: () => spent === 0 || used + now() - started < ms, spend: () => void spent++, }; } diff --git a/packages/sdk-browser/src/scene/partition/cells.fixture.ts b/packages/sdk-browser/src/scene/partition/cells.fixture.ts new file mode 100644 index 0000000000..af51c2b75e --- /dev/null +++ b/packages/sdk-browser/src/scene/partition/cells.fixture.ts @@ -0,0 +1,95 @@ +import type { TablePartition } from '../../../../sdk-core/src/scene/core/tablePartition.ts'; +import { Group, Object3D } from '../../../../sdk-core/src/world/object/object3d.ts'; +import type { PlacementRows } from '../../placement/rows.ts'; +import { createPartitionCells, type PartitionCells } from './cells.ts'; +import { placedMesh, type RowLink } from './rows.ts'; + +type PartitionIo = Parameters[2]; + +/** Two cells of one mesh, one near the origin and one 5 km away; each hangs under a moved core + * node, or under the scene root when its `far` or `near` is null. */ +export function world(far: number | null = 0, near: number | null = null) { + const node = (x: number, parent: number | null) => ({ + parent, + mesh: 7, + matrix: null, + translation: [x, 1, 2], + rotation: [0, Math.SQRT1_2, 0, Math.SQRT1_2], + scale: [2, 2, 2], + }); + const bodies: Record = { + 'near.json': { version: 1, nodes: [node(1, near), node(3, near)] }, + 'far.json': { version: 1, nodes: [node(5000, far)] }, + }; + const partition: TablePartition = { + version: 1, + bounds: [0, 0, 0, 5010, 5, 5], + meshes: [7], + cells: [ + { + url: 'near.json', + sha256: '', + bytes: 1, + parents: [near === null ? [null, [0, 0, 0, 5, 5, 5]] : [0, [0, -10, 0, 5, -5, 5]]], + meshes: [[7, 2]], + }, + { + url: 'far.json', + sha256: '', + bytes: 1, + // Under the core node, 10 m up, or at the same place under the root. + parents: [ + far === null ? [null, [5000, 0, 0, 5010, 5, 5]] : [0, [5000, -10, 0, 5010, -5, 5]], + ], + meshes: [[7, 1]], + }, + ], + }; + const root = new Group(); + const core = new Object3D(); + core.position.set(0, 10, 0); + root.add(core); + const links: RowLink[] = [ + { meshes: 7, primitives: 0 }, + { meshes: 7, primitives: 1 }, + ]; + const cells = createPartitionCells({ + partition, + base: 'https://cache.test/key/', + root, + parents: [core], + meshes: new Map([[7, placedMesh(links)]]), + }); + const bytes = (url: string) => + new TextEncoder().encode(JSON.stringify(bodies[url.split('/').at(-1)!])); + return { cells, links, root, core, bytes, node }; +} + +/** An io that holds every cell already read, records what it is asked and each time the reach + * outgrew the rows. */ +export function io(bytes: (url: string) => Uint8Array) { + const asked: string[] = [], + updates: [PlacementRows, number, number][] = []; + const held = new Set(); + const outgrown = { count: 0 }; + const port: PartitionIo = { + bytes: (url) => (held.has(url) ? bytes(url) : undefined), + loading: () => false, + request: (urls) => void asked.push(...urls), + update: (rows, from, to) => updates.push([rows, from, to]), + outgrown: () => void outgrown.count++, + }; + return { port, asked, updates, held, outgrown }; +} + +/** A reach past both cells. */ +export const everywhere = 1e5; +/** Opens a session on `cells` for `reach` from far away, an owner to open it again unless + * `owned` is false: its rows are sized, no cell is read. */ +export const opened = (cells: PartitionCells, reach: number, owned = true) => + cells.prime([1e9, 0, 0], reach, () => Promise.reject(new Error('nothing is read')), owned); +/** No arrival budget: what a test places never depends on the time the machine takes. */ +export const noBudget = { admits: () => true, spend() {} }; +export const row = (rows: PlacementRows, at: number) => [ + ...rows.matrices.subarray(at * 16, at * 16 + 16), +]; diff --git a/packages/sdk-browser/src/scene/partition/cells.test.ts b/packages/sdk-browser/src/scene/partition/cells.test.ts index 5f255ac3be..11f0f4103c 100644 --- a/packages/sdk-browser/src/scene/partition/cells.test.ts +++ b/packages/sdk-browser/src/scene/partition/cells.test.ts @@ -1,100 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import type { TablePartition } from '../../../../sdk-core/src/scene/core/tablePartition.ts'; -import { Group, Object3D } from '../../../../sdk-core/src/world/object/object3d.ts'; +import { Object3D } from '../../../../sdk-core/src/world/object/object3d.ts'; import { pose } from '../../host/prepared/nodes.ts'; import { hostWorldChainInto } from '../../host/world/chain.ts'; import type { PlacementRows } from '../../placement/rows.ts'; -import { createPartitionCells, type PartitionCells } from './cells.ts'; - -type PartitionIo = Parameters[2]; -import { placedMesh, type RowLink } from './rows.ts'; - -/** Two cells of one mesh, one near the origin and one 5 km away; each hangs under a moved core - * node, or under the scene root when its `far` or `near` is null. */ -function world(far: number | null = 0, near: number | null = null) { - const node = (x: number, parent: number | null) => ({ - parent, - mesh: 7, - matrix: null, - translation: [x, 1, 2], - rotation: [0, Math.SQRT1_2, 0, Math.SQRT1_2], - scale: [2, 2, 2], - }); - const bodies: Record = { - 'near.json': { version: 1, nodes: [node(1, near), node(3, near)] }, - 'far.json': { version: 1, nodes: [node(5000, far)] }, - }; - const partition: TablePartition = { - version: 1, - bounds: [0, 0, 0, 5010, 5, 5], - meshes: [7], - cells: [ - { - url: 'near.json', - sha256: '', - bytes: 1, - parents: [near === null ? [null, [0, 0, 0, 5, 5, 5]] : [0, [0, -10, 0, 5, -5, 5]]], - meshes: [[7, 2]], - }, - { - url: 'far.json', - sha256: '', - bytes: 1, - // Under the core node, 10 m up, or at the same place under the root. - parents: [ - far === null ? [null, [5000, 0, 0, 5010, 5, 5]] : [0, [5000, -10, 0, 5010, -5, 5]], - ], - meshes: [[7, 1]], - }, - ], - }; - const root = new Group(); - const core = new Object3D(); - core.position.set(0, 10, 0); - root.add(core); - const links: RowLink[] = [ - { meshes: 7, primitives: 0 }, - { meshes: 7, primitives: 1 }, - ]; - const cells = createPartitionCells({ - partition, - base: 'https://cache.test/key/', - root, - parents: [core], - meshes: new Map([[7, placedMesh(links)]]), - }); - const bytes = (url: string) => - new TextEncoder().encode(JSON.stringify(bodies[url.split('/').at(-1)!])); - return { cells, links, root, core, bytes, node }; -} - -/** An io that holds every cell already read, records what it is asked and each time the reach - * outgrew the rows. */ -function io(bytes: (url: string) => Uint8Array) { - const asked: string[] = [], - updates: [PlacementRows, number, number][] = []; - const held = new Set(); - const outgrown = { count: 0 }; - const port: PartitionIo = { - bytes: (url) => (held.has(url) ? bytes(url) : undefined), - loading: () => false, - request: (urls) => void asked.push(...urls), - update: (rows, from, to) => updates.push([rows, from, to]), - outgrown: () => void outgrown.count++, - }; - return { port, asked, updates, held, outgrown }; -} - -/** A reach past both cells. */ -const everywhere = 1e5; -/** Opens a session on `cells` for `reach` from far away, an owner to open it again unless - * `owned` is false: its rows are sized, no cell is read. */ -const opened = (cells: PartitionCells, reach: number, owned = true) => - cells.prime([1e9, 0, 0], reach, () => Promise.reject(new Error('nothing is read')), owned); -/** No arrival budget: what a test places never depends on the time the machine takes. */ -const noBudget = { admits: () => true, spend() {} }; -const row = (rows: PlacementRows, at: number) => [...rows.matrices.subarray(at * 16, at * 16 + 16)]; +import { everywhere, io, noBudget, opened, row, world } from './cells.fixture.ts'; test('the cells a camera needs are asked nearest first, then placed on rows once read', async () => { const { cells, links, bytes } = world(); diff --git a/packages/sdk-browser/src/scene/partition/cells.ts b/packages/sdk-browser/src/scene/partition/cells.ts index 381d5f493e..d37d09c61f 100644 --- a/packages/sdk-browser/src/scene/partition/cells.ts +++ b/packages/sdk-browser/src/scene/partition/cells.ts @@ -3,15 +3,14 @@ * * Before each frame (`frame`), the cells the camera needs (`plan.ts`, boxed where their parents * stand now: `boxes.ts`) are asked of the session's page streamer, nearest first, then those ahead - * at the prefetch priority; those it holds are placed within the frame's one arrival budget + * at the prefetch priority; those it holds are placed within the frame's one integration budget * (`FrameBudget`), each node on a row of its mesh (`rows.ts`) at the world matrix the engine * composes for a child of its core parent. A cell past its reach parks its rows; a moved parent * rewrites the rows under it. `prime`, before the first frame, sizes the rows for every node the * reach can hold at once wherever the parents stand (`sizing.ts`; every node when no owner can * reopen the session) and reads the cells it needs. Moved, turned or scaled up, parents never run - * the rows short; a reach past them, or a parent scaled down or stretched unevenly, sizes them - * again in place, through the engine's growth contract (`placement/growth.ts`) — only an engine - * that grows no buffer in place asks its owner to reopen the session. + * the rows short; a reach past them, or a parent scaled down or stretched unevenly, grows them in + * place (`placement/growth.ts`), or asks the owner to reopen on an engine that cannot. */ import { MATRIX_VALUES, multiplyMatrix4 } from '../../../../sdk-core/src/index.ts'; import { @@ -101,10 +100,10 @@ export function createPartitionCells(inputs: Inputs) { for (const placement of placements) if (placement.parent === node) write(placement); } }; - /** Sizes the rows for any place of the parents within a reach `bound` and their stretch now, - * widened `by`; `grown` hands each buffer replaced to its engine, absent before one reads it. */ - const resize = (bound: number, grown?: Grow, by = 1) => { - stretched = sizedStretch(boxes.stretch, by); + /** Sizes the rows for any place of the parents within a reach `bound` and a `stretch`; `grown` + * hands each buffer replaced to its engine, absent before one reads it. */ + const resize = (bound: number, grown?: Grow, stretch = sizedStretch(boxes.stretch)) => { + stretched = stretch; const rows = residentRows(partition.cells, bound, stretched); sizeRows(meshes, rows, grown); sized = holdsEvery(rows, cells) ? Infinity : bound; @@ -136,17 +135,18 @@ export function createPartitionCells(inputs: Inputs) { grow?: Grow; outgrown?: () => void; }, - /** The frame's one integration budget (`FrameBudget`, the session's). */ - budget: { admits(): boolean; spend(): void }, + budget: import('../../page/integration/frameBudget.ts').FrameBudget, ) { followParents(); const local = inCellFrame(hostWorldChainInto(rootWorld, root), eye, reach); const plan = planCells(boxes(), local.eye, local.reach, new Set(held.keys())); const beyond = local.reach > Math.max(sized, wanted); if (beyond) wanted = local.reach; - if (beyond || (!short && sized < Infinity && outstretched(boxes.stretch, stretched))) { - // Twice what is asked, as buffers grow: an ongoing zoom or shrink resizes O(log) times. - if (io.grow) resize(Math.max(2 * sized, wanted), io.grow, 2); + const over = !short && sized < Infinity && outstretched(boxes.stretch, stretched); + if (beyond || over) { + // Twice what outgrew them, as buffers grow: an ongoing zoom or shrink resizes O(log) times. + const stretch = over ? sizedStretch(boxes.stretch, 2) : stretched; + if (io.grow) resize(beyond ? Math.max(2 * sized, wanted) : sized, io.grow, stretch); else { short = true; io.outgrown?.(); diff --git a/packages/sdk-browser/src/scene/partition/plan.test.ts b/packages/sdk-browser/src/scene/partition/plan.test.ts index d6485c24ba..f09c5319c2 100644 --- a/packages/sdk-browser/src/scene/partition/plan.test.ts +++ b/packages/sdk-browser/src/scene/partition/plan.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { AHEAD, boxDistance, cellReach, inCellFrame, KEEP, planCells } from './plan.ts'; -const optics = { fov: 60, aspect: 16 / 9, far: 1e6, zoom: 1 }; +const optics = { fov: 60, aspect: 16 / 9, near: 0.1, far: 1e6, zoom: 1 }; const cell = (x: number) => ({ bounds: [x, 0, 0, x + 1, 1, 1], meshes: [[0, 1] as const] }); test('a cell is read up to the far plane, met on the frustum diagonal', () => { @@ -19,6 +19,8 @@ test('the reach follows the zoom: its frustum, and an orthographic box, widen as const box = { left: -10, right: 10, top: 5, bottom: -5 }; const orthographic = { ...optics, far: 1000, zoom: 0.5, orthographic: box }; assert.equal(cellReach(orthographic), Math.hypot(1000, 20, 10)); + // A negative near plane draws behind the eye, as far as it goes. + assert.equal(cellReach({ ...orthographic, near: -2000 }), Math.hypot(2000, 20, 10)); }); test('a sheared root reads every cell the world reach holds, by its least singular value', () => { diff --git a/packages/sdk-browser/src/scene/partition/plan.ts b/packages/sdk-browser/src/scene/partition/plan.ts index 5890f4167c..0c52a3d3de 100644 --- a/packages/sdk-browser/src/scene/partition/plan.ts +++ b/packages/sdk-browser/src/scene/partition/plan.ts @@ -47,7 +47,7 @@ export const KEEP = 0.5; /** What the reach reads of a camera: the optics its projection is composed from. */ export type PartitionOptics = Pick< CameraOptics, - 'fov' | 'aspect' | 'far' | 'zoom' | 'orthographic' + 'fov' | 'aspect' | 'near' | 'far' | 'zoom' | 'orthographic' >; /** The distance past which nothing `optics` sees is drawn: the frustum's farthest corner. */ @@ -56,7 +56,9 @@ export function cellReach(optics: PartitionOptics) { zoom = optics.zoom || 1; // as the projection reads it (`writeEngineCamera`) if (orthographic) { const [x, y, halfWidth, halfHeight] = orthographicView(orthographic, zoom, view); - return Math.hypot(far, Math.abs(x) + halfWidth, Math.abs(y) + halfHeight); + // Its depth range may reach behind the eye: a negative `near` draws there. + const depth = Math.max(Math.abs(far), Math.abs(optics.near)); + return Math.hypot(depth, Math.abs(x) + halfWidth, Math.abs(y) + halfHeight); } const slope = perspectiveSlope(optics.fov, zoom); return far * Math.sqrt(1 + slope * slope * (1 + optics.aspect * optics.aspect)); diff --git a/packages/sdk-browser/src/webgpu/row/claims.test.ts b/packages/sdk-browser/src/webgpu/row/claims.test.ts index 30d1d0e972..5bb6112ff4 100644 --- a/packages/sdk-browser/src/webgpu/row/claims.test.ts +++ b/packages/sdk-browser/src/webgpu/row/claims.test.ts @@ -42,29 +42,28 @@ test('a barrier image lifts the time budget and writes every owed row', () => { assert.deepEqual(placed, [0, 1, 2, 3]); }); -test('the rows a frame writes spend what its arrivals left of its one budget, on one clock', () => { +test('the rows a frame writes spend what its arrivals left of its one integration budget', () => { let now = 0; const budget = createFrameBudget(2, () => now); const arrivals = createArrivalQueue(1 << 20, 64, budget); const receiver = { acceptPage: () => void (now += 1) }; for (const url of ['p0', 'p1']) arrivals.queue(receiver, url, new Uint32Array(1)); const claims = owed(); - budget.open(); - assert.equal(arrivals.drain(), 2, 'two 1 ms arrivals spend the 2 ms frame'); - serveClaims( - claims, - () => true, - () => true, - budget, - ); - assert.equal(claims.count, 3, 'one row goes through, three wait for the next frame'); - budget.open(); - arrivals.drain(); - serveClaims( - claims, - () => true, - () => true, - budget, - ); - assert.equal(claims.count, 0, 'a frame with nothing to drain writes every row within it'); + /** A frame as the session draws it: the drain, then 5 ms of the engine's other work. */ + const frame = () => { + budget.open(); + arrivals.drain(); + budget.pause(); + now += 5; + serveClaims( + claims, + () => true, + () => (now += 0.5) > 0, + budget, + ); + return claims.count; + }; + assert.equal(frame(), 3, 'two 1 ms arrivals spend the 2 ms: one row goes through, three wait'); + // Nothing to drain: the engine's 5 ms are not integration, the rows have the 2 ms to themselves. + assert.equal(frame(), 0); }); diff --git a/packages/sdk-browser/src/webgpu/row/claims.ts b/packages/sdk-browser/src/webgpu/row/claims.ts index 566c268354..a68648930d 100644 --- a/packages/sdk-browser/src/webgpu/row/claims.ts +++ b/packages/sdk-browser/src/webgpu/row/claims.ts @@ -1,5 +1,5 @@ import { sortPages } from '../../../../sdk-core/src/index.ts'; -import type { FrameBudget } from '../../page/integration/frameBudget.ts'; +import type { FrameClock } from '../../page/integration/frameBudget.ts'; /** * Pages that claim the write of a row record and have not yet received it. @@ -51,7 +51,7 @@ export type WebgpuRowClaims = ReturnType; /** * Serves the queue in increasing page order within `budget`: the frame's one integration budget * (`BackendContext.frameBudget`), which its frame opened and its cells and arrivals spent from - * first — the clock is reread after each row and the rest waits for the next image, in the same + * first, its clock running only while rows are written — the clock is reread after each row and the rest waits for the next image, in the same * order. At least one row always goes through, or a page would never be written. `release` says * again whether the page still claims a row — it may have left since it enrolled, and then leaves * the queue costing nothing —, `place` writes it and returns `false` when the table is full. @@ -64,10 +64,11 @@ export function serveClaims( claims: WebgpuRowClaims, release: (page: number) => boolean, place: (page: number) => boolean, - budget?: FrameBudget, + budget?: FrameClock, ) { if (!claims.count) return 0; claims.sort(); + budget?.resume(); let served = 0, denied = 0; while (served < claims.count) { @@ -85,6 +86,7 @@ export function serveClaims( } served++; } + budget?.pause(); claims.consume(served); return denied; } diff --git a/packages/sdk-browser/src/webgpu/row/slots.ts b/packages/sdk-browser/src/webgpu/row/slots.ts index d374a56348..a39d5d6b7e 100644 --- a/packages/sdk-browser/src/webgpu/row/slots.ts +++ b/packages/sdk-browser/src/webgpu/row/slots.ts @@ -5,7 +5,7 @@ import type { PageRec } from '../../page/selection/selection.ts'; import { rowHasGeometry, type createPageRowWriter } from './pageRow.ts'; import { awaitsPageBytes } from './pageSlots.ts'; import type { createWebgpuRowState } from './state.ts'; -import type { FrameBudget } from '../../page/integration/frameBudget.ts'; +import type { FrameClock } from '../../page/integration/frameBudget.ts'; type Rows = ReturnType; type Writer = ReturnType; @@ -36,7 +36,7 @@ export function createWebgpuRowSlots( writePageRow: Writer, onResidenceChange: (rec: PageRec) => void, /** The frame's one integration budget; absent, an image writes every owed record. */ - budget?: FrameBudget, + budget?: FrameClock, ) { /** Ranks this pass gave back, waiting for a taker or a fill. */ const free = { rows: new Int32Array(Math.max(1, drawSlots)), count: 0 }; diff --git a/packages/sdk-browser/src/webgpu/row/sync.ts b/packages/sdk-browser/src/webgpu/row/sync.ts index 86e5b72f4a..5a5930b54d 100644 --- a/packages/sdk-browser/src/webgpu/row/sync.ts +++ b/packages/sdk-browser/src/webgpu/row/sync.ts @@ -6,7 +6,7 @@ import { createWebgpuRowSlots } from './slots.ts'; import { rowHasGeometry } from './pageRow.ts'; import { awaitsPageBytes } from './pageSlots.ts'; import { createBlendCasterRows } from './blendCasters.ts'; -import type { FrameBudget } from '../../page/integration/frameBudget.ts'; +import type { FrameClock } from '../../page/integration/frameBudget.ts'; type Rows = ReturnType; type Mirror = ReturnType; @@ -26,7 +26,7 @@ export function createWebgpuRowSync( /** Called when a blended caster's row is written again with another coverage. */ onCoverageChange: (rec: PageRec) => void = () => {}, /** The frame's one integration budget the owed records spend from (`claims.ts`). */ - budget?: FrameBudget, + budget?: FrameClock, ) { const slots = createWebgpuRowSlots( rows, diff --git a/packages/sdk-browser/src/world/render/render.ts b/packages/sdk-browser/src/world/render/render.ts index ec1045de08..4845409ed8 100644 --- a/packages/sdk-browser/src/world/render/render.ts +++ b/packages/sdk-browser/src/world/render/render.ts @@ -99,12 +99,13 @@ export function createExplorerRender(session: ExplorerRenderSession, inputs: Inp const start = performance.now(); if (pose) setPose(pose); // One integration budget per frame: the cells placed, then the arrivals drained, both - // outside the frame they would have lengthened. + // outside the frame they would have lengthened; the engine's row records spend what is left. frameBudget.open(); followCells?.(); guides?.follow(); const arrivalStart = performance.now(); streaming.arrivals.drain(); + frameBudget.pause(); (state.active as HostCpuProfile).cpuStep?.('arrivalsMs', performance.now() - arrivalStart); try { if (comparisonLayout === 'single' || measuring) { diff --git a/packages/sdk-browser/src/world/render/triangles.test.ts b/packages/sdk-browser/src/world/render/triangles.test.ts index 942e244297..1f9c69f5dd 100644 --- a/packages/sdk-browser/src/world/render/triangles.test.ts +++ b/packages/sdk-browser/src/world/render/triangles.test.ts @@ -5,6 +5,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createExplorerRender } from './render.ts'; +import { createFrameBudget } from '../../page/integration/frameBudget.ts'; import { createDiagnosticChannel } from '../../diagnostic/channel.ts'; import type { RenderBackend } from '../../backend/types.ts'; import type { FrameMetrics } from '../../../../sdk-core/src/index.ts'; @@ -38,7 +39,7 @@ function harness(options: { directGpu: boolean; counted?: number | null; order?: lookAtTarget: { x: 0, y: 0, z: 0 }, setPose: () => {}, streaming: { arrivals: { drain: () => {} } } as never, - frameBudget: { open: () => 0, admits: () => true, spend() {} }, + frameBudget: createFrameBudget(Infinity), drawBackend: () => options.order?.push('draw'), ensureTarget: ((target?: unknown) => target) as never, directGpu: options.directGpu, diff --git a/packages/sdk-browser/src/world/scene/partitionFrame.test.ts b/packages/sdk-browser/src/world/scene/partitionFrame.test.ts index c7f03f7901..557410767b 100644 --- a/packages/sdk-browser/src/world/scene/partitionFrame.test.ts +++ b/packages/sdk-browser/src/world/scene/partitionFrame.test.ts @@ -70,73 +70,51 @@ test('a frame reads the cells within the far plane of its camera, visible first ]); }); -test('a pebble far below any error target is read while the far plane lets it be drawn', () => { - // Nothing coarser stands for an unread cell before #23: a small object within the far plane is - // read whatever it projects to, or it would be missing from the image for good. - const pebble = { - url: 'pebble.json', +/** What a first frame from `camera` asks of one cell of one node, boxed by `bounds` under the root. */ +function asks( + url: string, + bounds: number[], + camera: Parameters[0]['camera'], +) { + const cell = { + url, sha256: '', bytes: 1, meshes: [[0, 1] as const], + parents: [[null, bounds] as const], }; const cells = createPartitionCells({ - partition: { - version: 1, - bounds: [200, 0, 0, 200.01, 0.01, 0.01], - meshes: [0], - cells: [{ ...pebble, parents: [[null, [200, 0, 0, 200.01, 0.01, 0.01]] as const] }], - }, + partition: { version: 1, bounds, meshes: [0], cells: [cell] }, base: 'https://cache.test/key/', root: new Group(), parents: [], meshes: new Map([[0, placedMesh([{ meshes: 0, primitives: 0 }])]]), }); const { port, asked } = streamer(); - createPartitionFrame({ - partitions: [cells], - streamer: port, - camera: hostFramingCamera(60, 16 / 9, 0.1, 300), - active: () => ({}) as RenderBackend, - budget, - })!(); - assert.deepEqual(asked, [[['https://cache.test/key/pebble.json'], PRIORITY_VISIBLE]]); + const active = () => ({}) as RenderBackend; + createPartitionFrame({ partitions: [cells], streamer: port, camera, active, budget })!(); + return asked; +} + +test('a pebble far below any error target is read while the far plane lets it be drawn', () => { + // Nothing coarser stands for an unread cell before #23: a small object within the far plane is + // read whatever it projects to, or it would be missing from the image for good. + const camera = hostFramingCamera(60, 16 / 9, 0.1, 300); + assert.deepEqual(asks('pebble.json', [200, 0, 0, 200.01, 0.01, 0.01], camera), [ + [['https://cache.test/key/pebble.json'], PRIORITY_VISIBLE], + ]); }); test('a camera zoomed out reads the cells its wider frustum sees', () => { // At zoom 0.5 the frustum is twice as wide: a pebble 560 m aside, 290 m ahead, is within its far // plane's corner, past the reach — and the read-ahead — of the same camera at zoom 1. const bounds = [560, 0, -290, 560.01, 0.01, -289.99]; - const cells = createPartitionCells({ - partition: { - version: 1, - bounds, - meshes: [0], - cells: [ - { - url: 'aside.json', - sha256: '', - bytes: 1, - meshes: [[0, 1] as const], - parents: [[null, bounds] as const], - }, - ], - }, - base: 'https://cache.test/key/', - root: new Group(), - parents: [], - meshes: new Map([[0, placedMesh([{ meshes: 0, primitives: 0 }])]]), - }); - const { port, asked } = streamer(); const camera = hostFramingCamera(60, 16 / 9, 0.1, 300); + assert.deepEqual(asks('aside.json', bounds, camera), []); camera.zoom = 0.5; - createPartitionFrame({ - partitions: [cells], - streamer: port, - camera, - active: () => ({}) as RenderBackend, - budget, - })!(); - assert.deepEqual(asked, [[['https://cache.test/key/aside.json'], PRIORITY_VISIBLE]]); + assert.deepEqual(asks('aside.json', bounds, camera), [ + [['https://cache.test/key/aside.json'], PRIORITY_VISIBLE], + ]); }); test('a reach past the rows sized at open asks the owner to open the session again', () => { diff --git a/packages/sdk-browser/src/world/scene/partitionFrame.ts b/packages/sdk-browser/src/world/scene/partitionFrame.ts index 96dfc3bb00..63a111cffb 100644 --- a/packages/sdk-browser/src/world/scene/partitionFrame.ts +++ b/packages/sdk-browser/src/world/scene/partitionFrame.ts @@ -46,8 +46,8 @@ type Inputs = { streamer: Streamer; camera: HostCamera; active: () => RenderBackend; - /** The frame's one integration budget, the arrival queue's: cells spend from it before the - * drain spends the rest. */ + /** The session's one integration budget per frame (`BackendContext.frameBudget`): cells spend + * from it before the arrival drain and the engine's row records spend the rest. */ budget: FrameBudget; /** Asked once the camera's reach, or a parent's stretch, outgrew the rows sized at open on an * engine that grows no buffer in place: the owner opens the session again, sized for it. diff --git a/packages/sdk-browser/src/world/session/backends.ts b/packages/sdk-browser/src/world/session/backends.ts index d164560ffe..6aebea6e40 100644 --- a/packages/sdk-browser/src/world/session/backends.ts +++ b/packages/sdk-browser/src/world/session/backends.ts @@ -24,7 +24,7 @@ type Inputs = { backends: RenderBackend[]; /** Manifest url base: that is what locates the resident-proxy cache object. */ base: string; - /** The session's one integration budget per frame. */ frameBudget: BackendContext['frameBudget']; + frameBudget?: BackendContext['frameBudget']; }; export async function prepareExplorerBackends(session: ExplorerSession, inputs: Inputs) { diff --git a/tests/integration/world-partition.test.ts b/tests/integration/world-partition.test.ts index b60cc9d0a5..eaa4002bfb 100644 --- a/tests/integration/world-partition.test.ts +++ b/tests/integration/world-partition.test.ts @@ -27,7 +27,7 @@ type Primed = { bytes: number; cells: { cells: number; held: number; rows: numbe /** The page's camera: 60°, a 300 m far plane, in the middle of the smaller world; the canvas * stand-in is 16:9. */ -const REACH = cellReach({ fov: 60, aspect: 16 / 9, far: 300 }); +const REACH = cellReach({ fov: 60, aspect: 16 / 9, near: 0.1, far: 300, zoom: 1 }); function pageCamera() { const camera = new Camera('perspective', { fov: 60, near: 0.1, far: 300 }); camera.position.set(48 * SPACING, 2, 48 * SPACING); From 56d23af0adf91cf1e31301a951174d9e8957979b Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 05:22:12 +0200 Subject: [PATCH 07/11] refactor(streaming): the frame budget pauses on every path, residentRows reads each ball once, the orthographic reach takes a mirrored box, the row slots take the budget per image (#404) --- .../src/scene/partition/cells.test.ts | 20 +++++------ .../src/scene/partition/plan.test.ts | 3 ++ .../sdk-browser/src/scene/partition/plan.ts | 20 ++++++----- .../sdk-browser/src/scene/partition/sizing.ts | 34 +++++++++---------- packages/sdk-browser/src/webgpu/row/claims.ts | 21 +++++++----- packages/sdk-browser/src/webgpu/row/slots.ts | 8 ++--- packages/sdk-browser/src/webgpu/row/sync.ts | 11 ++---- .../sdk-browser/src/world/render/render.ts | 14 +++++--- .../sdk-browser/src/world/session/backends.ts | 2 +- 9 files changed, 66 insertions(+), 67 deletions(-) diff --git a/packages/sdk-browser/src/scene/partition/cells.test.ts b/packages/sdk-browser/src/scene/partition/cells.test.ts index 11f0f4103c..1b335ea649 100644 --- a/packages/sdk-browser/src/scene/partition/cells.test.ts +++ b/packages/sdk-browser/src/scene/partition/cells.test.ts @@ -80,7 +80,7 @@ test('a parent scaled down grows the rows in place, on an engine that can, and r // both are within 100 m, three nodes on rows sized for the near cell's two. for (const grows of [true, false]) { const { cells, links, core, bytes } = world(0, 0); - const { port, held, outgrown } = io(bytes); + const { port, held, outgrown, updates } = io(bytes); const grown: [PlacementRows, PlacementRows][] = []; if (grows) port.grow = (from, to) => void grown.push([from, to]); ['near.json', 'far.json'].forEach((name) => held.add(`https://cache.test/key/${name}`)); @@ -90,20 +90,16 @@ test('a parent scaled down grows the rows in place, on an engine that can, and r cells.frame([0, 0, 0], 100, port, noBudget); cells.frame([0, 0, 0], 100, port, noBudget); const { held: placed, waiting } = cells.stats(); - if (!grows) { - assert.deepEqual([placed, waiting, outgrown.count], [1, 1, 1], 'it asks its owner to reopen'); - continue; - } - assert.deepEqual([placed, waiting, outgrown.count], [2, 0, 0]); + assert.deepEqual([placed, waiting, outgrown.count], grows ? [2, 0, 0] : [1, 1, 1]); + if (!grows) continue; // it asks its owner to reopen + const after = links.map((link) => link.placements!); assert.deepEqual( grown, - [0, 1].map((at) => [before[at], links[at].placements!]), + [0, 1].map((at) => [before[at], after[at]]), ); - for (const link of links) - assert.equal( - link.placements!.live.reduce((a, b) => a + b, 0), - 3, - ); + // The engine is told of the grown buffers only: no row of the old ones is read again. + assert.ok(updates.every(([rows]) => after.includes(rows))); + assert.ok(after.every((rows) => rows.live.reduce((a, b) => a + b, 0) === 3)); } }); diff --git a/packages/sdk-browser/src/scene/partition/plan.test.ts b/packages/sdk-browser/src/scene/partition/plan.test.ts index f09c5319c2..db2b52f5e9 100644 --- a/packages/sdk-browser/src/scene/partition/plan.test.ts +++ b/packages/sdk-browser/src/scene/partition/plan.test.ts @@ -21,6 +21,9 @@ test('the reach follows the zoom: its frustum, and an orthographic box, widen as assert.equal(cellReach(orthographic), Math.hypot(1000, 20, 10)); // A negative near plane draws behind the eye, as far as it goes. assert.equal(cellReach({ ...orthographic, near: -2000 }), Math.hypot(2000, 20, 10)); + // A box given right to left and bottom up is as wide. + const mirrored = { left: 10, right: -10, top: -5, bottom: 5 }; + assert.equal(cellReach({ ...orthographic, orthographic: mirrored }), Math.hypot(1000, 20, 10)); }); test('a sheared root reads every cell the world reach holds, by its least singular value', () => { diff --git a/packages/sdk-browser/src/scene/partition/plan.ts b/packages/sdk-browser/src/scene/partition/plan.ts index 0c52a3d3de..ccf2666ae1 100644 --- a/packages/sdk-browser/src/scene/partition/plan.ts +++ b/packages/sdk-browser/src/scene/partition/plan.ts @@ -21,7 +21,11 @@ * within a reach wherever the page moves the cells' parents (`sizing.ts`): nothing grows while a * session draws. */ -import { invertMatrix4, MATRIX_VALUES } from '../../../../sdk-core/src/index.ts'; +import { + invertMatrix4, + MATRIX_VALUES, + transformAffinePoint, +} from '../../../../sdk-core/src/index.ts'; import { boxPointDistance } from '../../../../sdk-core/src/math/primitives/box.ts'; import { orthographicView, @@ -53,12 +57,13 @@ export type PartitionOptics = Pick< /** The distance past which nothing `optics` sees is drawn: the frustum's farthest corner. */ export function cellReach(optics: PartitionOptics) { const { far, orthographic } = optics, - zoom = optics.zoom || 1; // as the projection reads it (`writeEngineCamera`) + zoom = optics.zoom || 1; // a zoom of 0 draws nothing: read as 1, never as an empty reach if (orthographic) { - const [x, y, halfWidth, halfHeight] = orthographicView(orthographic, zoom, view); - // Its depth range may reach behind the eye: a negative `near` draws there. + const [x, y, width, height] = orthographicView(orthographic, zoom, view); + // Its depth range may reach behind the eye: a negative `near` draws there. A box given right + // to left, or top to bottom, is as wide. const depth = Math.max(Math.abs(far), Math.abs(optics.near)); - return Math.hypot(depth, Math.abs(x) + halfWidth, Math.abs(y) + halfHeight); + return Math.hypot(depth, Math.abs(x) + Math.abs(width), Math.abs(y) + Math.abs(height)); } const slope = perspectiveSlope(optics.fov, zoom); return far * Math.sqrt(1 + slope * slope * (1 + optics.aspect * optics.aspect)); @@ -73,10 +78,7 @@ export function cellReach(optics: PartitionOptics) { */ export function inCellFrame(world: ArrayLike, eye: ArrayLike, reach: number) { invertMatrix4(inverse, world); - const local = [0, 1, 2].map( - (a) => - inverse[a] * eye[0] + inverse[4 + a] * eye[1] + inverse[8 + a] * eye[2] + inverse[12 + a], - ); + const local = transformAffinePoint([0, 0, 0], inverse, eye[0], eye[1], eye[2]); return { eye: local, reach: reach / stretchOf(world)[0] }; } diff --git a/packages/sdk-browser/src/scene/partition/sizing.ts b/packages/sdk-browser/src/scene/partition/sizing.ts index 64564bc24a..0a2328f1ea 100644 --- a/packages/sdk-browser/src/scene/partition/sizing.ts +++ b/packages/sdk-browser/src/scene/partition/sizing.ts @@ -36,24 +36,26 @@ function boxGap(a: Box, b: Box) { return Math.sqrt(sum); } -/** The centre of a box and the radius of the ball around it. */ +/** A box as its ball: the centre, then the radius around it. */ function ball(box: Box) { - const half = [0, 1, 2].map((axis) => (box[axis + 3] - box[axis]) / 2); - return { - centre: [0, 1, 2].map((axis) => box[axis] + half[axis]), - radius: Math.hypot(half[0], half[1], half[2]), - }; + const x = (box[3] - box[0]) / 2, + y = (box[4] - box[1]) / 2, + z = (box[5] - box[2]) / 2; + return [box[0] + x, box[1] + y, box[2] + z, Math.sqrt(x * x + y * y + z * z)]; } +type Part = { cell: Sized; box: Box; ball: number[] }; /** Whether two boxes of one parent can both lie within `radius` of one eye of the root's frame. */ function together(radius: number, stretch: Stretch | null) { - if (!stretch) return (a: Box, b: Box) => boxGap(a, b) <= 2 * radius; // the root's own frame + // The root's own frame: the boxes themselves. + if (!stretch) return (a: Part, b: Part) => boxGap(a.box, b.box) <= 2 * radius; const [least, most] = stretch; - return (a: Box, b: Box) => { - const p = ball(a), - q = ball(b); - const apart = Math.hypot(...p.centre.map((value, axis) => value - q.centre[axis])); - return least * apart <= 2 * radius + Math.sqrt(3) * most * (p.radius + q.radius); + return ({ ball: p }: Part, { ball: q }: Part) => { + const dx = p[0] - q[0], + dy = p[1] - q[1], + dz = p[2] - q[2]; + const apart = Math.sqrt(dx * dx + dy * dy + dz * dz); + return least * apart <= 2 * radius + Math.sqrt(3) * most * (p[3] + q[3]); }; } @@ -74,21 +76,19 @@ export function residentRows( reach: number, stretch: ReadonlyMap, ) { - const groups = new Map(); + const groups = new Map(); for (const cell of cells) for (const [rank, box] of cell.parents) { const group = groups.get(rank) ?? []; groups.set(rank, group); - group.push({ cell, box }); + group.push({ cell, box, ball: ball(box) }); } const rows = new Map(); for (const [rank, parts] of groups) { const near = together(reach * (1 + KEEP), rank === null ? null : (stretch.get(rank) ?? [1, 1])); const most = new Map(); for (const anchor of parts) { - const held = meshTotals( - parts.filter((part) => near(anchor.box, part.box)).map((p) => p.cell), - ); + const held = meshTotals(parts.filter((part) => near(anchor, part)).map((p) => p.cell)); for (const [mesh, nodes] of held) most.set(mesh, Math.max(most.get(mesh) ?? 0, nodes)); } for (const [mesh, nodes] of most) rows.set(mesh, (rows.get(mesh) ?? 0) + nodes); diff --git a/packages/sdk-browser/src/webgpu/row/claims.ts b/packages/sdk-browser/src/webgpu/row/claims.ts index a68648930d..2af837174c 100644 --- a/packages/sdk-browser/src/webgpu/row/claims.ts +++ b/packages/sdk-browser/src/webgpu/row/claims.ts @@ -71,22 +71,25 @@ export function serveClaims( budget?.resume(); let served = 0, denied = 0; - while (served < claims.count) { - const page = claims.pages[served]; - if (release(page)) { + try { + while (served < claims.count) { + const page = claims.pages[served]; + if (!release(page)) { + served++; + continue; + } if (!place(page)) { denied = claims.count - served; break; } served++; - if (!budget) continue; - budget.spend(); - if (!budget.admits()) break; - continue; + budget?.spend(); + if (budget && !budget.admits()) break; } - served++; + } finally { + // Balanced on every path: what runs after the rows is not integration. + budget?.pause(); } - budget?.pause(); claims.consume(served); return denied; } diff --git a/packages/sdk-browser/src/webgpu/row/slots.ts b/packages/sdk-browser/src/webgpu/row/slots.ts index a39d5d6b7e..ec827c3227 100644 --- a/packages/sdk-browser/src/webgpu/row/slots.ts +++ b/packages/sdk-browser/src/webgpu/row/slots.ts @@ -35,8 +35,6 @@ export function createWebgpuRowSlots( drawSlots: number, writePageRow: Writer, onResidenceChange: (rec: PageRec) => void, - /** The frame's one integration budget; absent, an image writes every owed record. */ - budget?: FrameClock, ) { /** Ranks this pass gave back, waiting for a taker or a fill. */ const free = { rows: new Int32Array(Math.max(1, drawSlots)), count: 0 }; @@ -160,8 +158,8 @@ export function createWebgpuRowSlots( }; /** What the image owes the row table: the pages the cache named, and what the record queue - * left behind, within the frame's budget unless `bounded` is false. */ - const apply = (bounded = true) => { + * left behind, within the frame's `budget`; absent, every owed record (a barrier image). */ + const apply = (budget?: FrameClock) => { written.changed = false; denied = 0; const full = revision !== rows.rowsRevision || epoch !== rows.tableEpoch; @@ -175,7 +173,7 @@ export function createWebgpuRowSlots( const page = rows.touched.pages[i]; if (release(page)) claims.add(page); } - denied = serveClaims(claims, release, place, bounded ? budget : undefined); + denied = serveClaims(claims, release, place, budget); closeFreeRows(); } rows.clearTouched(); diff --git a/packages/sdk-browser/src/webgpu/row/sync.ts b/packages/sdk-browser/src/webgpu/row/sync.ts index 5a5930b54d..6c430853cb 100644 --- a/packages/sdk-browser/src/webgpu/row/sync.ts +++ b/packages/sdk-browser/src/webgpu/row/sync.ts @@ -28,14 +28,7 @@ export function createWebgpuRowSync( /** The frame's one integration budget the owed records spend from (`claims.ts`). */ budget?: FrameClock, ) { - const slots = createWebgpuRowSlots( - rows, - packedPages, - drawSlots, - writePageRow, - onResidenceChange, - budget, - ); + const slots = createWebgpuRowSlots(rows, packedPages, drawSlots, writePageRow, onResidenceChange); /** The blended clusters' caster rows, behind the visibility rows: they follow the residency the * mirror reports (`follow`), and the table's age here, whichever cut draws the image. */ const blendCasters = createBlendCasterRows(rows, packedPages, writePageRow, onCoverageChange); @@ -62,7 +55,7 @@ export function createWebgpuRowSync( return; mirror.dirty = false; rows.rowsEpoch = rows.tableEpoch; - slots.apply(bounded); + slots.apply(bounded ? budget : undefined); }; /** * The CPU cut names its own pages, so its rows are its order; the cut is rebuilt every frame. diff --git a/packages/sdk-browser/src/world/render/render.ts b/packages/sdk-browser/src/world/render/render.ts index 4845409ed8..5e5bec5fd5 100644 --- a/packages/sdk-browser/src/world/render/render.ts +++ b/packages/sdk-browser/src/world/render/render.ts @@ -101,11 +101,15 @@ export function createExplorerRender(session: ExplorerRenderSession, inputs: Inp // One integration budget per frame: the cells placed, then the arrivals drained, both // outside the frame they would have lengthened; the engine's row records spend what is left. frameBudget.open(); - followCells?.(); - guides?.follow(); - const arrivalStart = performance.now(); - streaming.arrivals.drain(); - frameBudget.pause(); + let arrivalStart = start; + try { + followCells?.(); + guides?.follow(); + arrivalStart = performance.now(); + streaming.arrivals.drain(); + } finally { + frameBudget.pause(); // balanced on every path: the engine's own work spends none of it + } (state.active as HostCpuProfile).cpuStep?.('arrivalsMs', performance.now() - arrivalStart); try { if (comparisonLayout === 'single' || measuring) { diff --git a/packages/sdk-browser/src/world/session/backends.ts b/packages/sdk-browser/src/world/session/backends.ts index 6aebea6e40..363c32ed10 100644 --- a/packages/sdk-browser/src/world/session/backends.ts +++ b/packages/sdk-browser/src/world/session/backends.ts @@ -24,7 +24,7 @@ type Inputs = { backends: RenderBackend[]; /** Manifest url base: that is what locates the resident-proxy cache object. */ base: string; - frameBudget?: BackendContext['frameBudget']; + frameBudget: BackendContext['frameBudget']; }; export async function prepareExplorerBackends(session: ExplorerSession, inputs: Inputs) { From 76579bbddb411d7980ca26d6090aba13849fdbb4 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 05:33:09 +0200 Subject: [PATCH 08/11] fix(streaming): the reach follows a scaled camera, a nearly flat root reads every cell, guides spend no integration budget, the budget wiring is tested on a driven clock (#404) --- bench/perf/browser/arrival-admission.perf.ts | 5 ++- .../sdk-browser/src/scene/partition/boxes.ts | 10 +++-- .../src/scene/partition/plan.test.ts | 3 ++ .../sdk-browser/src/scene/partition/plan.ts | 4 +- .../sdk-browser/src/webgpu/row/claims.test.ts | 11 ++--- .../sdk-browser/src/world/render/render.ts | 4 +- .../src/world/render/triangles.test.ts | 43 +++++++++++++++++-- .../src/world/scene/partitionFrame.test.ts | 14 +++--- .../src/world/scene/partitionFrame.ts | 18 +++++--- .../sdk-browser/src/world/session/backends.ts | 2 +- 10 files changed, 81 insertions(+), 33 deletions(-) diff --git a/bench/perf/browser/arrival-admission.perf.ts b/bench/perf/browser/arrival-admission.perf.ts index 64b5d2d8e2..036d40692b 100644 --- a/bench/perf/browser/arrival-admission.perf.ts +++ b/bench/perf/browser/arrival-admission.perf.ts @@ -9,8 +9,9 @@ import { referenceArrivalQueue, } from '../../oracles/browser/arrival-admission.ts'; -/** The frame budget the engine's queue spends, opened before each drain as a frame opens it. */ -const frame = createFrameBudget(2); +/** The frame budget the engine's queue spends, opened before each drain as a frame opens it: no + * ceiling, like the oracle it is compared with, so the two deliver alike on any machine. */ +const frame = createFrameBudget(Infinity); /** The fields `findAdmissible`/`sortStreamJobs` read: a lighter shape than the engine's `Job`. */ interface TravailAdmission { diff --git a/packages/sdk-browser/src/scene/partition/boxes.ts b/packages/sdk-browser/src/scene/partition/boxes.ts index d26a01fbc3..fa044ec2f3 100644 --- a/packages/sdk-browser/src/scene/partition/boxes.ts +++ b/packages/sdk-browser/src/scene/partition/boxes.ts @@ -26,10 +26,14 @@ const rootWorld = new Float64Array(MATRIX_VALUES), inverse = new Float64Array(MATRIX_VALUES); /** The least and the most `matrix` stretches a distance — its smallest and largest singular - * values —; a flattened frame stretches it by 0. */ + * values —; a flattened frame, or one so nearly flat its inverse overflows, stretches it by 0. */ export function stretchOf(matrix: ArrayLike): Stretch { - if (determinantMatrix4(matrix) === 0) return [0, maxStretch(matrix)]; - return [1 / maxStretch(invertMatrix4(inverse, matrix)), maxStretch(matrix)]; + const most = maxStretch(matrix); + if (determinantMatrix4(matrix) === 0) return [0, most]; + const back = invertMatrix4(inverse, matrix).every(Number.isFinite) + ? maxStretch(inverse) + : Infinity; + return [1 / back, most]; } /** diff --git a/packages/sdk-browser/src/scene/partition/plan.test.ts b/packages/sdk-browser/src/scene/partition/plan.test.ts index db2b52f5e9..3cd3d568bb 100644 --- a/packages/sdk-browser/src/scene/partition/plan.test.ts +++ b/packages/sdk-browser/src/scene/partition/plan.test.ts @@ -37,6 +37,9 @@ test('a sheared root reads every cell the world reach holds, by its least singul assert.deepEqual(eye, [0, 0, 0]); const flat = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; assert.equal(inCellFrame(flat, [0, 0, 0], 1).reach, Infinity, 'a flattened root reads all'); + // So nearly flat its inverse overflows: it reads all too, rather than throw. + const nearlyFlat = flat.map((value, at) => (at === 5 ? 1e-310 : value)); + assert.deepEqual(inCellFrame(nearlyFlat, [1, 2, 3], 1), { eye: [0, 0, 0], reach: Infinity }); }); test('the distance to a cell is the distance to its box', () => { diff --git a/packages/sdk-browser/src/scene/partition/plan.ts b/packages/sdk-browser/src/scene/partition/plan.ts index ccf2666ae1..679181364d 100644 --- a/packages/sdk-browser/src/scene/partition/plan.ts +++ b/packages/sdk-browser/src/scene/partition/plan.ts @@ -77,9 +77,11 @@ export function cellReach(optics: PartitionOptics) { * needs; a flattened root, which stretches some distance by 0, reads every cell. */ export function inCellFrame(world: ArrayLike, eye: ArrayLike, reach: number) { + const [least] = stretchOf(world); + if (!least) return { eye: [0, 0, 0], reach: Infinity }; // flattened: no eye there, every cell invertMatrix4(inverse, world); const local = transformAffinePoint([0, 0, 0], inverse, eye[0], eye[1], eye[2]); - return { eye: local, reach: reach / stretchOf(world)[0] }; + return { eye: local, reach: reach / least }; } /** Distance from `eye` to the nearest box `[minX, minY, minZ, maxX, maxY, maxZ]` of `bounds`, diff --git a/packages/sdk-browser/src/webgpu/row/claims.test.ts b/packages/sdk-browser/src/webgpu/row/claims.test.ts index 5bb6112ff4..eec48ce87a 100644 --- a/packages/sdk-browser/src/webgpu/row/claims.test.ts +++ b/packages/sdk-browser/src/webgpu/row/claims.test.ts @@ -4,12 +4,10 @@ import { createWebgpuRowClaims, serveClaims } from './claims.ts'; import { createArrivalQueue } from '../../page/integration/arrivalQueue.ts'; import { createFrameBudget } from '../../page/integration/frameBudget.ts'; +/** The clock the budgets read, which a row write moves: no test waits on the machine's time. */ +let now = 0; /** A row write slower than the per-image budget: the clock always runs out after one row. */ -const slowPlace = () => { - const until = performance.now() + 3; - while (performance.now() < until); - return true; -}; +const slowPlace = () => ((now += 3), true); const owed = () => { const claims = createWebgpuRowClaims(4); @@ -19,7 +17,7 @@ const owed = () => { test('an image writes owed rows within its time budget and leaves the rest owed', () => { const claims = owed(), - budget = createFrameBudget(2); + budget = createFrameBudget(2, () => now); budget.open(); assert.equal( serveClaims(claims, () => true, slowPlace, budget), @@ -43,7 +41,6 @@ test('a barrier image lifts the time budget and writes every owed row', () => { }); test('the rows a frame writes spend what its arrivals left of its one integration budget', () => { - let now = 0; const budget = createFrameBudget(2, () => now); const arrivals = createArrivalQueue(1 << 20, 64, budget); const receiver = { acceptPage: () => void (now += 1) }; diff --git a/packages/sdk-browser/src/world/render/render.ts b/packages/sdk-browser/src/world/render/render.ts index 5e5bec5fd5..76c6c1aa2a 100644 --- a/packages/sdk-browser/src/world/render/render.ts +++ b/packages/sdk-browser/src/world/render/render.ts @@ -98,13 +98,13 @@ export function createExplorerRender(session: ExplorerRenderSession, inputs: Inp const frameNumber = ++state.hostFrame; const start = performance.now(); if (pose) setPose(pose); + guides?.follow(); // no integration: it spends none of the budget // One integration budget per frame: the cells placed, then the arrivals drained, both // outside the frame they would have lengthened; the engine's row records spend what is left. frameBudget.open(); - let arrivalStart = start; + let arrivalStart: number; try { followCells?.(); - guides?.follow(); arrivalStart = performance.now(); streaming.arrivals.drain(); } finally { diff --git a/packages/sdk-browser/src/world/render/triangles.test.ts b/packages/sdk-browser/src/world/render/triangles.test.ts index 1f9c69f5dd..109500d7e9 100644 --- a/packages/sdk-browser/src/world/render/triangles.test.ts +++ b/packages/sdk-browser/src/world/render/triangles.test.ts @@ -5,14 +5,20 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createExplorerRender } from './render.ts'; -import { createFrameBudget } from '../../page/integration/frameBudget.ts'; +import { createFrameBudget, type FrameClock } from '../../page/integration/frameBudget.ts'; import { createDiagnosticChannel } from '../../diagnostic/channel.ts'; import type { RenderBackend } from '../../backend/types.ts'; import type { FrameMetrics } from '../../../../sdk-core/src/index.ts'; /** A minimal set of inputs for `createExplorerRender`: mute draw, diagnostic off, audit * off (no `trillion3dFrameAudit` in the test URL). Only `directGpu` and the engine count vary. */ -function harness(options: { directGpu: boolean; counted?: number | null; order?: string[] }) { +function harness(options: { + directGpu: boolean; + counted?: number | null; + order?: string[]; + frameBudget?: FrameClock; + drain?: () => void; +}) { const active = { id: 'test-backend' } as unknown as RenderBackend; const metricsScratch = { drawCalls: 0, totalSubmittedTriangles: null } as unknown as FrameMetrics; const session = { @@ -38,8 +44,8 @@ function harness(options: { directGpu: boolean; counted?: number | null; order?: camera: {} as never, lookAtTarget: { x: 0, y: 0, z: 0 }, setPose: () => {}, - streaming: { arrivals: { drain: () => {} } } as never, - frameBudget: createFrameBudget(Infinity), + streaming: { arrivals: { drain: options.drain ?? (() => {}) } } as never, + frameBudget: options.frameBudget ?? createFrameBudget(Infinity), drawBackend: () => options.order?.push('draw'), ensureTarget: ((target?: unknown) => target) as never, directGpu: options.directGpu, @@ -85,3 +91,32 @@ test('each frame moves the followed guides once, before it draws', () => { render(); assert.deepEqual(order, ['follow', 'draw', 'follow', 'draw']); }); + +// #404: the frame's one budget runs while it integrates, and stops before the engine draws — whose +// row records resume it —, on every path. +test('a frame runs its integration budget around the arrivals only, and stops it even on a throw', () => { + const order: string[] = []; + const budget = createFrameBudget(Infinity); + const frameBudget = { + ...budget, + open: () => (order.push('open'), budget.open()), + pause: () => void (order.push('pause'), budget.pause()), + }; + const { render } = harness({ + directGpu: false, + order, + frameBudget, + drain: () => order.push('drain'), + }); + render(); + assert.deepEqual(order, ['follow', 'open', 'drain', 'pause', 'draw']); + order.length = 0; + const failing = harness({ + directGpu: false, + order, + frameBudget, + drain: () => assert.fail('drain'), + }); + assert.throws(() => failing.render()); + assert.deepEqual(order, ['follow', 'open', 'pause']); +}); diff --git a/packages/sdk-browser/src/world/scene/partitionFrame.test.ts b/packages/sdk-browser/src/world/scene/partitionFrame.test.ts index 557410767b..84f6c1f124 100644 --- a/packages/sdk-browser/src/world/scene/partitionFrame.test.ts +++ b/packages/sdk-browser/src/world/scene/partitionFrame.test.ts @@ -105,16 +105,18 @@ test('a pebble far below any error target is read while the far plane lets it be ]); }); -test('a camera zoomed out reads the cells its wider frustum sees', () => { - // At zoom 0.5 the frustum is twice as wide: a pebble 560 m aside, 290 m ahead, is within its far - // plane's corner, past the reach — and the read-ahead — of the same camera at zoom 1. +test('a camera zoomed out, or scaled up, reads the cells its wider frustum sees', () => { + // At zoom 0.5 the frustum is twice as wide, scaled twice it draws twice as far: a pebble 560 m + // aside, 290 m ahead, is within either's reach, past the read-ahead of the camera at zoom 1. const bounds = [560, 0, -290, 560.01, 0.01, -289.99]; + const seen = [['https://cache.test/key/aside.json'], PRIORITY_VISIBLE]; const camera = hostFramingCamera(60, 16 / 9, 0.1, 300); assert.deepEqual(asks('aside.json', bounds, camera), []); camera.zoom = 0.5; - assert.deepEqual(asks('aside.json', bounds, camera), [ - [['https://cache.test/key/aside.json'], PRIORITY_VISIBLE], - ]); + assert.deepEqual(asks('aside.json', bounds, camera), [seen]); + camera.zoom = 1; + (camera as unknown as Group).scale.set(2, 2, 2); + assert.deepEqual(asks('aside.json', bounds, camera), [seen]); }); test('a reach past the rows sized at open asks the owner to open the session again', () => { diff --git a/packages/sdk-browser/src/world/scene/partitionFrame.ts b/packages/sdk-browser/src/world/scene/partitionFrame.ts index 63a111cffb..1c47407136 100644 --- a/packages/sdk-browser/src/world/scene/partitionFrame.ts +++ b/packages/sdk-browser/src/world/scene/partitionFrame.ts @@ -6,6 +6,7 @@ * The reach is the frame camera's far plane, never a number of the scene's * (`../../scene/partition/plan.ts`). */ +import { maxStretch } from '../../../../sdk-core/src/index.ts'; import { PRIORITY_PREFETCH, PRIORITY_VISIBLE } from '../../streaming/priority.ts'; import type { RenderBackend } from '../../backend/types.ts'; import type { FrameBudget } from '../../page/integration/frameBudget.ts'; @@ -16,10 +17,15 @@ import type { createPageStreamer } from '../../streaming/pageStreamer.ts'; type Streamer = ReturnType; -/** Where a camera's eye stands in the world. */ -function eyeOf(camera: HostCamera) { +/** Where a camera's eye stands in the world, and its reach there: the frustum its world matrix + * poses, which a scaled camera — or one under a scaled rig — stretches by up to its largest + * singular value (`maxStretch`). */ +function viewOf(camera: HostCamera) { const elements = resolveCameraWorld(camera).matrixWorld.elements; - return [elements[12], elements[13], elements[14]]; + return { + eye: [elements[12], elements[13], elements[14]], + reach: cellReach(camera) * maxStretch(elements), + }; } /** @@ -34,8 +40,7 @@ export async function primePartitions( owned: boolean, signal?: AbortSignal, ) { - const reach = cellReach(camera); - const eye = eyeOf(camera); + const { eye, reach } = viewOf(camera); const read = (url: string) => streamer.readBytes(url, signal); const bytes = await Promise.all(partitions.map((cells) => cells.prime(eye, reach, read, owned))); return bytes.reduce((sum, value) => sum + value, 0); @@ -91,8 +96,7 @@ export function createPartitionFrame(inputs: Inputs) { grow: backend.growPlacements?.bind(backend), outgrown: renew, }; - const reach = cellReach(camera); - const eye = eyeOf(camera); + const { eye, reach } = viewOf(camera); later = false; for (const cells of partitions) later = cells.frame(eye, reach, io, budget) || later; }; diff --git a/packages/sdk-browser/src/world/session/backends.ts b/packages/sdk-browser/src/world/session/backends.ts index 363c32ed10..6aebea6e40 100644 --- a/packages/sdk-browser/src/world/session/backends.ts +++ b/packages/sdk-browser/src/world/session/backends.ts @@ -24,7 +24,7 @@ type Inputs = { backends: RenderBackend[]; /** Manifest url base: that is what locates the resident-proxy cache object. */ base: string; - frameBudget: BackendContext['frameBudget']; + frameBudget?: BackendContext['frameBudget']; }; export async function prepareExplorerBackends(session: ExplorerSession, inputs: Inputs) { From 07e9e3d30b901cb9cd1732deb09705e7d40b4a7e Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 05:37:16 +0200 Subject: [PATCH 09/11] refactor(geometry): the cell tests keep their helpers, only the world moves to the fixture; the frame's budget wiring is checked in the guides' order test (#404) --- .../src/scene/partition/cells.fixture.ts | 34 +----------- .../src/scene/partition/cells.test.ts | 32 +++++++++++- .../src/world/render/triangles.test.ts | 52 ++++++------------- 3 files changed, 48 insertions(+), 70 deletions(-) diff --git a/packages/sdk-browser/src/scene/partition/cells.fixture.ts b/packages/sdk-browser/src/scene/partition/cells.fixture.ts index af51c2b75e..b21ee91d8c 100644 --- a/packages/sdk-browser/src/scene/partition/cells.fixture.ts +++ b/packages/sdk-browser/src/scene/partition/cells.fixture.ts @@ -1,11 +1,8 @@ import type { TablePartition } from '../../../../sdk-core/src/scene/core/tablePartition.ts'; import { Group, Object3D } from '../../../../sdk-core/src/world/object/object3d.ts'; -import type { PlacementRows } from '../../placement/rows.ts'; -import { createPartitionCells, type PartitionCells } from './cells.ts'; +import { createPartitionCells } from './cells.ts'; import { placedMesh, type RowLink } from './rows.ts'; -type PartitionIo = Parameters[2]; - /** Two cells of one mesh, one near the origin and one 5 km away; each hangs under a moved core * node, or under the scene root when its `far` or `near` is null. */ export function world(far: number | null = 0, near: number | null = null) { @@ -64,32 +61,3 @@ export function world(far: number | null = 0, near: number | null = null) { new TextEncoder().encode(JSON.stringify(bodies[url.split('/').at(-1)!])); return { cells, links, root, core, bytes, node }; } - -/** An io that holds every cell already read, records what it is asked and each time the reach - * outgrew the rows. */ -export function io(bytes: (url: string) => Uint8Array) { - const asked: string[] = [], - updates: [PlacementRows, number, number][] = []; - const held = new Set(); - const outgrown = { count: 0 }; - const port: PartitionIo = { - bytes: (url) => (held.has(url) ? bytes(url) : undefined), - loading: () => false, - request: (urls) => void asked.push(...urls), - update: (rows, from, to) => updates.push([rows, from, to]), - outgrown: () => void outgrown.count++, - }; - return { port, asked, updates, held, outgrown }; -} - -/** A reach past both cells. */ -export const everywhere = 1e5; -/** Opens a session on `cells` for `reach` from far away, an owner to open it again unless - * `owned` is false: its rows are sized, no cell is read. */ -export const opened = (cells: PartitionCells, reach: number, owned = true) => - cells.prime([1e9, 0, 0], reach, () => Promise.reject(new Error('nothing is read')), owned); -/** No arrival budget: what a test places never depends on the time the machine takes. */ -export const noBudget = { admits: () => true, spend() {} }; -export const row = (rows: PlacementRows, at: number) => [ - ...rows.matrices.subarray(at * 16, at * 16 + 16), -]; diff --git a/packages/sdk-browser/src/scene/partition/cells.test.ts b/packages/sdk-browser/src/scene/partition/cells.test.ts index 1b335ea649..877aaefe1a 100644 --- a/packages/sdk-browser/src/scene/partition/cells.test.ts +++ b/packages/sdk-browser/src/scene/partition/cells.test.ts @@ -4,7 +4,37 @@ import { Object3D } from '../../../../sdk-core/src/world/object/object3d.ts'; import { pose } from '../../host/prepared/nodes.ts'; import { hostWorldChainInto } from '../../host/world/chain.ts'; import type { PlacementRows } from '../../placement/rows.ts'; -import { everywhere, io, noBudget, opened, row, world } from './cells.fixture.ts'; +import type { PartitionCells } from './cells.ts'; +import { world } from './cells.fixture.ts'; + +type PartitionIo = Parameters[2]; + +/** An io that holds every cell already read, records what it is asked and each time the reach + * outgrew the rows. */ +function io(bytes: (url: string) => Uint8Array) { + const asked: string[] = [], + updates: [PlacementRows, number, number][] = []; + const held = new Set(); + const outgrown = { count: 0 }; + const port: PartitionIo = { + bytes: (url) => (held.has(url) ? bytes(url) : undefined), + loading: () => false, + request: (urls) => void asked.push(...urls), + update: (rows, from, to) => updates.push([rows, from, to]), + outgrown: () => void outgrown.count++, + }; + return { port, asked, updates, held, outgrown }; +} + +/** A reach past both cells. */ +const everywhere = 1e5; +/** Opens a session on `cells` for `reach` from far away, an owner to open it again unless + * `owned` is false: its rows are sized, no cell is read. */ +const opened = (cells: PartitionCells, reach: number, owned = true) => + cells.prime([1e9, 0, 0], reach, () => Promise.reject(new Error('nothing is read')), owned); +/** No arrival budget: what a test places never depends on the time the machine takes. */ +const noBudget = { admits: () => true, spend() {} }; +const row = (rows: PlacementRows, at: number) => [...rows.matrices.subarray(at * 16, at * 16 + 16)]; test('the cells a camera needs are asked nearest first, then placed on rows once read', async () => { const { cells, links, bytes } = world(); diff --git a/packages/sdk-browser/src/world/render/triangles.test.ts b/packages/sdk-browser/src/world/render/triangles.test.ts index 109500d7e9..ea7c88902e 100644 --- a/packages/sdk-browser/src/world/render/triangles.test.ts +++ b/packages/sdk-browser/src/world/render/triangles.test.ts @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createExplorerRender } from './render.ts'; -import { createFrameBudget, type FrameClock } from '../../page/integration/frameBudget.ts'; +import { createFrameBudget } from '../../page/integration/frameBudget.ts'; import { createDiagnosticChannel } from '../../diagnostic/channel.ts'; import type { RenderBackend } from '../../backend/types.ts'; import type { FrameMetrics } from '../../../../sdk-core/src/index.ts'; @@ -16,9 +16,9 @@ function harness(options: { directGpu: boolean; counted?: number | null; order?: string[]; - frameBudget?: FrameClock; - drain?: () => void; + fails?: boolean; }) { + const note = (step: string) => void options.order?.push(step); const active = { id: 'test-backend' } as unknown as RenderBackend; const metricsScratch = { drawCalls: 0, totalSubmittedTriangles: null } as unknown as FrameMetrics; const session = { @@ -44,8 +44,14 @@ function harness(options: { camera: {} as never, lookAtTarget: { x: 0, y: 0, z: 0 }, setPose: () => {}, - streaming: { arrivals: { drain: options.drain ?? (() => {}) } } as never, - frameBudget: options.frameBudget ?? createFrameBudget(Infinity), + streaming: { + arrivals: { drain: () => (note('drain'), options.fails && assert.fail()) }, + } as never, + frameBudget: { + ...createFrameBudget(Infinity), + open: () => (note('open'), 0), + pause: () => note('pause'), + }, drawBackend: () => options.order?.push('draw'), ensureTarget: ((target?: unknown) => target) as never, directGpu: options.directGpu, @@ -83,40 +89,14 @@ test('triangles publishes the engine submitted total as soon as it exists', () = } }); -// #264: the guides that follow a node are moved once per frame, before it draws. -test('each frame moves the followed guides once, before it draws', () => { +// #264: the guides that follow a node are moved once per frame, before it draws. #404: the frame's +// one budget runs around its integration only, and stops before the engine draws, on every path. +test('each frame moves the followed guides once, and integrates within its budget, before it draws', () => { const order: string[] = []; const { render } = harness({ directGpu: false, order }); render(); - render(); - assert.deepEqual(order, ['follow', 'draw', 'follow', 'draw']); -}); - -// #404: the frame's one budget runs while it integrates, and stops before the engine draws — whose -// row records resume it —, on every path. -test('a frame runs its integration budget around the arrivals only, and stops it even on a throw', () => { - const order: string[] = []; - const budget = createFrameBudget(Infinity); - const frameBudget = { - ...budget, - open: () => (order.push('open'), budget.open()), - pause: () => void (order.push('pause'), budget.pause()), - }; - const { render } = harness({ - directGpu: false, - order, - frameBudget, - drain: () => order.push('drain'), - }); - render(); assert.deepEqual(order, ['follow', 'open', 'drain', 'pause', 'draw']); order.length = 0; - const failing = harness({ - directGpu: false, - order, - frameBudget, - drain: () => assert.fail('drain'), - }); - assert.throws(() => failing.render()); - assert.deepEqual(order, ['follow', 'open', 'pause']); + assert.throws(harness({ directGpu: false, order, fails: true }).render); + assert.deepEqual(order, ['follow', 'open', 'drain', 'pause'], 'a drain that throws still pauses'); }); From 9165dd3ddabfac70cb8db252ed7cd7a49be8ab69 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 05:37:36 +0200 Subject: [PATCH 10/11] refactor(streaming): the arrival reference test takes built queues; the row claims' contract reads in one paragraph (#404) --- .../src/page/integration/arrivalQueue.test.ts | 10 +++------- packages/sdk-browser/src/webgpu/row/claims.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts b/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts index 503ec8860d..5d1d9d3697 100644 --- a/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts +++ b/packages/sdk-browser/src/page/integration/arrivalQueue.test.ts @@ -70,13 +70,11 @@ test('a page already waiting for a target is queued once, and each target keeps // Delivery preserves exact pages and order despite removing implicit rendering. test('many duplicate targets across a drain deliver exactly like the reference', () => { - type Queue = Pick, 'queue' | 'drain'>; - function arrivals(create: (byteBudget: number, countBudget: number) => Queue) { + function arrivals(queue: Pick, 'queue' | 'drain'>) { const delivered: string[] = []; const targets = Array.from({ length: 8 }, (_, c) => ({ acceptPage: (url: string) => delivered.push(`${c}:${url}`), })); - const queue = create(1 << 20, 4096); const bytes = new Uint32Array(4); // Round-robin over the eight targets so the touched list sees many repeats before a drain. for (let i = 0; i < 500; i++) queue.queue(targets[i % 8], `page-${i % 50}.bin`, bytes); @@ -84,10 +82,8 @@ test('many duplicate targets across a drain deliver exactly like the reference', for (let d = 0; d < 3; d++) livrs += queue.drain(); return { delivered, livrs }; } - const optimisee = arrivals((bytes, count) => - createArrivalQueue(bytes, count, createFrameBudget(Infinity)), - ); - const reference = arrivals(referenceArrivalQueue); + const optimisee = arrivals(createArrivalQueue(1 << 20, 4096, createFrameBudget(Infinity))); + const reference = arrivals(referenceArrivalQueue(1 << 20, 4096)); assert.deepEqual(optimisee, reference); }); diff --git a/packages/sdk-browser/src/webgpu/row/claims.ts b/packages/sdk-browser/src/webgpu/row/claims.ts index 2af837174c..4fe59b6c9c 100644 --- a/packages/sdk-browser/src/webgpu/row/claims.ts +++ b/packages/sdk-browser/src/webgpu/row/claims.ts @@ -49,12 +49,12 @@ export function createWebgpuRowClaims(pageCount: number) { export type WebgpuRowClaims = ReturnType; /** - * Serves the queue in increasing page order within `budget`: the frame's one integration budget - * (`BackendContext.frameBudget`), which its frame opened and its cells and arrivals spent from - * first, its clock running only while rows are written — the clock is reread after each row and the rest waits for the next image, in the same - * order. At least one row always goes through, or a page would never be written. `release` says - * again whether the page still claims a row — it may have left since it enrolled, and then leaves - * the queue costing nothing —, `place` writes it and returns `false` when the table is full. + * Serves the queue in increasing page order within `budget`, the frame's one integration budget + * (`BackendContext.frameBudget`): what its cells and arrivals left, its clock running only while + * rows are written and reread after each; the rest waits for the next image, in the same order. + * At least one row always goes through, or a page would never be written. `release` says again + * whether the page still claims a row — it may have left since it enrolled, and then leaves the + * queue costing nothing —, `place` writes it and returns `false` when the table is full. * * Returns the number of pages a table overflow leaves without a rank: never those the time budget * alone deferred, which overflow nothing. No `budget` — a barrier image, outside the measured From 4ecd8a1e85b4551c1b194fb17e43dbd17a41cd2f Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 06:52:15 +0200 Subject: [PATCH 11/11] fix(geometry): the partition's frame signature keeps its budget and growth types internal (#404) --- packages/sdk-browser/src/scene/partition/cells.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk-browser/src/scene/partition/cells.ts b/packages/sdk-browser/src/scene/partition/cells.ts index d37d09c61f..e01c7fd1ea 100644 --- a/packages/sdk-browser/src/scene/partition/cells.ts +++ b/packages/sdk-browser/src/scene/partition/cells.ts @@ -132,10 +132,10 @@ export function createPartitionCells(inputs: Inputs) { loading(url: string): boolean; request(urls: readonly string[], ahead: boolean): void; update(rows: PlacementRows, from: number, to: number): void; - grow?: Grow; + grow?(from: PlacementRows, to: PlacementRows): void; outgrown?: () => void; }, - budget: import('../../page/integration/frameBudget.ts').FrameBudget, + budget: { admits(): boolean; spend(): void }, // structurally a `FrameBudget`, kept internal ) { followParents(); const local = inCellFrame(hostWorldChainInto(rootWorld, root), eye, reach);