diff --git a/dev/conformance/index.html b/dev/conformance/index.html index 1cd9bb7c5..16d48a537 100644 --- a/dev/conformance/index.html +++ b/dev/conformance/index.html @@ -99,6 +99,7 @@ + diff --git a/dev/conformance/main.js b/dev/conformance/main.js index 8b812a471..7189d3a86 100644 --- a/dev/conformance/main.js +++ b/dev/conformance/main.js @@ -20,6 +20,10 @@ import { import { createStubHarness } from '../../src/render/stub/harness.js'; import { toEcef } from '../../src/render/stub/math.js'; import { createCesiumSceneRenderer } from '../../src/render/cesium/sceneRenderer.js'; +import { + CESIUM_CHECKS, + createCesiumHarness, +} from '../../src/render/cesium/harness.js'; const REFERENCES = '/src/render/conformance/goldens/visual/'; const SAVE = '/__conformance/references/'; @@ -31,9 +35,19 @@ const RENDERERS = { cesium: () => createCesiumSceneRenderer({ container: $('stage') }), }; -async function runLogic() { - status('running the logic suite…'); - const report = await runConformance(createStubHarness('stub (browser)')); +const HARNESSES = { + stub: () => [createStubHarness('stub (browser)'), {}], + cesium: () => [ + createCesiumHarness({ model: ctx.assets.model }), + { only: (id) => CESIUM_CHECKS.has(id) }, + ], +}; + +/** Runs the logic suite on `which`; `all` runs every check, listed or not. */ +async function runLogic(which = 'stub', { all = false } = {}) { + status(`running the logic suite on ${which}…`); + const [harness, options] = HARNESSES[which](); + const report = await runConformance(harness, all ? {} : options); $('logicResults').innerHTML = `

${report.harness}: ${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped

${report.results @@ -209,7 +223,8 @@ async function runScenes({ record = false, only } = {}) { return results; } -$('logic').onclick = () => runLogic(); +$('logic').onclick = () => runLogic('stub'); +$('logicCesium').onclick = () => runLogic('cesium'); $('check').onclick = () => runScenes(); $('record').onclick = () => runScenes({ record: true }); window.conformance = { runLogic, runScenes }; diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index b3d37aecb..fdda47b35 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -145,6 +145,15 @@ test('work pending at destroy settles and lets go of the scene', async (t) => { assert.equal(viewers[0].listeners.size, 0, 'no one-shot render stays'); await renderer.frame.renderOnce(); + const waiting = await plugin.create({ container: {} }); + await assert.rejects( + waiting.frame.renderOnce({ timeoutMs: 5 }), + /timed out/, + 'a frame that never comes times out when asked to', + ); + assert.equal(viewers[1].listeners.size, 4, 'and lets go of its listener'); + waiting.destroy(); + // A model that finishes loading after destroy is destroyed, not added. const load = Promise.withResolvers(); const saved = Cesium.Model.fromGltfAsync; diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js new file mode 100644 index 000000000..0036ca0e8 --- /dev/null +++ b/src/render/cesium/harness.js @@ -0,0 +1,93 @@ +/** + * @module render/cesium/harness + * @description The Cesium renderer as the conformance suite's renderer under + * test, in the `/dev/conformance/` page. + * + * `CESIUM_CHECKS` lists the checks of the ports the Cesium renderer has. + * Each migration adds the checks of the members it brings, so the suite + * shows how far the renderer is through the contract; every other check is + * left out of the run rather than failed. + */ +import { DEFAULT_TOLERANCE } from '../conformance/index.js'; +import { toEcef } from '../stub/math.js'; +import { createCesiumSceneRenderer } from './sceneRenderer.js'; + +/** @type {ReadonlySet} */ +export const CESIUM_CHECKS = new Set([ + 'draw.ids', + 'draw.models', + 'draw.sets', + 'frame.order', + 'frame.renderOnce', + 'frame.time', + 'globe.sky', + 'renderer.whenSettled', +]); + +/** A tick fails the check rather than hang it when the loop has stopped. */ +const TICK_TIMEOUT_MS = 5000; + +/** + * One tick of the render loop, through the contract: it resolves once the + * next tick (drawn or not) has run, so loads and timers progress between + * ticks. It asks for no frame itself, so a check's own `requestRender` is + * what makes a tick draw. + */ +function nextTick(r) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + off(); + reject(new Error('the render loop did not tick')); + }, TICK_TIMEOUT_MS); + const off = r.frame.on('preUpdate', () => { + off(); + clearTimeout(timer); + // The rest of the tick (render, postRender) runs in the same task. + setTimeout(resolve, 0); + }); + }); +} + +/** + * @param {{name?: string, model?: string}} [options] `model`: a glTF URL + * for the model checks. + * @returns {import('../conformance/index.js').ConformanceHarness} + */ +export function createCesiumHarness({ name = 'cesium', model } = {}) { + return { + name, + async create({ viewport, capabilities }) { + const container = document.createElement('div'); + container.style.cssText = `position:fixed;left:0;top:0;width:${viewport.width}px;height:${viewport.height}px`; + document.body.append(container); + let renderer; + try { + renderer = await createCesiumSceneRenderer({ container, capabilities }); + } catch (error) { + container.remove(); + throw error; + } + const destroy = renderer.destroy; + const { canvas } = renderer; + renderer.destroy = () => { + try { + destroy(); + } finally { + // Free the context now: a full run makes more widgets than the + // browser keeps contexts for. + (canvas.getContext('webgl2') ?? canvas.getContext('webgl')) + ?.getExtension('WEBGL_lose_context') + ?.loseContext(); + container.remove(); + } + }; + return renderer; + }, + frame: nextTick, + now: () => performance.now(), + toEcef, + tolerance: DEFAULT_TOLERANCE, + flightPaths: 'endpoints', + ...(model ? { assets: { model } } : {}), + }; +} diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index 4ed2403ee..d28410613 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -54,6 +54,16 @@ export function createCesiumPorts(widget) { const frameListeners = { preUpdate: [], preRender: [], postRender: [] }; let frames = 0; + // Every tick raises preUpdate, drawn or not; the phases of a tick share + // its clock reading. The first tick counts from when the loop was made. + let tickNow = performance.now(); + let dt = 0; + const tick = () => { + const now = performance.now(); + dt = (now - tickNow) / 1000; + tickNow = now; + frames += 1; + }; const onPhase = (phase) => (_scene, time) => { if (frameListeners[phase].length === 0) return; const t = { @@ -61,15 +71,15 @@ export function createCesiumPorts(widget) { time: Cesium.JulianDate.toDate( time ?? widget.clock.currentTime, ).getTime(), - now: performance.now(), - dt: 1 / 60, + now: tickNow, + dt, rendering: phase !== 'preUpdate', }; for (const cb of frameListeners[phase].slice()) cb(t); }; let disposed = false; const removers = [ - scene.preUpdate.addEventListener(() => (frames += 1)), + scene.preUpdate.addEventListener(tick), scene.preUpdate.addEventListener(onPhase('preUpdate')), scene.preRender.addEventListener(onPhase('preRender')), scene.postRender.addEventListener(onPhase('postRender')), @@ -78,16 +88,26 @@ export function createCesiumPorts(widget) { // Pending one-shot renders; disposal settles them rather than leave them // attached to a scene that will not render again. const pendingRenders = new Set(); - function nextPostRender() { + /** Resolves after the next drawn frame; rejects after `timeoutMs`, if given. */ + function nextPostRender({ timeoutMs } = {}) { if (disposed) return Promise.resolve(); - return new Promise((resolve) => { - const done = () => { + return new Promise((resolve, reject) => { + let timer; + const settle = (error) => { off(); + clearTimeout(timer); pendingRenders.delete(done); - resolve(); + if (error) reject(error); + else resolve(); }; + const done = () => settle(); const off = scene.postRender.addEventListener(done); pendingRenders.add(done); + if (timeoutMs !== undefined) + timer = setTimeout( + () => settle(new Error('renderOnce timed out')), + timeoutMs, + ); scene.requestRender(); }); } @@ -102,7 +122,7 @@ export function createCesiumPorts(widget) { }, requestRender: () => scene.requestRender(), renderNow: () => scene.render(widget.clock.currentTime), - renderOnce: () => nextPostRender(), + renderOnce: (options) => nextPostRender(options), time: () => Cesium.JulianDate.toDate(widget.clock.currentTime).getTime(), }; diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index 69b956326..02d7774df 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -29,11 +29,19 @@ const SKY_DEFAULTS = Object.freeze({ lighting: false, }); +/** What this renderer can do; a caller may ask for fewer. */ +const CAPABILITIES = ['models']; + /** - * @param {{container: HTMLElement, msaa?: number}} options + * @param {{container: HTMLElement, msaa?: number, capabilities?: Iterable}} options + * `capabilities` limits the renderer to those it lists. * @returns {Promise} A renderer with the members listed above. */ -export async function createCesiumSceneRenderer({ container, msaa = 4 }) { +export async function createCesiumSceneRenderer({ + container, + msaa = 4, + capabilities: only, +}) { const credits = document.createElement('div'); const widget = new Cesium.CesiumWidget(container, { baseLayer: false, @@ -60,7 +68,10 @@ export async function createCesiumSceneRenderer({ container, msaa = 4 }) { if (scene.moon) scene.moon.show = false; const sky = { ...SKY_DEFAULTS }; - const capabilities = new Set(['models']); + const allowed = only && new Set(only); + const capabilities = new Set( + CAPABILITIES.filter((c) => !allowed || allowed.has(c)), + ); function applySky() { scene.globe.enableLighting = sky.lighting; @@ -134,7 +145,12 @@ export async function createCesiumSceneRenderer({ container, msaa = 4 }) { canvas: scene.canvas, frame, globe, - draw: ports.draw, + // A member of a capability left out is absent, not stubbed. + draw: capabilities.has('models') + ? ports.draw + : Object.fromEntries( + Object.entries(ports.draw).filter(([kind]) => kind !== 'models'), + ), camera: ports.camera, query: ports.query, capture: ports.capture,