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,