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.
This commit is contained in:
Sameh Khamis committed 2026-11-06 16:33:40 -08:00
1 parent d0ef3a902b
commit 910a691494
8 files changed
+333 -64

No files matched your search

+1
View File
@@ -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",
+68 -9
View File
@@ -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,
});
});
+88
View File
@@ -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);
});
+98 -45
View File
@@ -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);
}
/**
+35
View File
@@ -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');
});
+36 -8
View File
@@ -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.`,
);
});
+3
View File
@@ -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';
+4 -2
View File
@@ -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);
}
}