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