diff --git a/docs/RENDERER-CONTRACT.md b/docs/RENDERER-CONTRACT.md index 566dabeac..fdc62f78c 100644 --- a/docs/RENDERER-CONTRACT.md +++ b/docs/RENDERER-CONTRACT.md @@ -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. diff --git a/src/render/conformance/checks/ports.js b/src/render/conformance/checks/ports.js index 122dcd918..ba4338975 100644 --- a/src/render/conformance/checks/ports.js +++ b/src/render/conformance/checks/ports.js @@ -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( diff --git a/src/render/contract/common.d.ts b/src/render/contract/common.d.ts index 1390d6dfd..7ab669e63 100644 --- a/src/render/contract/common.d.ts +++ b/src/render/contract/common.d.ts @@ -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. */ diff --git a/src/render/contract/globe.d.ts b/src/render/contract/globe.d.ts index 0b35ebfb1..02c072747 100644 --- a/src/render/contract/globe.d.ts +++ b/src/render/contract/globe.d.ts @@ -10,13 +10,16 @@ export interface PhotorealSpec { key?: string; /** Renews a session token; called again when the token expires. */ token?: () => Promise; - /** 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; + /** `imageHooks`: changes a tile's decoded image before it is drawn. */ tileTransform?: ( tile: TileRequest, image: ImageBitmap, ) => ImageBitmap | Promise; /** - * 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. */ diff --git a/src/render/contract/index.js b/src/render/contract/index.js index dac76b864..ed5407bc1 100644 --- a/src/render/contract/index.js +++ b/src/render/contract/index.js @@ -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', diff --git a/src/render/contract/renderer.d.ts b/src/render/contract/renderer.d.ts index dc4a7a4d4..7be48054b 100644 --- a/src/render/contract/renderer.d.ts +++ b/src/render/contract/renderer.d.ts @@ -34,8 +34,12 @@ export interface CreateOptions { googleApiKey?: string; /** Short-lived Google session tokens, when the key is held server-side. */ googleTokens?: () => Promise; - /** 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. */ diff --git a/src/render/stub/index.js b/src/render/stub/index.js index 7fc55a68d..ec77d45ad 100644 --- a/src/render/stub/index.js +++ b/src/render/stub/index.js @@ -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. */