Settle imagery, world data and sky capabilities in the renderer contract

- setFrames takes a timed source (URL frames, or video tiles in
  segments) on the scene clock by default or its own; frameReady fires
  once each time the shown frame is drawn.
- drapeOn is 'surface' | 'objects' | 'all' plus lit; imagery.host() and
  onHostChange go, and the drape rule Core derives is written out.
- ImagerySpec.fetchTile answers bytes, missing or defer on every
  renderer; tileTransform, admit and canvas imagery need imageHooks, and
  add() throws for them without it.
- Generic names for the default renderer's world data: worldDataToken,
  the photoreal fallback route, and world terrain and imagery.
- clouds and water capabilities, with globe.setClouds and setWater.
This commit is contained in:
Sameh Khamis committed 2026-11-05 13:26:14 -08:00
1 parent e9c0dc5405
commit fefdf242ee
7 files changed
+360 -52

No files matched your search

+3 -1
View File
@@ -166,4 +166,6 @@ easing (`courseBlendMs`, `courseGlide`), and a hidden row keeping its eased
course. The stub keeps no drawn course. These wait for scenes that capture
an icon mid-turn. Height queries that count a set
(`includeInHeightQueries`) are not checked either: the stub's heights are
scripted, so it does not model them.
scripted, so it does not model them. Nor are video frames (a check needs a
video), `fetchTile` (the stub fetches nothing) or what clouds and water
draw.
+159 -1
View File
@@ -20,6 +20,14 @@ import {
} from 'satellite.js';
import { deepEqual, equal, near, nearVec, ok, throws } from '../expect.js';
/** 1 × 1 PNGs (red, green, blue), so imagery checks load nothing from the network. */
const PIXEL_PNGS = [
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==',
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNg+M/wHwAEAQH/cetH5QAAAABJRU5ErkJggg==',
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYPj/HwADAgH/5ncLrgAAAABJRU5ErkJggg==',
];
const PIXEL_PNG = PIXEL_PNGS[0];
/** Elements for the orbit check: a station-like orbit, epoch 2026-01-04. */
const ISS_TLE = Object.freeze([
'1 25544U 98067A 26004.50000000 .00016717 00000-0 10270-3 0 9992',
@@ -1674,11 +1682,161 @@ export function portChecks() {
deepEqual(ours(), ['a', 'b'], 'lowered');
a.alpha = 0.5;
equal(a.alpha, 0.5, 'alpha');
equal(b.drapeOn, 'all', 'drapes on whatever is drawn by default');
equal(b.lit, true, 'lit by default');
b.drapeOn = 'surface';
b.lit = false;
equal(b.drapeOn, 'surface', 'drapeOn is settable');
equal(b.lit, false, 'lit is settable');
a.remove();
deepEqual(ours(), ['b'], 'the removed layer is the one gone');
b.remove();
deepEqual(ours(), [], 'both removed');
ok(['globe', 'tiles', 'both'].includes(r.globe.imagery.host()), 'host');
},
),
check(
'globe.imagery.frames',
'timed frames follow scene time, or setFrameTime on their own clock',
async (h, r) => {
await overAustin(h, r, 2000000);
const t0 = r.frame.time();
const frames = [0, 60000, 120000].map((dt, i) => ({
time: t0 + dt,
url: PIXEL_PNGS[i],
}));
const ready = [];
// Frames decode in real time: wait on the clock, drawing meanwhile.
const waitFor = async (time, ms = 5000) => {
const until = Date.now() + ms;
while (!ready.includes(time) && Date.now() < until) {
await drawn(h, r, 1);
await new Promise((done) => setTimeout(done, 10));
}
return ready.includes(time);
};
const settle = () => waitFor(Symbol('nothing'), 300);
const scene = r.globe.imagery.add({
kind: 'single-image',
url: PIXEL_PNG,
});
scene.on('frameReady', (time) => ready.push(time));
scene.setFrames({ kind: 'frames', frames });
r.frame.setTime(t0 + 50000);
ok(await waitFor(t0 + 60000), 'the frame nearest scene time is ready');
await settle();
ready.length = 0;
r.frame.setTime(t0 + 55000);
scene.setFrameTime(t0 + 130000);
await settle();
deepEqual(
ready,
[],
'the same frame is not ready twice, and setFrameTime does nothing on the scene clock',
);
scene.remove();
ready.length = 0;
const own = r.globe.imagery.add({
kind: 'single-image',
url: PIXEL_PNG,
});
own.on('frameReady', (time) => ready.push(time));
own.setFrames({ kind: 'frames', frames }, { clock: 'own' });
own.setFrameTime(t0 + 130000);
ok(await waitFor(t0 + 120000), 'setFrameTime on its own clock');
await settle();
ready.length = 0;
r.frame.setTime(t0 + 5000);
await settle();
deepEqual(ready, [], 'scene time does not move its own clock');
own.remove();
ready.length = 0;
const paused = r.globe.imagery.add({
kind: 'single-image',
url: PIXEL_PNG,
});
paused.on('frameReady', (time) => ready.push(time));
r.frame.suspend(true);
paused.setFrames({ kind: 'frames', frames });
r.frame.setTime(t0 + 1000);
r.frame.setTime(t0 + 61000);
await settle();
deepEqual(ready, [], 'nothing is ready while the loop is suspended');
r.frame.suspend(false);
ok(
await waitFor(t0 + 60000),
'the frame shown is ready once it is drawn',
);
equal(ready[0], t0 + 60000, 'and frames never drawn are not announced');
paused.remove();
ready.length = 0;
const first = r.globe.imagery.add({
kind: 'single-image',
url: PIXEL_PNG,
});
const second = r.globe.imagery.add({
kind: 'single-image',
url: PIXEL_PNG,
});
first.on('frameReady', () => {
ready.push('first');
second.remove();
});
second.on('frameReady', () => ready.push('second'));
first.setFrames({ kind: 'frames', frames });
second.setFrames({ kind: 'frames', frames });
r.frame.setTime(t0 + 120000);
await waitFor('first');
await settle();
ok(
!ready.includes('second'),
'a layer removed while another announces says nothing',
);
first.remove();
},
{ capability: 'imageryFrames' },
),
check(
'globe.withoutImageHooks',
'without imageHooks, a spec with main-thread tile hooks throws',
async (h) => {
const r = await h.create({
viewport: SCRIPT_VIEWPORT,
capabilities: CAPABILITIES.filter((c) => c !== 'imageHooks'),
});
if (!r) return { skip: 'this harness cannot drop capabilities' };
try {
const url = 'https://example.invalid/{z}/{x}/{y}.png';
throws(
() =>
r.globe.imagery.add({
kind: 'xyz',
url,
tileTransform: (t, i) => i,
}),
'tileTransform',
);
throws(
() =>
r.globe.imagery.add({
kind: 'xyz',
url,
admit: (t, issue) => issue(),
}),
'admit',
);
throws(
() => r.globe.imagery.add({ kind: 'canvas', drawTile: () => {} }),
'canvas imagery',
);
const plain = r.globe.imagery.add({ kind: 'xyz', url });
ok(plain, 'a plain layer is added');
plain.remove();
} finally {
r.destroy();
}
},
),
check(
+6
View File
@@ -160,6 +160,12 @@ export type Capability =
| 'imageryFrames'
/** Wind-relief and similar shading of the globe surface. */
| 'surfaceShading'
/** Volumetric clouds (`globe.setClouds`). */
| 'clouds'
/** A water surface on the oceans (`globe.setWater`). */
| 'water'
/** Main-thread tile hooks: `tileTransform`, `admit` and `canvas` imagery. */
| 'imageHooks'
/** A screen-space sector modulation pass (a sweeping sonar band). */
| 'screenModulation'
/** GPU frame timing through timer queries. */
+95 -18
View File
@@ -10,13 +10,16 @@ export interface PhotorealSpec {
key?: string;
/** Renews a session token; called again when the token expires. */
token?: () => Promise<string>;
/** Fall back to an ion-hosted copy when Google refuses. */
ionFallback?: boolean;
/**
* Fall back to the renderer's copy of the same tiles when Google refuses
* (for the default renderer, one hosted on Cesium ion).
*/
fallback?: boolean;
}
export interface PhotorealStatus {
/** Which route is drawing, or `'none'` when photoreal is off or failed. */
route: 'google' | 'ion' | 'none';
route: 'google' | 'fallback' | 'none';
error?: { code: string; message: string };
}
@@ -29,7 +32,11 @@ export interface PhotorealStatus {
export type TerrainSpec = (
| { kind: 'ellipsoid' }
| { kind: 'quantizedMesh'; url: string; requestVertexNormals?: boolean }
| { kind: 'ion'; assetId: number }
/**
* The renderer's global terrain, through `CreateOptions.worldDataToken`
* (for the default renderer, Cesium World Terrain).
*/
| { kind: 'world'; requestVertexNormals?: boolean }
/** A DEM image pyramid (`{z}/{x}/{y}`), heights encoded in RGB. */
| {
kind: 'heightmap';
@@ -53,11 +60,27 @@ export interface TileRequest {
level: number;
}
/**
* What a tile fetch answered: the bytes to decode, `missing` (no tile
* there; draw the parent's), or `defer` (ask again on a later frame).
*/
export type TileFetch =
| { bytes: ArrayBuffer; contentType?: string }
| { missing: true }
| { defer: true };
export interface ImagerySpec {
kind: 'xyz' | 'wmts' | 'arcgis' | 'bing' | 'single-image' | 'canvas';
/**
* `world`: the renderer's global imagery through
* `CreateOptions.worldDataToken` (for the default renderer, Cesium ion's
* world imagery), in `worldStyle`. `canvas`: `imageHooks`.
*/
kind: 'xyz' | 'wmts' | 'arcgis' | 'world' | 'single-image' | 'canvas';
/** `world`: aerial (the default), or aerial with labels. */
worldStyle?: 'aerial' | 'aerialLabels';
/** URL or URL template (`{x}`, `{y}`, `{z}`, `{s}`, `{time}`). */
url?: string;
/** `'canvas'`: draws one tile. */
/** `'canvas'` (`imageHooks`): draws one tile. */
drawTile?: (tile: TileRequest, canvas: HTMLCanvasElement) => void;
/** WMTS: layer, style, tile matrix set and format. */
wmts?: {
@@ -77,14 +100,26 @@ export interface ImagerySpec {
maxConcurrentRequests?: number;
/** HTTP statuses retried rather than treated as missing. */
retryStatuses?: number[];
/** Changes a tile's image before it is drawn. */
/**
* Fetches a tile in place of the renderer, with the URL it would fetch.
* Every renderer calls it, before its own decoding (in a worker, say).
*/
fetchTile?: (request: {
tile: TileRequest;
url: string;
signal: AbortSignal;
}) => Promise<TileFetch>;
/** `imageHooks`: changes a tile's decoded image before it is drawn. */
tileTransform?: (
tile: TileRequest,
image: ImageBitmap,
) => ImageBitmap | Promise<ImageBitmap>;
/**
* Decides each tile request: call `issue()` to fetch it, or return
* `undefined` to defer it and be asked again on a later frame.
* `imageHooks`: decides each tile request on the main thread: call
* `issue()` to fetch it, or return `undefined` to defer it and be asked
* again on a later frame. `fetchTile`'s `defer` does the same anywhere.
* With both, `admit` decides first and an issued request goes through
* `fetchTile`; each defers on its own.
*/
admit?: (
tile: TileRequest,
@@ -100,20 +135,56 @@ export interface TimedUrl {
url: string;
}
/**
* A timed source: frames as URLs (one template per time), or video tiles
* in segments, each a template whose tiles are videos spanning `start` to
* `end` (epoch ms), a time within shown by its offset into the segment's
* videos. Outside every segment, the nearest segment end is shown.
*/
export type TimedSource =
| { kind: 'frames'; frames: TimedUrl[] }
| {
kind: 'video';
segments: Array<{ url: string; start: number; end: number }>;
};
export interface ImageryLayer {
alpha: number;
show: boolean;
/** Which side of the split this layer shows on. */
split: 'none' | 'left' | 'right';
/** What it drapes onto. `'active'` follows whichever base is showing. */
drapeOn: 'globe' | 'tiles' | 'both' | 'active';
/** `imageryFrames`: a timed sequence, prefetched hidden. */
/**
* What it drapes onto: the globe's surface (terrain or the ellipsoid),
* objects (3D tiles and models; `imageryOnTiles`), or all of them. The
* default `all` drapes on whichever is drawn, so under photoreal it is
* on the tiles. Without `imageryOnTiles`, `objects` drapes on nothing and
* `all` on the surface. So nothing drapes while the globe is hidden and
* either photoreal's route is `none` or there is no `imageryOnTiles`:
* Core reads that from what it set, with no event for it.
*/
drapeOn: 'surface' | 'objects' | 'all';
/** Shaded by the sun like the surface (the default), or drawn unlit. */
lit: boolean;
/**
* `imageryFrames`: a timed source, prefetched hidden. `clock: 'scene'`
* (the default) shows the frame for the frame port's scene time;
* `'own'` shows the one `setFrameTime` names.
*/
setFrames?(
frames: TimedUrl[],
options?: { mode?: 'nearest' | 'blend'; prefetch?: number },
source: TimedSource,
options?: {
mode?: 'nearest' | 'blend';
prefetch?: number;
clock?: 'scene' | 'own';
},
): void;
/** `imageryFrames`: which time to show. */
/** `imageryFrames`: which time to show, on the `own` clock. */
setFrameTime?(ms: number): void;
/**
* `imageryFrames`: the frame the layer's clock now shows (by its time)
* has been drawn. Fires once each time the shown frame changes, never
* for frames only prefetched.
*/
on(event: 'frameReady', cb: (time: number) => void): Off;
on(
event: 'tileError',
@@ -189,12 +260,18 @@ export interface GlobePort {
setGlobeVisible(visible: boolean): void;
/** `surfaceShading`. */
setSurfaceShading?(shading: SurfaceShading | null): void;
/** `clouds`: the renderer's volumetric clouds, or none. */
setClouds?(clouds: { coverage: number } | null): void;
/** `water`: the renderer's own water surface on the oceans, on or off. */
setWater?(enabled: boolean): void;
readonly imagery: {
/**
* Without `imageHooks`, a spec with `tileTransform`, `admit` or kind
* `canvas` throws, rather than drawing tiles the hooks were meant to
* change.
*/
add(spec: ImagerySpec, index?: number): ImageryLayer;
list(): ImageryLayer[];
/** What imagery drapes onto now. */
host(): 'globe' | 'tiles' | 'both';
onHostChange(cb: (host: 'globe' | 'tiles' | 'both') => void): Off;
/** `splitSwipe`: split position, a fraction of the canvas's CSS width. */
getSplit?(): number;
/** `splitSwipe`: move the split to `x01` of the canvas's CSS width. */
+6 -2
View File
@@ -23,6 +23,9 @@ export const CAPABILITIES = Object.freeze([
'splitSwipe',
'imageryFrames',
'surfaceShading',
'clouds',
'water',
'imageHooks',
'screenModulation',
'gpuTiming',
'pickPosition',
@@ -138,8 +141,6 @@ export const PORTS = deepFreeze({
'imagery',
'imagery.add',
'imagery.list',
'imagery.host',
'imagery.onHostChange',
'sky',
'sky.get',
'sky.set',
@@ -147,6 +148,8 @@ export const PORTS = deepFreeze({
'on',
),
['setSurfaceShading', 'surfaceShading'],
['setClouds', 'clouds'],
['setWater', 'water'],
['imagery.getSplit', 'splitSwipe'],
['imagery.setSplit', 'splitSwipe'],
]),
@@ -293,6 +296,7 @@ export const PORTS = deepFreeze({
'show',
'split',
'drapeOn',
'lit',
'on',
'raise',
'lower',
+6 -2
View File
@@ -34,8 +34,12 @@ export interface CreateOptions {
googleApiKey?: string;
/** Short-lived Google session tokens, when the key is held server-side. */
googleTokens?: () => Promise<string>;
/** Cesium ion token, for ion-hosted terrain and fallback tiles. */
ionToken?: string;
/**
* The token for the renderer's world data: `world` terrain and imagery
* and the photoreal `fallback`. For the default renderer, a Cesium ion
* token.
*/
worldDataToken?: string;
/** Initial multisampling; quality can change it later. */
msaa?: 0 | 2 | 4 | 8;
/** Keep the drawing buffer after presenting, for capture. */
+85 -28
View File
@@ -201,6 +201,9 @@ export function createStubRenderer(options = {}) {
runPhase('preRender', t);
step('render');
rendered += 1;
// A frame each timed layer was asked to show is now drawn.
for (const [layer, flush] of [...frameFlushes])
if (frameFlushes.get(layer) === flush) flush();
step('postRender');
runPhase('postRender', t);
for (const waiter of onceWaiters.splice(0)) waiter();
@@ -277,6 +280,7 @@ export function createStubRenderer(options = {}) {
},
setTime(ms) {
sceneTime = ms;
for (const show of [...sceneFrameLayers.values()]) show(ms);
requestRender('time');
},
time: () => sceneTime,
@@ -916,8 +920,10 @@ export function createStubRenderer(options = {}) {
const imageryLayers = [];
let split = 0.5;
let imageryHost = 'globe';
const hostListeners = [];
/** Layers whose frames follow scene time, each with its show-a-time function. */
const sceneFrameLayers = new Map();
/** Each timed layer's announcement of the frame it drew, run after a render. */
const frameFlushes = new Map();
const progressListeners = [];
const settled = { base: true, tiles: true, terrain: true, imagery: true };
let sky = {
@@ -933,13 +939,21 @@ export function createStubRenderer(options = {}) {
let photorealStatus = { route: 'none' };
function addImagery(spec, index) {
if (
!capabilities.has('imageHooks') &&
(spec.tileTransform || spec.admit || spec.kind === 'canvas')
)
throw new TypeError(
'tileTransform, admit and canvas imagery need imageHooks',
);
const listeners = { frameReady: [], tileError: [] };
const layer = {
spec: { ...spec },
alpha: 1,
show: true,
split: 'none',
drapeOn: 'active',
drapeOn: 'all',
lit: true,
on(event, cb) {
listeners[event]?.push(cb);
return () => {
@@ -967,23 +981,67 @@ export function createStubRenderer(options = {}) {
remove() {
const i = imageryLayers.indexOf(layer);
if (i >= 0) imageryLayers.splice(i, 1);
sceneFrameLayers.delete(layer);
frameFlushes.delete(layer);
requestRender('imagery');
},
};
if (capabilities.has('imageryFrames')) {
let frames = [];
layer.setFrames = (list) => {
frames = list.slice();
let source = null;
let clockKind = 'scene';
/** The time the source shows for `ms`: the nearest frame, or within the videos. */
const shownTime = (ms) => {
if (source?.kind === 'video') {
let best = null;
let bestOff = Infinity;
for (const seg of source.segments) {
const t = Math.min(seg.end, Math.max(seg.start, ms));
if (Math.abs(t - ms) < bestOff) {
best = t;
bestOff = Math.abs(t - ms);
}
}
return best;
}
const frames = source?.frames ?? [];
let best = null;
for (const f of frames)
if (!best || Math.abs(f.time - ms) < Math.abs(best.time - ms))
best = f;
return best ? best.time : null;
};
// The stub has every frame at once, so the frame asked for is drawn
// by the next render; it is said once per change of the drawn frame.
let wanted = null;
let drawnTime = null;
const show = (ms) => {
wanted = shownTime(ms);
requestRender('imagery');
};
frameFlushes.set(layer, () => {
if (wanted === null || wanted === drawnTime) return;
drawnTime = wanted;
deliver(listeners.frameReady, drawnTime);
});
layer.setFrames = (next, opts = {}) => {
source =
next.kind === 'video'
? { kind: 'video', segments: next.segments.map((s) => ({ ...s })) }
: { kind: 'frames', frames: next.frames.slice() };
wanted = null;
drawnTime = null;
clockKind = opts.clock ?? 'scene';
if (clockKind === 'scene') {
sceneFrameLayers.set(layer, show);
show(sceneTime);
} else sceneFrameLayers.delete(layer);
requestRender('imagery');
};
layer.setFrameTime = (ms) => {
const nearest = frames.reduce(
(best, f) =>
!best || Math.abs(f.time - ms) < Math.abs(best.time - ms)
? f
: best,
null,
);
if (nearest) deliver(listeners.frameReady, nearest.time);
// On the scene clock the frame port's time decides.
if (clockKind !== 'own') return;
show(ms);
requestRender('imagery');
};
}
if (index === undefined) imageryLayers.push(layer);
@@ -996,7 +1054,7 @@ export function createStubRenderer(options = {}) {
const globe = {
async setBase(base) {
log('globe', 'setBase', [base]);
for (const layer of imageryLayers.splice(0)) void layer;
for (const layer of imageryLayers.slice()) layer.remove();
if (base.photoreal !== undefined)
await globe.setPhotoreal(base.photoreal);
const layers = (base.imagery ?? []).map((spec) => addImagery(spec));
@@ -1008,11 +1066,6 @@ export function createStubRenderer(options = {}) {
spec && capabilities.has('photoreal')
? { route: 'google' }
: { route: 'none' };
const next = photorealStatus.route === 'none' ? 'globe' : 'tiles';
if (next !== imageryHost) {
imageryHost = next;
deliver(hostListeners, next);
}
requestRender('base');
return photorealStatus;
},
@@ -1027,14 +1080,6 @@ export function createStubRenderer(options = {}) {
imagery: {
add: addImagery,
list: () => imageryLayers.slice(),
host: () => imageryHost,
onHostChange(cb) {
hostListeners.push(cb);
return () => {
const i = hostListeners.indexOf(cb);
if (i >= 0) hostListeners.splice(i, 1);
};
},
},
sky: {
get: () => ({ ...sky }),
@@ -1058,6 +1103,16 @@ export function createStubRenderer(options = {}) {
log('globe', 'setSurfaceShading', [shading]);
requestRender('base');
};
if (capabilities.has('clouds'))
globe.setClouds = (clouds) => {
log('globe', 'setClouds', [clouds]);
requestRender('base');
};
if (capabilities.has('water'))
globe.setWater = (water) => {
log('globe', 'setWater', [water]);
requestRender('base');
};
if (capabilities.has('splitSwipe')) {
globe.imagery.getSplit = () => split;
globe.imagery.setSplit = (x01) => {
@@ -1328,6 +1383,8 @@ export function createStubRenderer(options = {}) {
onceTimers.clear();
sets.clear();
satrecs.clear();
sceneFrameLayers.clear();
frameFlushes.clear();
},
testing: {
/** Run one frame of the loop; true when it drew. */