From 92fd2b0c2e213d8a07b96aee093ba3db9e1b21a0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:04:02 -0800 Subject: [PATCH] Run the conformance logic suite on the Cesium renderer The conformance page can now run the logic checks against the Cesium scene renderer as well as the stub. CESIUM_CHECKS lists the checks of the ports Cesium already has; each phase 2 migration adds its own, so the suite shows how far the renderer is through the contract. All eight listed checks pass in the browser, and the stub still passes its 69. A harness frame is one tick of the render loop, observed through frame.on('preUpdate'). It asks for no frame itself, so a check's own requestRender is what makes a tick draw, and a loop that stops fails the check after five seconds instead of hanging the run. The harness frees each WebGL context on destroy. FrameTime.dt is now measured rather than fixed at 1/60, and renderOnce honours timeoutMs. A scene renderer made without the models capability has no draw.models. --- dev/conformance/index.html | 1 + dev/conformance/main.js | 23 +++++-- src/render/cesium/appRenderer.test.mjs | 9 +++ src/render/cesium/harness.js | 93 ++++++++++++++++++++++++++ src/render/cesium/ports.js | 36 +++++++--- src/render/cesium/sceneRenderer.js | 24 +++++-- 6 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 src/render/cesium/harness.js 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,