Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions bench/perf/browser/arrival-admission.perf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}

Expand All @@ -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 },
Expand Down
18 changes: 10 additions & 8 deletions docs/FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk-browser/src/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ export interface BackendContext {
/** Host-owned, validated page reader for the initial complete GPU fallback. */
readPage?: (url: string) => Promise<Uint32Array>;
readGeometryPage?: (url: string) => Promise<Uint8Array>;
/** 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';
49 changes: 33 additions & 16 deletions packages/sdk-browser/src/page/integration/arrivalQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -69,25 +70,24 @@ 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<ReturnType<typeof referenceArrivalQueue>, '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);
let livrs = 0;
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[] = [];
Expand All @@ -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) =>
Expand Down Expand Up @@ -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];
};
Expand Down
40 changes: 17 additions & 23 deletions packages/sdk-browser/src/page/integration/arrivalQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -54,29 +55,24 @@ 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<ArrivalTarget, Set<string>>();
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;
waiting.get(item.target)?.delete(item.url);
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;
Expand Down Expand Up @@ -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
Expand All @@ -143,7 +137,7 @@ export function createArrivalQueue(byteBudget: number, countBudget: number, msBu
}
head++;
deliver(item);
spend();
budget.spend();
bytes += item.array.byteLength;
count++;
}
Expand Down
24 changes: 16 additions & 8 deletions packages/sdk-browser/src/page/integration/frameBudget.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createFrameBudget>;

/** `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++,
};
}
Expand Down
13 changes: 9 additions & 4 deletions packages/sdk-browser/src/scene/partition/boxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>): 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<number>): 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];
}

/**
Expand Down
Loading
Loading