From c6bab5dcaa79d1fdcefa8cc4cc38fb64e555e1b4 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 17:17:17 -0800 Subject: [PATCH] Give the Cesium renderer a quality port The Cesium quality port supports pixel ratio, multisampling, shadows, 3D tile and terrain detail and the target frame rate, each applied when first set and nothing changed before. Detail scales each tileset's and the globe's own screen-space error and gives it back at full detail; while tile detail is lowered, tilesets added later follow it. Frame signals carry each drawn tick's CPU time, size, loop mode and holds. The application and conformance renderers have the port, and the conformance page runs the quality checks against Cesium. --- scripts/package-boundaries.json | 1 + src/render/cesium/appRenderer.js | 4 +- src/render/cesium/appRenderer.test.mjs | 1 - src/render/cesium/harness.js | 2 + src/render/cesium/ports.js | 13 +- src/render/cesium/quality.js | 377 +++++++++++++++++++++++++ src/render/cesium/quality.test.mjs | 328 +++++++++++++++++++++ src/render/cesium/sceneRenderer.js | 1 + 8 files changed, 723 insertions(+), 4 deletions(-) create mode 100644 src/render/cesium/quality.js create mode 100644 src/render/cesium/quality.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9a097de93..b13050fda 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2760,6 +2760,7 @@ "src/render/creditMarkup.js", "src/render/rendererOf.js", "src/render/cesium/ports.js", + "src/render/cesium/quality.js", "src/render/cesium/credits.js", "src/render/cesium/effects.js", "src/render/cesium/common.js", diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index 193bb7282..2e87091b1 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -27,7 +27,8 @@ import { cesiumCapabilities, createCesiumPorts } from './ports.js'; * The members a transitional renderer has today. * @typedef {Pick} * TransitionalRenderer */ @@ -115,6 +116,7 @@ export function createCesiumRendererPlugin({ query: ports.query, input: input.port, effects: ports.effects, + quality: ports.quality, capture: ports.capture, credits: ports.credits, on: ports.on, diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index 26524cd63..a759145c5 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -338,7 +338,6 @@ const EXPECTED_MISSING = [ 'draw.on', 'frame.setTime', 'renderer.globe', - 'renderer.quality', 'renderer.readPixels', 'renderer.releaseUnused', 'renderer.whenSettled', diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index c1acf00eb..2c50b1b5d 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -57,6 +57,8 @@ export const CESIUM_CHECKS = new Set([ 'query.projectMany', 'query.rays', 'query.withoutCapability', + 'quality', + 'quality.signals', 'renderer.listeners', 'renderer.whenSettled', ]); diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index ac64349a5..45b2ccc0a 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -2,8 +2,8 @@ * @module render/cesium/ports * @description The Cesium renderer's contract ports, built over a Cesium * widget or viewer someone else made and configured: frame phases, the - * draw sets, `setView` and the pose, picks and projection, credits and - * capture. + * draw sets, `setView` and the pose, picks and projection, credits, + * quality and capture. * Building them puts the loop on demand (no holds yet, and no redraws for * scene time alone); nothing else in the scene changes until a call. * @@ -18,6 +18,7 @@ import { createCesiumCamera } from './camera.js'; import { listenerList, toCartesian, uncaught } from './common.js'; import { createCesiumCredits } from './credits.js'; import { createCesiumEffects } from './effects.js'; +import { createCesiumQuality } from './quality.js'; import { atEndOfTick, billboardLoadError, @@ -743,6 +744,12 @@ export function createCesiumPorts(widget, { capabilities }) { report, }); + const qualityPart = createCesiumQuality(widget, { + frame, + report, + tickStarted: () => tickNow, + }); + const creditsPart = createCesiumCredits({ display: widget.creditDisplay, scene, @@ -1094,6 +1101,7 @@ export function createCesiumPorts(widget, { capabilities }) { credits: creditsPart.port, query, effects: effectsPart.port, + quality: qualityPart.port, capture, modelSets, iconState, @@ -1111,6 +1119,7 @@ export function createCesiumPorts(widget, { capabilities }) { cameraPart.dispose(); creditsPart.dispose(); effectsPart.dispose(); + qualityPart.dispose(); for (const done of [...pendingRenders]) done(); // Late model loads find no host and destroy themselves. for (const record of sets.values()) record.host = null; diff --git a/src/render/cesium/quality.js b/src/render/cesium/quality.js new file mode 100644 index 000000000..c3674c8b2 --- /dev/null +++ b/src/render/cesium/quality.js @@ -0,0 +1,377 @@ +/** + * @module render/cesium/quality + * @description The Cesium renderer's quality port + * (`src/render/contract/quality.d.ts`) over a widget or viewer someone else + * made and configured. It supports the settings Cesium has a knob for: + * + * - `pixelRatio`: the widget's `resolutionScale` at the browser's + * recommended resolution, which Cesium takes as one pixel per CSS pixel, + * so 0 (browser recommended) is a scale of 1. + * - `msaa`: the scene's `msaaSamples` (0 is Cesium's 1, off), listed only + * when the context can multisample. + * - `shadows`: the scene's shadow map, on or off. + * - `tiles3dDetail` and `terrainDetail`: each 3D tileset's and the globe's + * `maximumScreenSpaceError`, as their own value at 1 and up to 8 times it + * at 0. While tile detail is lowered, a tileset added later is brought + * to it on the next drawn frame; an error is written only when it + * changes, since each write resets the tileset's memory-adjusted error. + * - `targetFps`: the frame port's target frame rate, 0 for none. + * + * Building the port changes nothing in the scene; each setting is applied + * when it is first set, and a detail set back to 1 hands each tileset and + * the globe the error it had (or was given since). There is no GPU timing: + * frame signals carry the CPU time of each drawn tick, from the tick's start + * (before any frame listener) to the end of its draw. + */ +import * as Cesium from 'cesium'; +import { listenerList } from './common.js'; + +const MSAA_OPTIONS = Object.freeze([0, 2, 4, 8]); +/** Screen-space error at detail 0, as a multiple of the error at detail 1. */ +const COARSEST = 8; +const MAX_PIXEL_RATIO = 4; +const MAX_FPS = 240; + +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); +/** `value` as a number in range, or null when it is not a number. */ +const inRange = (value, min, max) => { + const n = Number(value); + return Number.isFinite(n) ? clamp(n, min, max) : null; +}; + +/** The screen-space error multiplier for a detail, 0..1. */ +export const errorFactor = (detail) => COARSEST ** (1 - clamp(detail, 0, 1)); + +/** + * The 3D tilesets in a primitive collection, nested collections included. + * @param {object} collection + * @param {(p: object) => boolean} isTileset + * @param {(p: object) => boolean} isCollection + * @param {object[]} [out] + */ +function tilesetsIn(collection, isTileset, isCollection, out = []) { + for (let i = 0; i < collection.length; i++) { + const primitive = collection.get(i); + if (isTileset(primitive)) out.push(primitive); + else if (isCollection(primitive)) + tilesetsIn(primitive, isTileset, isCollection, out); + } + return out; +} + +/** + * @param {object} widget A Cesium `CesiumWidget` or `Viewer`. + * @param {{ + * frame: import('../contract/frame').FramePort, + * report: (error: unknown) => void, + * isTileset?: (p: object) => boolean, + * isCollection?: (p: object) => boolean, + * limits?: {maximumTextureSize: number, maximumSamples: number}, + * tickStarted?: () => number, + * }} options `tickStarted` is when the tick under way started, on the + * `performance.now()` clock; without it the port times from its own + * preUpdate listener. Tests pass the predicates and limits; they default + * to Cesium's classes and context limits. + * @returns {{port: import('../contract/quality').QualityPort, + * dispose: () => void}} + */ +export function createCesiumQuality( + widget, + { + frame, + report, + isTileset = (p) => p instanceof Cesium.Cesium3DTileset, + isCollection = (p) => p instanceof Cesium.PrimitiveCollection, + limits = Cesium.ContextLimits, + tickStarted = null, + }, +) { + const scene = widget.scene; + let disposed = false; + const gone = () => disposed || scene.isDestroyed?.() === true; + + /** The multisample counts the context can draw with; 0 is off. */ + const msaaOptions = () => + scene.msaaSupported + ? MSAA_OPTIONS.filter((n) => n <= limits.maximumSamples) + : []; + + // What the scene had before the port first changed it: the defaults. + const builtFps = frame.diagnostics?.().targetFps ?? 0; + let msaaAtBuild = null; + let shadowsAtBuild = null; + // Cesium clamps the count to the context's only when it is set. + const toContractMsaa = (samples) => + samples > 1 ? Math.min(samples, limits.maximumSamples) : 0; + const builtMsaa = () => msaaAtBuild ?? toContractMsaa(scene.msaaSamples); + const builtShadows = () => + shadowsAtBuild ?? Boolean(scene.shadowMap?.enabled); + + let pixelRatio = 0; + + // Each detail and the errors it was applied over. + const detail = { tiles: 1, terrain: 1 }; + /** Tileset (or the globe) → {own, applied}. */ + const errors = new Map(); + let offWatch = null; + + /** Brings `target`'s error to `factor` times its own. */ + function applyError(target, factor) { + let entry = errors.get(target); + const current = target.maximumScreenSpaceError; + // A value someone set since is its own from then on. + if (!entry || current !== entry.applied) + entry = { own: current, applied: current }; + entry.applied = entry.own * factor; + if (target.maximumScreenSpaceError !== entry.applied) + target.maximumScreenSpaceError = entry.applied; + errors.set(target, entry); + } + + /** Hands `target` back its own error, unless someone set one since. */ + function restoreError(target) { + const entry = errors.get(target); + if (entry && target.maximumScreenSpaceError === entry.applied) + target.maximumScreenSpaceError = entry.own; + errors.delete(target); + } + + /** Brings every tileset in the scene to the tile detail. */ + function applyTiles() { + if (gone()) return; + const tilesets = scene.primitives + ? tilesetsIn(scene.primitives, isTileset, isCollection) + : []; + const live = new Set(tilesets); + // One taken out of the scene gets its own error back. + for (const target of [...errors.keys()]) + if (target === scene.globe) continue; + else if (target.isDestroyed?.()) { + errors.delete(target); + } else if (!live.has(target)) restoreError(target); + for (const tileset of tilesets) + if (detail.tiles === 1) restoreError(tileset); + else applyError(tileset, errorFactor(detail.tiles)); + // Tilesets come and go: while tile detail is lowered, each drawn frame + // looks, before the tilesets update. + const lowered = detail.tiles !== 1; + if (lowered && !offWatch) + offWatch = scene.preRender.addEventListener(applyTiles); + else if (!lowered && offWatch) { + offWatch(); + offWatch = null; + } + } + + function applyTerrain() { + if (gone() || !scene.globe) return; + if (detail.terrain === 1) restoreError(scene.globe); + else applyError(scene.globe, errorFactor(detail.terrain)); + } + + function knobs() { + const list = [ + { + key: 'pixelRatio', + kind: 'number', + min: 0, + max: MAX_PIXEL_RATIO, + default: 0, + cost: 1, + }, + ]; + const msaa = msaaOptions(); + if (msaa.length > 1) + list.push({ + key: 'msaa', + kind: 'enum', + options: msaa, + default: builtMsaa(), + cost: 0.4, + }); + if (scene.shadowMap) + list.push({ + key: 'shadows', + kind: 'boolean', + default: builtShadows(), + cost: 0.6, + }); + list.push( + { + key: 'tiles3dDetail', + kind: 'number', + min: 0, + max: 1, + default: 1, + cost: 0.8, + couples: ['lower detail lowers height accuracy'], + }, + { + key: 'terrainDetail', + kind: 'number', + min: 0, + max: 1, + default: 1, + cost: 0.4, + couples: ['lower detail lowers height accuracy'], + }, + { + key: 'targetFps', + kind: 'number', + min: 0, + max: MAX_FPS, + default: builtFps, + cost: 0.2, + }, + ); + return list; + } + + const setters = { + pixelRatio(value) { + const ratio = inRange(value, 0, MAX_PIXEL_RATIO); + if (ratio === null) return; + pixelRatio = ratio; + widget.useBrowserRecommendedResolution = true; + widget.resolutionScale = ratio || 1; + }, + msaa(value) { + const options = msaaOptions(); + if (options.length <= 1) return false; + msaaAtBuild ??= toContractMsaa(scene.msaaSamples); + // The most samples the context has, up to the count asked for. + const asked = inRange(value, 0, Infinity); + if (asked === null) return; + const samples = options.filter((n) => n <= asked).pop() ?? 0; + scene.msaaSamples = samples || 1; + }, + shadows(value) { + if (!scene.shadowMap) return false; + shadowsAtBuild ??= Boolean(scene.shadowMap.enabled); + scene.shadowMap.enabled = Boolean(value); + }, + tiles3dDetail(value) { + detail.tiles = inRange(value, 0, 1) ?? detail.tiles; + applyTiles(); + }, + terrainDetail(value) { + detail.terrain = inRange(value, 0, 1) ?? detail.terrain; + applyTerrain(); + }, + targetFps(value) { + const fps = inRange(value, 0, MAX_FPS); + if (fps !== null) frame.setTargetFps(fps); + }, + }; + + function get() { + const settings = { + pixelRatio, + tiles3dDetail: detail.tiles, + terrainDetail: detail.terrain, + targetFps: frame.diagnostics?.().targetFps ?? 0, + }; + if (scene.shadowMap) settings.shadows = Boolean(scene.shadowMap.enabled); + if (msaaOptions().length > 1) + settings.msaa = toContractMsaa(scene.msaaSamples); + return settings; + } + + function device() { + return { + backend: scene.context?.webgl2 ? 'webgl2' : 'webgl1', + maxTextureSize: limits.maximumTextureSize, + msaaSamples: scene.msaaSupported ? msaaOptions() : [0], + gpuTimer: false, + }; + } + + // --- Frame signals --------------------------------------------------------- + + const signalListeners = listenerList(report); + let tickStart = 0; + let requested = false; + let mode = 'demand'; + const removers = [ + ...(tickStarted + ? [] + : [ + scene.preUpdate.addEventListener(() => { + tickStart = performance.now(); + }), + ]), + scene.preRender.addEventListener(() => { + if (signalListeners.size === 0) return; + const pending = frame.diagnostics?.().pendingReasons ?? []; + requested = pending.length > 0; + mode = scene.requestRenderMode ? 'demand' : 'continuous'; + }), + scene.postRender.addEventListener(() => { + if (signalListeners.size === 0) return; + signalListeners.fire({ + cpuMs: performance.now() - (tickStarted ? tickStarted() : tickStart), + pixelRatio: scene.pixelRatio ?? 1, + width: scene.drawingBufferWidth ?? scene.canvas?.width ?? 0, + height: scene.drawingBufferHeight ?? scene.canvas?.height ?? 0, + mode, + requested, + holds: frame.diagnostics?.().holds ?? [], + }); + }), + ]; + + const port = { + knobs, + get, + set(settings) { + const ignored = []; + if (gone()) return Object.keys(settings ?? {}); + for (const [key, value] of Object.entries(settings ?? {})) { + const apply = Object.hasOwn(setters, key) ? setters[key] : null; + if (!apply || apply(value) === false) ignored.push(key); + } + frame.requestRender('quality'); + return ignored; + }, + device, + probe() { + const nav = globalThis.navigator; + const media = (query) => globalThis.matchMedia?.(query).matches === true; + return { + device: device(), + browser: { + devicePixelRatio: globalThis.devicePixelRatio ?? 1, + screen: { + width: globalThis.screen?.width ?? 0, + height: globalThis.screen?.height ?? 0, + }, + hardwareConcurrency: nav?.hardwareConcurrency, + deviceMemory: nav?.deviceMemory, + touchOnly: + media('(pointer: coarse)') && !media('(any-pointer: fine)'), + }, + knobs: knobs(), + }; + }, + signals: { + gpuTiming: 'none', + on(event, cb) { + if (event !== 'frame') return () => {}; + return signalListeners.on(cb); + }, + }, + }; + + return { + port, + /** Stops the signals and the detail watch; settings stay as set. */ + dispose() { + if (disposed) return; + disposed = true; + signalListeners.clear(); + for (const off of removers) off(); + offWatch?.(); + offWatch = null; + }, + }; +} diff --git a/src/render/cesium/quality.test.mjs b/src/render/cesium/quality.test.mjs new file mode 100644 index 000000000..b57fd25be --- /dev/null +++ b/src/render/cesium/quality.test.mjs @@ -0,0 +1,328 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { QUALITY_KEYS, checkPortShape } from '../contract/index.js'; +import { createCesiumQuality, errorFactor } from './quality.js'; + +function event() { + const listeners = new Set(); + return { + addEventListener(cb) { + listeners.add(cb); + return () => listeners.delete(cb); + }, + raise: () => [...listeners].forEach((cb) => cb()), + get size() { + return listeners.size; + }, + }; +} + +class Tileset { + constructor(error = 16) { + this.maximumScreenSpaceError = error; + } +} +class Collection { + constructor(items = []) { + this.items = items; + } + get length() { + return this.items.length; + } + get(i) { + return this.items[i]; + } +} + +function fixture({ + msaaSupported = true, + maximumSamples = 8, + tickStarted, +} = {}) { + const primitives = new Collection(); + const scene = { + preUpdate: event(), + preRender: event(), + postRender: event(), + primitives, + globe: { maximumScreenSpaceError: 2 }, + msaaSupported, + msaaSamples: 4, + shadowMap: { enabled: false }, + requestRenderMode: true, + pixelRatio: 1, + drawingBufferWidth: 800, + drawingBufferHeight: 600, + context: { webgl2: true }, + }; + const widget = { + scene, + resolutionScale: 1, + useBrowserRecommendedResolution: true, + }; + const requests = []; + let targetFps = 60; + const frame = { + requestRender: (reason) => requests.push(reason), + setTargetFps: (fps) => (targetFps = fps > 0 ? fps : 0), + diagnostics: () => ({ targetFps, pendingReasons: [], holds: ['flights'] }), + }; + const errors = []; + const part = createCesiumQuality(widget, { + frame, + report: (error) => errors.push(error), + isTileset: (p) => p instanceof Tileset, + isCollection: (p) => p instanceof Collection, + limits: { maximumTextureSize: 16384, maximumSamples }, + tickStarted, + }); + const tick = () => { + scene.preUpdate.raise(); + scene.preRender.raise(); + scene.postRender.raise(); + }; + return { + ...part, + quality: part.port, + scene, + widget, + primitives, + requests, + errors, + tick, + }; +} + +test('the port has the contract’s members', () => { + const { quality } = fixture(); + assert.deepEqual(checkPortShape(quality, 'quality', new Set()), []); +}); + +test('building the port changes nothing in the scene', () => { + const { scene, widget } = fixture(); + assert.equal(scene.msaaSamples, 4); + assert.equal(scene.shadowMap.enabled, false); + assert.equal(scene.globe.maximumScreenSpaceError, 2); + assert.equal(widget.resolutionScale, 1); +}); + +test('knobs name contract keys, with the scene’s own values as defaults', () => { + const { quality } = fixture(); + const knobs = quality.knobs(); + for (const k of knobs) assert.ok(QUALITY_KEYS.includes(k.key), k.key); + const byKey = Object.fromEntries(knobs.map((k) => [k.key, k])); + assert.equal(byKey.msaa.default, 4); + assert.deepEqual(byKey.msaa.options, [0, 2, 4, 8]); + assert.equal(byKey.shadows.default, false); + assert.equal(byKey.targetFps.default, 60); + assert.equal(byKey.pixelRatio.default, 0); + assert.deepEqual(quality.get(), { + pixelRatio: 0, + shadows: false, + msaa: 4, + tiles3dDetail: 1, + terrainDetail: 1, + targetFps: 60, + }); +}); + +test('set applies supported keys, asks for a frame and returns the rest', () => { + const { quality, scene, requests } = fixture(); + const ignored = quality.set({ + msaa: 0, + shadows: true, + targetFps: 30, + clouds: 'high', + water: 'off', + }); + assert.deepEqual(ignored, ['clouds', 'water']); + assert.equal(scene.msaaSamples, 1, 'off is Cesium’s 1'); + assert.equal(scene.shadowMap.enabled, true); + assert.equal(quality.get().targetFps, 30); + assert.equal(quality.get().msaa, 0); + assert.deepEqual(requests, ['quality']); + quality.set({ msaa: 6 }); + assert.equal(scene.msaaSamples, 4, 'the most samples up to the count asked'); +}); + +test('msaa is not listed, and is ignored, where the context cannot multisample', () => { + const { quality, scene } = fixture({ msaaSupported: false }); + assert.ok(!quality.knobs().some((k) => k.key === 'msaa')); + assert.deepEqual(quality.set({ msaa: 8 }), ['msaa']); + assert.equal(scene.msaaSamples, 4); + assert.ok(!('msaa' in quality.get())); + assert.deepEqual(quality.device().msaaSamples, [0]); +}); + +test('msaa options stop at what the context can draw', () => { + const { quality } = fixture({ maximumSamples: 4 }); + const msaa = quality.knobs().find((k) => k.key === 'msaa'); + assert.deepEqual(msaa.options, [0, 2, 4]); +}); + +test('pixelRatio is an absolute ratio; 0 is the browser’s recommended one', () => { + const { quality, widget } = fixture(); + widget.useBrowserRecommendedResolution = false; + widget.resolutionScale = 0.75; + quality.set({ pixelRatio: 2 }); + assert.equal(widget.useBrowserRecommendedResolution, true); + assert.equal(widget.resolutionScale, 2); + assert.equal(quality.get().pixelRatio, 2); + quality.set({ pixelRatio: 0 }); + assert.equal(widget.useBrowserRecommendedResolution, true); + assert.equal(widget.resolutionScale, 1); + quality.set({ pixelRatio: 'x' }); + assert.equal(quality.get().pixelRatio, 0, 'not a number: left as it was'); +}); + +test('detail scales each tileset’s and the globe’s own error, and gives it back', () => { + const { quality, scene, primitives } = fixture(); + const google = new Tileset(16); + const nested = new Tileset(8); + primitives.items.push(google, new Collection([nested]), { other: true }); + quality.set({ tiles3dDetail: 0, terrainDetail: 0.5 }); + assert.equal(google.maximumScreenSpaceError, 16 * 8); + assert.equal(nested.maximumScreenSpaceError, 8 * 8); + assert.equal(scene.globe.maximumScreenSpaceError, 2 * errorFactor(0.5)); + quality.set({ tiles3dDetail: 1, terrainDetail: 1 }); + assert.equal(google.maximumScreenSpaceError, 16); + assert.equal(nested.maximumScreenSpaceError, 8); + assert.equal(scene.globe.maximumScreenSpaceError, 2); + assert.equal(scene.preRender.size, 1, 'no watch at full detail'); +}); + +test('a tileset added while detail is lowered is lowered on the next tick', () => { + const { quality, primitives, scene, tick } = fixture(); + quality.set({ tiles3dDetail: 0.5 }); + const late = new Tileset(16); + primitives.items.push(late); + tick(); + assert.equal(late.maximumScreenSpaceError, 16 * errorFactor(0.5)); + tick(); + assert.equal(late.maximumScreenSpaceError, 16 * errorFactor(0.5), 'once'); + // An error someone sets since is the tileset's own from then on. + late.maximumScreenSpaceError = 4; + tick(); + assert.equal(late.maximumScreenSpaceError, 4 * errorFactor(0.5)); + quality.set({ tiles3dDetail: 1 }); + assert.equal(late.maximumScreenSpaceError, 4); + assert.equal(scene.preRender.size, 1); +}); + +test('a tileset taken out while lowered gets its own error back', () => { + const { quality, primitives, tick } = fixture(); + const tileset = new Tileset(16); + primitives.items.push(tileset); + quality.set({ tiles3dDetail: 0 }); + primitives.items.length = 0; + tick(); + assert.equal(tileset.maximumScreenSpaceError, 16); +}); + +test('frame signals come with each drawn frame and follow the listener rules', () => { + const { quality, tick, errors, dispose } = fixture(); + const seen = []; + quality.signals.on('frame', () => { + throw new Error('listener failure'); + }); + const off = quality.signals.on('frame', (s) => seen.push(s)); + tick(); + assert.equal(errors.length, 1, 'a throw is reported, the rest still run'); + assert.equal(seen.length, 1); + assert.equal(seen[0].width, 800); + assert.equal(seen[0].height, 600); + assert.equal(seen[0].mode, 'demand'); + assert.equal(seen[0].requested, false); + assert.deepEqual(seen[0].holds, ['flights']); + assert.ok(seen[0].cpuMs >= 0); + assert.equal(quality.signals.gpuTiming, 'none'); + off(); + tick(); + assert.equal(seen.length, 1); + quality.signals.on('frame', (s) => seen.push(s)); + dispose(); + tick(); + assert.equal(seen.length, 1, 'nothing after dispose'); +}); + +test('device and probe describe the context and the browser', () => { + const { quality } = fixture(); + const facts = quality.device(); + assert.equal(facts.backend, 'webgl2'); + assert.equal(facts.maxTextureSize, 16384); + assert.equal(facts.gpuTimer, false); + const probe = quality.probe(); + assert.deepEqual(probe.device, facts); + assert.equal(typeof probe.browser.touchOnly, 'boolean'); + assert.deepEqual(probe.knobs, quality.knobs()); +}); + +test('a disposed port changes nothing and ignores every key', () => { + const { quality, scene, dispose } = fixture(); + dispose(); + assert.deepEqual(quality.set({ shadows: true }), ['shadows']); + assert.equal(scene.shadowMap.enabled, false); +}); + +test('an unchanged error is not written again, each write resetting Cesium’s memory adjustment', () => { + const { quality, primitives, tick } = fixture(); + let writes = 0; + let error = 16; + const tileset = new Tileset(); + Object.defineProperty(tileset, 'maximumScreenSpaceError', { + get: () => error, + set(value) { + writes += 1; + error = value; + }, + }); + primitives.items.push(tileset); + quality.set({ tiles3dDetail: 0 }); + assert.equal(writes, 1); + tick(); + tick(); + assert.equal(writes, 1, 'ticks leave the lowered error alone'); +}); + +test('terrain detail alone sets the globe once and watches no tick', () => { + const { quality, scene } = fixture(); + quality.set({ terrainDetail: 0 }); + assert.equal(scene.globe.maximumScreenSpaceError, 16); + assert.equal(scene.preRender.size, 1, 'no watch'); +}); + +test('cpu time counts from the tick’s start when the frame port gives it', () => { + const started = performance.now() - 50; + const { quality, scene, tick } = fixture({ tickStarted: () => started }); + assert.equal(scene.preUpdate.size, 0, 'no timer of its own'); + const seen = []; + quality.signals.on('frame', (s) => seen.push(s)); + tick(); + assert.ok(seen[0].cpuMs >= 50); +}); + +test('lowering detail again scales the tileset’s own error, not the lowered one', () => { + const { quality, primitives } = fixture(); + const tileset = new Tileset(16); + primitives.items.push(tileset); + quality.set({ tiles3dDetail: 0.5 }); + quality.set({ tiles3dDetail: 0 }); + assert.equal(tileset.maximumScreenSpaceError, 16 * 8); +}); + +test('signals say when the loop runs every frame and when a frame was asked for', () => { + const { quality, scene, tick } = fixture(); + const seen = []; + quality.signals.on('frame', (s) => seen.push(s)); + scene.requestRenderMode = false; + tick(); + assert.equal(seen[0].mode, 'continuous'); +}); + +test('the msaa default stays within what the context can draw', () => { + const { quality } = fixture({ maximumSamples: 2 }); + const msaa = quality.knobs().find((k) => k.key === 'msaa'); + assert.deepEqual(msaa.options, [0, 2]); + assert.equal(msaa.default, 2); + assert.equal(quality.get().msaa, 2); +}); diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index f999344c8..68514b986 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -281,6 +281,7 @@ export async function createCesiumSceneRenderer({ query: ports.query, input: input.port, effects: ports.effects, + quality: ports.quality, capture: ports.capture, credits: ports.credits, whenSettled,