diff --git a/bench/perf/browser/arrival-admission.perf.ts b/bench/perf/browser/arrival-admission.perf.ts index eb10b4aad8..036d40692b 100644 --- a/bench/perf/browser/arrival-admission.perf.ts +++ b/bench/perf/browser/arrival-admission.perf.ts @@ -2,12 +2,17 @@ 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'; 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: 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 { url: string; @@ -77,7 +82,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++) { + frame.open(); + livrs += file.drain(); + } return { livrees, livrs }; } @@ -97,14 +105,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/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 af6ab13958..71733af095 100644 --- a/packages/sdk-browser/src/backend/types.ts +++ b/packages/sdk-browser/src/backend/types.ts @@ -193,6 +193,8 @@ 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 (`frameBudget.ts`); absent, 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..5d1d9d3697 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'); @@ -69,12 +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', () => { - function arrivals(create: typeof createArrivalQueue) { + 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, 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); @@ -82,12 +82,12 @@ 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(createArrivalQueue(1 << 20, 4096, createFrameBudget(Infinity))); + const reference = arrivals(referenceArrivalQueue(1 << 20, 4096)); 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[] = []; @@ -97,22 +97,38 @@ 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)); - assert.equal(queue.drain(), 2, 'two 1 ms deliveries reach the default 2 ms ceiling'); + const frame = () => (budget.open(), queue.drain()); + 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(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)); + budget.open(); + budget.spend(); + now = 3; // the frame's cells spent it + assert.equal(queue.drain(), 0, 'the page waits for the next frame'); + budget.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) => { - // 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) => @@ -160,11 +176,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 0f19e601bb..986d907617 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: 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 } 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. */ @@ -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, + /** 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, - opened = false; - const { admits, spend, ...budget } = createFrameBudget(msBudget); - const open = () => { - opened = true; - budget.open(); - }; + let head = 0; /** 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(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, - admits, - spend, /** Arrivals still waiting to drain. */ get pending() { return items.length - head; @@ -119,16 +115,14 @@ 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 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()) { + 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 @@ -143,7 +137,7 @@ export function createArrivalQueue(byteBudget: number, countBudget: number, msBu } head++; deliver(item); - spend(); + budget.spend(); bytes += item.array.byteLength; count++; } diff --git a/packages/sdk-browser/src/page/integration/frameBudget.ts b/packages/sdk-browser/src/page/integration/frameBudget.ts index 119239ab59..8fb4bd2908 100644 --- a/packages/sdk-browser/src/page/integration/frameBudget.ts +++ b/packages/sdk-browser/src/page/integration/frameBudget.ts @@ -1,27 +1,35 @@ /** * 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. * - * 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`), 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`). */ 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()) { 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/boxes.ts b/packages/sdk-browser/src/scene/partition/boxes.ts index 7edcfd3a04..fa044ec2f3 100644 --- a/packages/sdk-browser/src/scene/partition/boxes.ts +++ b/packages/sdk-browser/src/scene/partition/boxes.ts @@ -25,10 +25,15 @@ 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 { - if (determinantMatrix4(matrix) === 0) return [0, maxStretch(matrix)]; - return [1 / maxStretch(invertMatrix4(inverse, matrix)), maxStretch(matrix)]; +/** The least and the most `matrix` stretches a distance — its smallest and largest singular + * values —; a flattened frame, or one so nearly flat its inverse overflows, stretches it by 0. */ +export function stretchOf(matrix: ArrayLike): Stretch { + 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/cells.fixture.ts b/packages/sdk-browser/src/scene/partition/cells.fixture.ts new file mode 100644 index 0000000000..b21ee91d8c --- /dev/null +++ b/packages/sdk-browser/src/scene/partition/cells.fixture.ts @@ -0,0 +1,63 @@ +import type { TablePartition } from '../../../../sdk-core/src/scene/core/tablePartition.ts'; +import { Group, Object3D } from '../../../../sdk-core/src/world/object/object3d.ts'; +import { createPartitionCells } from './cells.ts'; +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. */ +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 }; +} diff --git a/packages/sdk-browser/src/scene/partition/cells.test.ts b/packages/sdk-browser/src/scene/partition/cells.test.ts index c00219142c..877aaefe1a 100644 --- a/packages/sdk-browser/src/scene/partition/cells.test.ts +++ b/packages/sdk-browser/src/scene/partition/cells.test.ts @@ -1,73 +1,13 @@ 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'; +import type { PartitionCells } from './cells.ts'; +import { world } from './cells.fixture.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) { - 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, null), node(3, null)] }, - '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: [[null, [0, 0, 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. */ @@ -165,6 +105,34 @@ 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, 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}`)); + 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(); + 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], after[at]]), + ); + // 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)); + } +}); + 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..e01c7fd1ea 100644 --- a/packages/sdk-browser/src/scene/partition/cells.ts +++ b/packages/sdk-browser/src/scene/partition/cells.ts @@ -3,13 +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, asks to reopen. + * 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 { @@ -22,9 +23,10 @@ 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 Grow = (from: PlacementRows, to: PlacementRows) => void; type Placement = { mesh: PlacedMesh; row: number; parent: Object3D; local: Float64Array }; type Inputs = { partition: TablePartition; @@ -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,14 @@ 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 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; + short = false; }; return { /** Every cell as the streamer's catalogue reads it. */ @@ -124,25 +125,32 @@ 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). */ - budget: { admits(): boolean; spend(): void }, + budget: { admits(): boolean; spend(): void }, // structurally a `FrameBudget`, kept internal ) { 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))) { - short = true; - io.outgrown?.(); + 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?.(); + } } plan.leave.forEach(leave); waiting = 0; @@ -165,7 +173,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 +187,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/plan.test.ts b/packages/sdk-browser/src/scene/partition/plan.test.ts index 2c9a902ff0..3cd3d568bb 100644 --- a/packages/sdk-browser/src/scene/partition/plan.test.ts +++ b/packages/sdk-browser/src/scene/partition/plan.test.ts @@ -1,15 +1,45 @@ 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, 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', () => { 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)); + // 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', () => { + // `(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'); + // 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 744b3e2440..679181364d 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, @@ -20,15 +21,26 @@ * 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, + 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,33 +48,39 @@ 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' | 'near' | '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; // a zoom of 0 draws nothing: read as 1, never as an empty reach + if (orthographic) { + 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) + 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)); } /** * `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]))); + const [least] = stretchOf(world); + if (!least) return { eye: [0, 0, 0], reach: Infinity }; // flattened: no eye there, every cell 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 / least }; } 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/scene/partition/sizing.ts b/packages/sdk-browser/src/scene/partition/sizing.ts index f88ca0df3e..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); @@ -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/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..eec48ce87a 100644 --- a/packages/sdk-browser/src/webgpu/row/claims.test.ts +++ b/packages/sdk-browser/src/webgpu/row/claims.test.ts @@ -1,13 +1,13 @@ 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'; +/** 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); @@ -16,9 +16,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, () => now); + 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 +34,33 @@ 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 integration budget', () => { + 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(); + /** 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 98c3fa1c40..4fe59b6c9c 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 { FrameClock } 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,42 +49,46 @@ 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`): 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. `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?: FrameClock, ) { if (!claims.count) return 0; claims.sort(); - const budget = claims.budget; - budget.open(); + 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++; - budget.spend(); - if (bounded && !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(); } 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 129f002f7a..ec827c3227 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 { FrameClock } from '../../page/integration/frameBudget.ts'; type Rows = ReturnType; type Writer = ReturnType; @@ -157,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 time 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; @@ -172,7 +173,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, 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 957ef2259a..6c430853cb 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 { FrameClock } from '../../page/integration/frameBudget.ts'; type Rows = ReturnType; type Mirror = ReturnType; @@ -24,6 +25,8 @@ 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?: FrameClock, ) { const slots = createWebgpuRowSlots(rows, packedPages, drawSlots, writePageRow, onResidenceChange); /** The blended clusters' caster rows, behind the visibility rows: they follow the residency the @@ -33,7 +36,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; @@ -52,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/hostFrame.ts b/packages/sdk-browser/src/world/render/hostFrame.ts index de673b56c5..537752e69f 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, @@ -62,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, @@ -73,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..76c6c1aa2a 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, @@ -94,13 +98,18 @@ 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. - streaming.arrivals.open(); - followCells?.(); - guides?.follow(); - const arrivalStart = performance.now(); - streaming.arrivals.drain(); + // outside the frame they would have lengthened; the engine's row records spend what is left. + frameBudget.open(); + let arrivalStart: number; + try { + followCells?.(); + 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/render/triangles.test.ts b/packages/sdk-browser/src/world/render/triangles.test.ts index 8288b98f4d..ea7c88902e 100644 --- a/packages/sdk-browser/src/world/render/triangles.test.ts +++ b/packages/sdk-browser/src/world/render/triangles.test.ts @@ -5,13 +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 { 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[]; + 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 = { @@ -37,7 +44,14 @@ 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: () => (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, @@ -75,11 +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']); + assert.deepEqual(order, ['follow', 'open', 'drain', 'pause', 'draw']); + order.length = 0; + assert.throws(harness({ directGpu: false, order, fails: true }).render); + assert.deepEqual(order, ['follow', 'open', 'drain', 'pause'], 'a drain that throws still pauses'); }); diff --git a/packages/sdk-browser/src/world/scene/partitionFrame.test.ts b/packages/sdk-browser/src/world/scene/partitionFrame.test.ts index 4b6fc9803b..84f6c1f124 100644 --- a/packages/sdk-browser/src/world/scene/partitionFrame.test.ts +++ b/packages/sdk-browser/src/world/scene/partitionFrame.test.ts @@ -70,36 +70,53 @@ 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, 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), [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 6566b0b22e..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); @@ -46,11 +51,12 @@ 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 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,10 +93,10 @@ export function createPartitionFrame(inputs: Inputs) { request, update: (...range: Parameters>) => backend.updatePlacements?.(...range), + 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/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..6aebea6e40 100644 --- a/packages/sdk-browser/src/world/session/backends.ts +++ b/packages/sdk-browser/src/world/session/backends.ts @@ -24,6 +24,7 @@ type Inputs = { backends: RenderBackend[]; /** Manifest url base: that is what locates the resident-proxy cache object. */ base: string; + frameBudget?: BackendContext['frameBudget']; }; export async function prepareExplorerBackends(session: ExplorerSession, inputs: Inputs) { @@ -125,6 +126,7 @@ export async function prepareExplorerBackends(session: ExplorerSession, inputs: readTextureLevel: createTextureLevelReader(metadata.textures, base, signal), sceneLights, importedLightIds, + frameBudget: inputs.frameBudget, }; for (const factory of factories) { const backend = factory(context); diff --git a/packages/sdk-browser/src/world/session/prepare.ts b/packages/sdk-browser/src/world/session/prepare.ts index 7f8fe8292b..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,6 +145,8 @@ export async function prepareExplorer(session: ExplorerSession, inputs: Inputs) cells: loadedScene.partitions.map((cells) => cells.stats()), }); } + // 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, @@ -155,6 +159,7 @@ export async function prepareExplorer(session: ExplorerSession, inputs: Inputs) factories: choice.factories, backends, base, + frameBudget, }); return { source, @@ -164,6 +169,7 @@ export async function prepareExplorer(session: ExplorerSession, inputs: Inputs) directGpu, viewport, context, + frameBudget, ...cameraState, }; } 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'); + }, +); 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);