From 910a6914942b6474570c35e61fbfdc94cc254ca9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 16:15:43 -0800 Subject: [PATCH] Give a viewer with no renderer an effects port, as cameras get The renderer bridge builds and keeps an effects port over a viewer no renderer is bound to, with a factory the Cesium effects module provides, so visual settings on a consumer's own viewer draw their styles, bloom and sharpen as before and give the scene's bloom back when destroyed. The application shell loads the factory; a test checks every export that reaches the effects lookup loads it. --- scripts/package-boundaries.json | 1 + src/render/cesium/effects.js | 77 ++++++++++++++-- src/render/cesium/effects.test.mjs | 88 ++++++++++++++++++ src/render/rendererOf.js | 143 ++++++++++++++++++++--------- src/render/rendererOf.test.mjs | 35 +++++++ src/render/unboundCamera.test.mjs | 44 +++++++-- src/ui/applicationShell.js | 3 + src/ui/visualSettings.js | 6 +- 8 files changed, 333 insertions(+), 64 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 03b5613f7..625dc2923 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1000,6 +1000,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/render/cesium/effects.js", "src/data/followed.js", "src/render/cesium/camera.js", "src/render/cesium/credits.js", diff --git a/src/render/cesium/effects.js b/src/render/cesium/effects.js index d260c3858..608a735fd 100644 --- a/src/render/cesium/effects.js +++ b/src/render/cesium/effects.js @@ -22,7 +22,8 @@ * in its own shader, not the frame. */ import * as Cesium from 'cesium'; -import { listenerList } from './common.js'; +import { provideUnboundEffects } from '../rendererOf.js'; +import { listenerList, uncaught } from './common.js'; /** Below this a style, bloom or sharpen draws nothing, so it runs no pass. */ const OFF = 0.001; @@ -135,6 +136,9 @@ export function createCesiumEffects( let disposed = false; const started = performance.now(); + // Disposed, or the scene went first and took its stages with it. + const gone = () => disposed || scene.isDestroyed?.() === true; + const drawing = (record) => (record.id === current || fading.has(record.id)) && record.intensity > OFF; @@ -197,7 +201,7 @@ export function createCesiumEffects( } function changed(reason) { - if (disposed) return; + if (gone()) return; apply(); frame.requestRender(reason); listeners.fire(port.diagnostics()); @@ -217,7 +221,7 @@ export function createCesiumEffects( : [], defineStyle(definition) { if ( - disposed || + gone() || !capabilities.has('styles') || typeof definition?.shader?.glsl !== 'string' ) @@ -260,7 +264,7 @@ export function createCesiumEffects( return () => { if (removed) return; removed = true; - if (disposed || styles.get(id) !== record) return; + if (gone() || styles.get(id) !== record) return; styles.delete(id); stages.remove(record.stage); fading.delete(id); @@ -269,7 +273,7 @@ export function createCesiumEffects( }; }, setStyle(id, params) { - if (disposed || !capabilities.has('styles')) return; + if (gone() || !capabilities.has('styles')) return; const next = id === null || id === undefined ? null : String(id); if (next !== null && !styles.has(next)) return; if (next !== current) { @@ -282,19 +286,19 @@ export function createCesiumEffects( changed('effects-style'); }, setStyleIntensity(id, intensity) { - if (disposed || !known(id)) return; + if (gone() || !known(id)) return; const record = styles.get(id); record.intensity = clamp01(intensity); if (record.intensity <= OFF) fading.delete(id); changed('effects-style'); }, setStyleParams(id, params) { - if (disposed || !known(id)) return; + if (gone() || !known(id)) return; assignParams(styles.get(id), params); changed('effects-style'); }, bloom({ enabled, strength } = {}) { - if (disposed || !capabilities.has('bloom')) return; + if (gone() || !capabilities.has('bloom')) return; const bloom = scene.postProcessStages.bloom; if (!bloomTaken) { bloomTaken = { @@ -311,7 +315,7 @@ export function createCesiumEffects( changed('effects-bloom'); }, sharpen({ enabled, amount } = {}) { - if (disposed || !capabilities.has('sharpen')) return; + if (gone() || !capabilities.has('sharpen')) return; sharpenEnabled = Boolean(enabled); if (amount !== undefined) sharpenAmount = clamp01(amount); if (!sharpenStage) makeSharpen(); @@ -352,6 +356,13 @@ export function createCesiumEffects( disposed = true; stopClock(); listeners.clear(); + // A destroyed scene took its stages with it. + if (scene.isDestroyed?.()) { + styles.clear(); + sharpenStage = null; + bloomTaken = null; + return; + } for (const record of styles.values()) stages.remove(record.stage); styles.clear(); if (sharpenStage) stages.remove(sharpenStage); @@ -365,3 +376,51 @@ export function createCesiumEffects( }, }; } + +/** + * The frame port's members the effects port uses, straight over a scene no + * renderer drives: a hold asks for the next frame after each one drawn. + * @param {object} scene + */ +function sceneFrame(scene) { + return { + on: (phase, cb) => + scene[phase].addEventListener(() => cb({ now: performance.now() })), + requestRender: () => scene.requestRender(), + holdContinuous() { + const off = scene.postRender.addEventListener(() => + scene.requestRender(), + ); + scene.requestRender(); + let released = false; + return () => { + if (released) return; + released = true; + off(); + if (!scene.isDestroyed?.()) scene.requestRender(); + }; + }, + }; +} + +// A Cesium viewer of a consumer's own, with no renderer bound to it, gets an +// effects port over its scene when code first asks `findEffects` for one. +provideUnboundEffects((viewer) => { + const scene = viewer.scene; + // By shape, not `instanceof`: a consumer's viewer may come from another + // copy of Cesium. + if ( + typeof scene?.postProcessStages?.add !== 'function' || + typeof scene.postProcessStages.remove !== 'function' || + !scene.postProcessStages.bloom || + typeof scene.preRender?.addEventListener !== 'function' || + typeof scene.postRender?.addEventListener !== 'function' || + typeof scene.requestRender !== 'function' + ) + return null; + return createCesiumEffects(scene, { + capabilities: new Set(['styles', 'bloom', 'sharpen']), + frame: sceneFrame(scene), + report: uncaught, + }); +}); diff --git a/src/render/cesium/effects.test.mjs b/src/render/cesium/effects.test.mjs index 9a56096b4..00be336e0 100644 --- a/src/render/cesium/effects.test.mjs +++ b/src/render/cesium/effects.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { bloomUniforms, createCesiumEffects } from './effects.js'; +import { findEffects, releaseUnboundEffects } from '../rendererOf.js'; const ALL = new Set(['styles', 'bloom', 'sharpen']); @@ -300,3 +301,90 @@ test('dispose removes every stage and releases the clock', () => { f.effects.setStyle('anim'); assert.equal(f.stages.length, 0); }); + +test('a viewer with no renderer gets an effects port over its scene', () => { + const events = () => { + const cbs = new Set(); + return { + cbs, + addEventListener(cb) { + cbs.add(cb); + return () => cbs.delete(cb); + }, + raise: () => [...cbs].forEach((cb) => cb()), + }; + }; + const stages = []; + let requests = 0; + const scene = { + postProcessStages: { + bloom: { enabled: false, uniforms: {} }, + add: (stage) => stages.push(stage), + remove: (stage) => stages.splice(stages.indexOf(stage), 1), + }, + preRender: events(), + postRender: events(), + requestRender: () => (requests += 1), + }; + const viewer = { scene }; + const effects = findEffects(viewer); + assert.ok(effects, 'built'); + assert.equal(findEffects(viewer), effects, 'kept'); + assert.equal(findEffects({ scene: {} }), null, 'not a Cesium scene'); + effects.bloom({ enabled: true, strength: 1 }); + assert.equal(scene.postProcessStages.bloom.enabled, true); + // An animated style draws every frame while it shows. + effects.defineStyle(style('anim', { animated: true })); + effects.setStyle('anim'); + assert.equal(scene.postRender.cbs.size, 1, 'held'); + const before = requests; + scene.postRender.raise(); + assert.equal(requests, before + 1, 'the next frame is asked for'); + effects.setStyleIntensity('anim', 0); + assert.equal(scene.postRender.cbs.size, 0, 'released'); + assert.equal(scene.preRender.cbs.size, 0); + releaseUnboundEffects(viewer, {}); + assert.ok(stages.length > 0, 'another port is not let go of'); + releaseUnboundEffects(viewer, effects); + assert.equal(stages.length, 0, 'let go: its passes are gone'); + assert.equal( + scene.postProcessStages.bloom.enabled, + false, + 'bloom given back', + ); + assert.notEqual(findEffects(viewer), effects, 'a later lookup builds anew'); +}); + +test('a destroyed scene is let go of without touching it', () => { + let destroyed = false; + const scene = { + isDestroyed: () => destroyed, + postProcessStages: { + bloom: { enabled: false, uniforms: {} }, + add() {}, + remove() { + if (destroyed) throw new Error('destroyed'); + }, + }, + preRender: { addEventListener: () => () => {} }, + postRender: { addEventListener: () => () => {} }, + requestRender() { + if (destroyed) throw new Error('destroyed'); + }, + }; + const viewer = { scene, isDestroyed: () => destroyed }; + const effects = findEffects(viewer); + effects.defineStyle(style('anim', { animated: true })); + effects.setStyle('anim'); + effects.bloom({ enabled: true, strength: 1 }); + effects.sharpen({ enabled: true, amount: 1 }); + const off = effects.defineStyle(style('plain')); + destroyed = true; + // Core's own teardown after the scene went: nothing throws. + off(); + effects.bloom({ enabled: false }); + effects.sharpen({ enabled: false }); + effects.setStyle(null); + releaseUnboundEffects(viewer, effects); + assert.equal(findEffects(viewer), null); +}); diff --git a/src/render/rendererOf.js b/src/render/rendererOf.js index f0f807c46..49546e5ad 100644 --- a/src/render/rendererOf.js +++ b/src/render/rendererOf.js @@ -9,15 +9,59 @@ const renderers = new WeakMap(); /** - * Camera ports built for scene objects no renderer is bound to (a viewer of - * a consumer's own, say), each `{port, dispose}` and kept for as long as its - * scene object lives, or until a renderer is bound to it. + * Ports built for scene objects no renderer is bound to (a viewer of a + * consumer's own, say), per port: each `{port, dispose}` kept for as long + * as its scene object lives, or until a renderer is bound to it. The + * renderer adapter provides the factory each is built with as it loads. */ -const unboundCameras = new WeakMap(); -let buildUnboundCamera = null; -/** Credits ports built likewise, each `{port, dispose, release}`. */ -const unboundCredits = new WeakMap(); -let buildUnboundCredits = null; +function unboundPorts(member) { + const built = new WeakMap(); + let build = null; + return { + provide(factory) { + build = factory; + }, + drop(host, port) { + const entry = built.get(host); + if (!entry || (port !== undefined && entry.port !== port)) return; + built.delete(host); + entry.dispose(); + }, + /** + * A renderer bound to `host` supersedes its built port: released when + * it can be (it stops working for new callers but keeps what it did), + * else disposed. + */ + supersede(host) { + const entry = built.get(host); + if (!entry) return; + built.delete(host); + if (entry.release) entry.release(); + else entry.dispose(); + }, + find(host) { + const port = rendererOf(host)?.[member]; + if (port) return port; + if (!host || typeof host !== 'object') return null; + if (host.isDestroyed?.()) { + // Gone with its scene: let the built port go with it. + this.drop(host); + return null; + } + let entry = built.get(host); + if (!entry && build) { + entry = build(host); + if (entry) built.set(host, entry); + } + return entry?.port ?? null; + }, + }; +} + +const unboundCameras = unboundPorts('camera'); +const unboundEffects = unboundPorts('effects'); +/** Credits ports are `{port, dispose, release}`: see `bindRenderer`. */ +const unboundCredits = unboundPorts('credits'); /** * Provide the factory `cameraOf` builds a camera port with for a scene @@ -26,7 +70,7 @@ let buildUnboundCredits = null; * @param {(host: object) => ({port: object, dispose: () => void} | null)} build */ export function provideUnboundCamera(build) { - buildUnboundCamera = build; + unboundCameras.provide(build); } /** @@ -37,27 +81,45 @@ export function provideUnboundCamera(build) { * release: () => void} | null)} build */ export function provideUnboundCredits(build) { - buildUnboundCredits = build; + unboundCredits.provide(build); } /** - * Bind a renderer to its scene object. A camera `cameraOf` built for it - * while it had no renderer is disposed: its listeners hear nothing more, - * and `cameraOf` answers with the renderer's camera from then on. + * Provide the factory `findEffects` builds an effects port with for a + * scene object no renderer is bound to, as `provideUnboundCamera` does. + * @param {(host: object) => ({port: object, dispose: () => void} | null)} build + */ +export function provideUnboundEffects(build) { + unboundEffects.provide(build); +} + +/** + * Let go of `port`, if it is the effects port `findEffects` built for + * `host`: its passes go and the scene's own settings come back. A + * renderer's own port, or one built later, is left alone. + * @param {object|null|undefined} host + * @param {object|null|undefined} port + */ +export function releaseUnboundEffects(host, port) { + if (host && typeof host === 'object' && port) unboundEffects.drop(host, port); +} + +/** + * Bind a renderer to its scene object. A port `cameraOf` or `findEffects` + * built for it while it had no renderer is disposed: its listeners hear + * nothing more, and they answer with the renderer's ports from then on. + * A credits port `findCredits` built is released, not disposed: the + * credits added through it stay shown until their own Off. * @param {object} host The scene object callers hold. * @param {object} renderer * @returns {() => void} Unbinds, if the binding is still this one. */ export function bindRenderer(host, renderer) { if (renderers.has(host)) throw new Error('This scene already has a renderer'); - // The renderer's camera supersedes one built while there was none. - unboundCameras.get(host)?.dispose(); - unboundCameras.delete(host); - // Its credits port does too for credits added from now on; one built - // before is released, not disposed: the credits added through it stay - // shown until their own Off. - unboundCredits.get(host)?.release?.(); - unboundCredits.delete(host); + // The renderer's ports supersede ones built while there was none. + unboundCameras.drop(host); + unboundEffects.drop(host); + unboundCredits.supersede(host); renderers.set(host, renderer); return () => { if (renderers.get(host) === renderer) renderers.delete(host); @@ -79,10 +141,20 @@ export const rendererOf = (host) => (host && renderers.get(host)) || null; * has no renderer and no camera can be built for it, or is destroyed. */ export function findCamera(host) { - return ( - rendererOf(host)?.camera ?? - findUnbound(host, unboundCameras, buildUnboundCamera) - ); + return unboundCameras.find(host); +} + +/** + * The effects port of the renderer bound to `host`; with no renderer bound, + * one built over `host` by the provided factory and kept with it while + * `host` lives. + * @param {object|null|undefined} host + * @returns {import('./contract/effects').EffectsPort | null} Null when + * `host` has no renderer and no effects can be built for it, or is + * destroyed. + */ +export function findEffects(host) { + return unboundEffects.find(host); } /** @@ -93,26 +165,7 @@ export function findCamera(host) { * @returns {import('./contract/credits').CreditsPort | null} */ export function findCredits(host) { - return ( - rendererOf(host)?.credits ?? - findUnbound(host, unboundCredits, buildUnboundCredits) - ); -} - -function findUnbound(host, built, build) { - if (!host || typeof host !== 'object') return null; - if (host.isDestroyed?.()) { - // Gone with its scene: let the built port go with it. - built.get(host)?.dispose(); - built.delete(host); - return null; - } - let part = built.get(host); - if (!part && build) { - part = build(host); - if (part) built.set(host, part); - } - return part?.port ?? null; + return unboundCredits.find(host); } /** diff --git a/src/render/rendererOf.test.mjs b/src/render/rendererOf.test.mjs index 0d16db175..c706b655e 100644 --- a/src/render/rendererOf.test.mjs +++ b/src/render/rendererOf.test.mjs @@ -5,8 +5,10 @@ import { cameraOf, findCamera, findCredits, + findEffects, provideUnboundCamera, provideUnboundCredits, + provideUnboundEffects, rendererOf, } from './rendererOf.js'; @@ -130,3 +132,36 @@ test('with no renderer bound, findCredits builds one port per scene and keeps it assert.equal(findCredits(gone), null); assert.equal(built.find((entry) => entry.port === port).disposed, 1); }); + +test('with no renderer bound, findEffects builds one effects port per scene until a renderer is bound', (t) => { + t.after(() => provideUnboundEffects(null)); + const built = []; + provideUnboundEffects((host) => { + if (!host.scene) return null; + const entry = { port: { id: built.length }, disposed: 0 }; + entry.dispose = () => (entry.disposed += 1); + built.push(entry); + return entry; + }); + const viewer = { scene: {} }; + assert.equal(findEffects(viewer), built[0].port); + assert.equal(findEffects(viewer), built[0].port, 'kept, not rebuilt'); + assert.equal(findEffects({}), null, 'nothing to build over'); + assert.equal(findEffects(null), null); + const effects = { styles() {} }; + const unbind = bindRenderer(viewer, { effects }); + assert.equal(built[0].disposed, 1, 'the renderer supersedes the built port'); + assert.equal(findEffects(viewer), effects); + unbind(); + assert.equal( + findEffects(viewer), + built[1].port, + 'unbound again: a fresh one', + ); + let destroyed = false; + const gone = { scene: {}, isDestroyed: () => destroyed }; + findEffects(gone); + destroyed = true; + assert.equal(findEffects(gone), null); + assert.equal(built[2].disposed, 1, 'let go with its scene'); +}); diff --git a/src/render/unboundCamera.test.mjs b/src/render/unboundCamera.test.mjs index 06f26f4db..5cc603410 100644 --- a/src/render/unboundCamera.test.mjs +++ b/src/render/unboundCamera.test.mjs @@ -5,6 +5,8 @@ * provides as `render/cesium/camera.js` loads, so every such entry must load * that module too. It loads `render/cesium/credits.js`, whose factory * builds `findCredits`' port for such a viewer the same way. + * Likewise an entry that reaches a caller of `findEffects` must load + * `render/cesium/effects.js`, which provides the effects factory. * * The graph is every static import, re-export and literal dynamic import of * the entry's module and those it reaches, read by the parsers the Cesium @@ -24,26 +26,26 @@ const root = path.resolve( ); const BRIDGE = 'src/render/rendererOf.js'; const FACTORY = 'src/render/cesium/camera.js'; +const EFFECTS_FACTORY = 'src/render/cesium/effects.js'; /** * Entries that take their Cesium code injected: the module named here is - * GEV's Cesium side for them, and must load the factory. Given another, the - * entry's flight finds no camera (`findCamera`) and does nothing. + * GEV's Cesium side for them, and must load the factories. Given another, the + * entry's flight finds no camera (`findCamera`) and does nothing. Visual + * settings are given their viewer by the application shell, which loads + * both factories; without it, their styles keep records and draw nothing. */ const INJECTED = { './layers/recent-imagery': 'src/layers/recentImagery/rendering.js', + './ui/visual-settings': 'src/ui/applicationShell.js', }; /** * Modules whose use of the bridge holds up without the factory: with no * port they read Cesium's `trackedEntity` instead, which is all an unbound - * viewer can follow by; visual settings ask for the renderer's effects - * only, never a camera, and keep their own records without one. + * viewer can follow by. */ -const FALLS_BACK = new Set([ - 'src/data/followed.js', - 'src/ui/visualSettings.js', -]); +const FALLS_BACK = new Set(['src/data/followed.js']); const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); @@ -163,3 +165,29 @@ test('every public entry that reaches the renderer bridge loads the unbound came `These entries can reach cameraOf without ${FACTORY}; import it where the entry first reaches the bridge.`, ); }); + +test('every public entry that reaches a findEffects caller loads the unbound effects factory', () => { + const missing = []; + let reaching = 0; + const callsFindEffects = (file) => + !file.startsWith('src/render/') && + /\.(m?js|vue)$/.test(file) && + readFileSync(path.join(root, file), 'utf8').includes('findEffects('); + for (const [key, declaration] of Object.entries(pkg.exports)) { + const target = browserTarget(declaration); + if (typeof target !== 'string' || !target.startsWith('./src/')) continue; + const entry = path.normalize(target.slice(2)); + if (![...reachable(entry)].some(callsFindEffects)) continue; + reaching += 1; + const carrier = INJECTED[key]; + const loads = (file) => + reachable(file, { eager: true }).has(EFFECTS_FACTORY); + if (!loads(entry) && !(carrier && loads(carrier))) missing.push(key); + } + assert.ok(reaching > 0, 'no entry reaches findEffects: the walk is broken'); + assert.deepEqual( + missing, + [], + `These entries can reach findEffects without ${EFFECTS_FACTORY}; import it where the entry first reaches the lookup.`, + ); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 69a96e20f..d69926fed 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -32,6 +32,9 @@ import { import * as Cesium from 'cesium'; import { cameraOf } from '../render/cesium/camera.js'; +// Loads the effects factory, so visual settings on a viewer with no renderer +// bound still get an effects port. +import '../render/cesium/effects.js'; import { aircraftTrackingTarget } from '../cockpitTracking.js'; diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 4661493d1..4cc7a5c6d 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -5,7 +5,7 @@ import { useDisplayStore } from './stores/display.js'; import { applicationPinia } from './vueRuntime.js'; import { onLocaleChange, t } from '../i18n/index.js'; import { UiLifetime } from './uiLifetime.js'; -import { rendererOf } from '../render/rendererOf.js'; +import { findEffects, releaseUnboundEffects } from '../render/rendererOf.js'; import { VisualEffects, STYLES, @@ -196,7 +196,7 @@ export class VisualSettings { setStyleParameter: (name, value) => this.setStyleParameter(name, value), }); this._visualEffects = new VisualEffects({ - effects: rendererOf(viewer)?.effects ?? null, + effects: findEffects(viewer), viewer, requestRender: services.governorRequestRender, holdRender: services.holdContinuousRender, @@ -1621,5 +1621,7 @@ export class VisualSettings { this.stop(); this.releaseIrBoost(); this._visualEffects.destroy(); + // On a viewer of a consumer's own, its bloom comes back as it was. + releaseUnboundEffects(this.viewer, this._visualEffects.effects); } }