Drive the visual styles, bloom and sharpen through the effects port

VisualEffects defines each preset on the renderer and keeps its own
records of intensities and parameters, written through to the port, so
crossfades, the anime sky fade, bloom and sharpen behave as before. The
renderer animates the animated styles. Sensor picks read the style
records instead of the renderer's stages. The application renderer now
has the effects port and its capabilities.
This commit is contained in:
Sameh Khamis committed 2026-11-06 16:32:49 -08:00
1 parent d6b553646e
commit d0ef3a902b
18 files changed
+375 -186

No files matched your search

+8 -5
View File
@@ -1473,11 +1473,14 @@ remain unchanged.
## Visual effects ownership
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom
and sharpen. Display supplies actions and renders settings; effect execution has
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
Stopping the controller revokes animation before asynchronous UI teardown; final
destruction removes its stages and restores the borrowed bloom configuration.
VisualEffects keeps the records of the styles, their crossfades, bloom and
sharpen, and drives them through the renderer's effects port: it defines each
preset there as a style, and the renderer runs the passes and animates the
animated ones. Display supplies actions and renders settings; effect execution
has no DOM dependency. Existing presets and the 500 ms transition remain
unchanged. Stopping the controller revokes animation before asynchronous UI
teardown; final destruction removes its styles and turns bloom and sharpen off,
and the renderer restores the scene's bloom configuration when it goes.
Snow is no longer a visual preset. A share link that names it, in either the
long or compact form, opens on Normal and drops the Snow shader parameters. A
view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot,
+28
View File
@@ -32,6 +32,7 @@
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js",
"src/queries/heights.js"
@@ -640,6 +641,7 @@
"modules": [
"src/ui/effects.js",
"src/ui/visualEffects.js",
"src/styles/styleStages.js",
"src/ui/visualPresets.js",
"src/bloom.js",
"src/styles/retro.js",
@@ -915,6 +917,7 @@
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/input/dispatcher.js",
"src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
@@ -1097,6 +1100,7 @@
"src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
"src/styles/styleStages.js",
"src/ui/visualInput.js",
"src/ui/visualPresets.js",
"src/ui/visualSettings.js",
@@ -1348,6 +1352,7 @@
"src/ui/stores/dataPacks.js",
"src/ui/i18n.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
@@ -1447,6 +1452,7 @@
"src/ui/unitLabels.js",
"src/data/entityRecords.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
@@ -1536,6 +1542,7 @@
"src/data/entityRecords.js",
"src/data/scenePick.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
"src/render/cesium/follow.js",
@@ -1636,6 +1643,7 @@
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/input/dispatcher.js",
"src/render/cesium/follow.js",
"src/render/cesium/cameraInput.js",
@@ -1729,6 +1737,7 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
@@ -1783,6 +1792,7 @@
"src/ui/unitLabels.js",
"src/data/entityRecords.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
@@ -1859,6 +1869,7 @@
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/input/dispatcher.js",
"src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
@@ -1932,6 +1943,7 @@
"src/layers/installations/classLabel.js",
"src/data/entityRecords.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
@@ -2097,6 +2109,7 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
@@ -2191,6 +2204,7 @@
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/input/dispatcher.js",
"src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
@@ -2285,6 +2299,7 @@
"src/data/inputOwnership.js",
"src/data/scenePick.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
"src/render/cesium/cameraInput.js",
@@ -2347,6 +2362,7 @@
"src/i18n/locales/zh-Hant.json",
"src/data/entityRecords.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
@@ -2420,6 +2436,7 @@
"src/i18n/locales/zh-Hant.json",
"src/data/scenePick.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
"src/render/cesium/cameraInput.js",
@@ -2475,6 +2492,7 @@
"src/i18n/locales/zh-Hant.json",
"src/data/scenePick.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
"src/render/cesium/cameraInput.js",
@@ -3279,6 +3297,7 @@
"src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
"src/styles/styleStages.js",
"src/ui/visualInput.js",
"src/ui/visualPresets.js",
"src/ui/visualSettings.js",
@@ -4308,6 +4327,7 @@
"src/data/local_data/wof_neighborhoods/files.js",
"src/data/entityRecords.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/input/dispatcher.js",
"src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
@@ -4447,6 +4467,7 @@
"src/render/cesium/ctrlWheelZoom.js",
"src/render/input/router.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/inputOwnership.js",
"src/data/pickRegistry.js",
"src/data/scenePick.js"
@@ -4552,9 +4573,11 @@
"src/ui/rail/rangeValue.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
"src/styles/styleStages.js",
"src/ui/visualInput.js",
"src/ui/visualPresets.js",
"src/ui/visualSettings.js",
"src/render/rendererOf.js",
"src/ui/stores/display.js",
"src/ui/stores/owners.js",
"src/ui/vueRuntime.js",
@@ -4720,6 +4743,7 @@
"src/data/inputOwnership.js",
"src/data/scenePick.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
"src/render/cesium/follow.js",
@@ -5072,6 +5096,7 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/input/dispatcher.js",
"src/render/cesium/cameraInput.js",
"src/render/cesium/input.js",
@@ -5132,6 +5157,7 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
@@ -5206,6 +5232,7 @@
"src/i18n/locales/zh-Hant.json",
"src/data/inputOwnership.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/scenePick.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
@@ -5315,6 +5342,7 @@
"src/data/inputOwnership.js",
"src/data/scenePick.js",
"src/input/sensorPick.js",
"src/styles/styleStages.js",
"src/data/pickRegistry.js",
"src/input/dispatcher.js",
"src/render/cesium/follow.js",
+1 -1
View File
@@ -127,7 +127,7 @@ try {
let animeOn = false;
for (let i = 0; i < s.postProcessStages.length; i++) {
const stage = s.postProcessStages.get(i);
if (stage.name === 'godsEyeView_anime')
if (stage.name === 'effects_style_anime')
animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
}
return {
+18 -10
View File
@@ -169,7 +169,7 @@ try {
result.bloom.intensityPct === 98 &&
snapshot.bloom.intensity === 98 &&
manager._visualEffects.bloomIntensity === 98 &&
manager._bloomStage.enabled
manager.viewer.scene.postProcessStages.bloom.enabled
);
}),
);
@@ -183,7 +183,11 @@ try {
result.sharpen.intensityPct === 61 &&
manager.getVisualState().sharpen.intensity === 61 &&
manager._visualEffects.sharpenIntensity === 0.61 &&
Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9
Math.abs(
manager.viewer.scene.postProcessStages.getStageByName(
'effects_sharpen',
).uniforms.amount - 1.32,
) < 1e-9
);
}),
);
@@ -199,9 +203,10 @@ try {
const manager = window.__godsEyeView.styleManager;
const effects = manager._visualEffects;
manager.setStyle('retro');
const owned = [...Object.values(effects.stages), effects.sharpenStage];
const collection = manager.viewer.scene.postProcessStages;
const previousBloom = effects.previousBloom;
const owned = Object.keys(effects.stages).map((name) =>
collection.getStageByName(`effects_style_${name}`),
);
const pending = manager.dispose();
const stoppedBeforeAwait =
effects.stopped &&
@@ -210,12 +215,15 @@ try {
await pending;
return {
stoppedBeforeAwait,
removed: owned.every((stage) => !collection.contains(stage)),
restored:
effects.bloomStage.enabled === previousBloom.enabled &&
Object.entries(previousBloom.uniforms).every(
([name, value]) => effects.bloomStage.uniforms[name] === value,
removed:
owned.every(Boolean) &&
Object.keys(effects.stages).every(
(name) => !collection.getStageByName(`effects_style_${name}`),
),
// The renderer gives the scene's bloom back when it goes.
restored:
!collection.bloom.enabled &&
!collection.getStageByName('effects_sharpen')?.enabled,
};
});
check(
@@ -223,7 +231,7 @@ try {
teardown.stoppedBeforeAwait,
);
check(
'UI teardown removes owned stages and restores borrowed bloom',
'UI teardown removes its styles and turns bloom and sharpen off',
teardown.removed && teardown.restored,
);
check(
+7 -4
View File
@@ -150,7 +150,7 @@ try {
manager._bloomSlider.focus();
return {
value: Number(manager._bloomSlider.value),
contrast: manager._bloomStage.uniforms.contrast,
contrast: manager.viewer.scene.postProcessStages.bloom.uniforms.contrast,
};
});
await page.keyboard.press('ArrowRight');
@@ -160,7 +160,8 @@ try {
const manager = window.__godsEyeView.styleManager;
return (
Number(manager._bloomSlider.value) > before.value &&
manager._bloomStage.uniforms.contrast < before.contrast
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast <
before.contrast
);
}, displayBefore),
);
@@ -267,13 +268,15 @@ try {
const manager = window.__godsEyeView.styleManager;
manager._displayBindings._detachDisplayControls();
const before = manager.bloomEnabled;
const contrast = manager._bloomStage.uniforms.contrast;
const contrast =
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast;
manager._bloomBtn.click();
manager._bloomSlider.value = '15';
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
return (
manager.bloomEnabled === before &&
manager._bloomStage.uniforms.contrast === contrast
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast ===
contrast
);
}),
);
+1
View File
@@ -208,6 +208,7 @@ test('consumer build includes only infrastructure code and resolves assets under
'router.js',
'scenePick.js',
'sensorPick.js',
'styleStages.js',
'vector.js',
]);
assert.deepEqual(
+2 -4
View File
@@ -8,6 +8,7 @@ import {
fakeInput,
} from './testDoubles.mjs';
import { sensorPickPosition } from './sensorPick.js';
import { registerStyleStages } from '../styles/styleStages.js';
import { claimPointer, releasePointer } from '../data/inputOwnership.js';
import {
registerPickOwner,
@@ -376,11 +377,8 @@ test('sensor styles move the pick to where the picture came from', () => {
const stage = { enabled: true, uniforms: { intensity: 1, pixelation: 4 } };
const scene = {
canvas: { clientWidth: 400, clientHeight: 200, width: 400, height: 200 },
postProcessStages: {
getStageByName: (name) =>
name === 'godsEyeView_surveillance' ? stage : undefined,
},
};
registerStyleStages(scene, { surveillance: stage });
const click = { x: 300, y: 50 };
const mapped = sensorPickPosition(scene, click);
assert.notDeepEqual([mapped.x, mapped.y], [300, 50]);
+4 -3
View File
@@ -7,6 +7,7 @@
* distortion and quantization first.
*/
import * as Cesium from 'cesium';
import { styleStagesOf } from '../styles/styleStages.js';
const SENSOR_STYLES = ['thermal', 'surveillance'];
@@ -18,10 +19,10 @@ const SENSOR_STYLES = ['thermal', 'surveillance'];
*/
export function sensorPickPosition(scene, position) {
const out = new Cesium.Cartesian2(position.x, position.y);
const stages = scene.postProcessStages;
if (typeof stages?.getStageByName !== 'function') return out;
const stages = styleStagesOf(scene);
if (!stages) return out;
for (const style of SENSOR_STYLES) {
const stage = stages.getStageByName(`godsEyeView_${style}`);
const stage = stages[style];
if (!stage?.enabled) continue;
const intensity = stage.uniforms.intensity;
const canvas = scene.canvas,
+11 -6
View File
@@ -44,6 +44,7 @@ import {
} from '../../renderGovernor.js';
import { fakeInput } from '../../input/testDoubles.mjs';
import { framePortFor } from '../../testSupport/fakeFramePort.mjs';
import { registerStyleStages } from '../../styles/styleStages.js';
const BOSTON = { lat: 42.3601, lon: -71.0589 };
@@ -3446,12 +3447,16 @@ test('sensor clicks map displayed pixels into the pre-shader pick buffer', async
};
const uniforms = { intensity: 1, pixelation: 1 };
let preset = 'surveillance';
scene.postProcessStages = {
getStageByName: (name) =>
name === `godsEyeView_${preset}`
? { enabled: true, uniforms }
: undefined,
};
const stage = (name) =>
name === preset ? { enabled: true, uniforms } : undefined;
registerStyleStages(scene, {
get surveillance() {
return stage('surveillance');
},
get thermal() {
return stage('thermal');
},
});
const click = (position) => app.input.click(position);
click(new Cesium.Cartesian2(1440, 540));
assert.ok(Math.abs(actual.x - 1472) < 1e-6);
+4 -10
View File
@@ -27,16 +27,13 @@ import { cesiumCapabilities, createCesiumPorts } from './ports.js';
* The members a transitional renderer has today.
* @typedef {Pick<import('../contract/renderer').Renderer,
* 'id' | 'contractVersion' | 'capabilities' | 'canvas' | 'frame' | 'draw'
* | 'camera' | 'query' | 'input' | 'capture' | 'credits' | 'on'
* | 'resize' | 'destroy'>}
* | 'camera' | 'query' | 'input' | 'effects' | 'capture' | 'credits'
* | 'on' | 'resize' | 'destroy'>}
* TransitionalRenderer
*/
const viewers = new WeakMap();
/** Capabilities of ports the application renderer does not have yet. */
const NOT_YET = ['styles', 'bloom', 'sharpen'];
/** A Set no one can change once built, so `capabilities` stays true. */
export class ReadonlySet extends Set {
#sealed;
@@ -94,11 +91,7 @@ export function createCesiumRendererPlugin({
let capabilities;
try {
viewer = createViewer({ container, creditContainer });
// Effects are not the application's yet: its styles still run
// through stages of their own.
capabilities = new ReadonlySet(
cesiumCapabilities(viewer.scene).filter((c) => !NOT_YET.includes(c)),
);
capabilities = new ReadonlySet(cesiumCapabilities(viewer.scene));
ports = createCesiumPorts(viewer, { capabilities });
input = createCesiumInput(viewer, {
query: ports.query,
@@ -121,6 +114,7 @@ export function createCesiumRendererPlugin({
camera: ports.camera,
query: ports.query,
input: input.port,
effects: ports.effects,
capture: ports.capture,
credits: ports.credits,
on: ports.on,
-1
View File
@@ -337,7 +337,6 @@ const EXPECTED_MISSING = [
'draw.clampState',
'draw.on',
'frame.setTime',
'renderer.effects',
'renderer.globe',
'renderer.quality',
'renderer.readPixels',
-1
View File
@@ -224,7 +224,6 @@
"src/ui/locationNavigation.js",
"src/ui/navigationController.js",
"src/ui/shareFollow.js",
"src/ui/visualEffects.js",
"src/voice/gevActions.js",
"src/voice/pointerPick.js"
]
+6 -2
View File
@@ -37,9 +37,13 @@ const INJECTED = {
/**
* 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.
* viewer can follow by; visual settings ask for the renderer's effects
* only, never a camera, and keep their own records without one.
*/
const FALLS_BACK = new Set(['src/data/followed.js']);
const FALLS_BACK = new Set([
'src/data/followed.js',
'src/ui/visualSettings.js',
]);
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
+29
View File
@@ -0,0 +1,29 @@
/**
* @module styles/styleStages
* @description Core's record of each visual style on a scene: its
* intensity, parameter values and whether it draws, as the application set
* them. Code that must follow a style's picture (sensor picks) reads it here
* rather than asking the renderer.
*/
const records = new WeakMap();
/**
* @param {object} scene
* @param {Record<string, {enabled: boolean, uniforms: Record<string, number>}>} stages
* @returns {() => void} Forgets them, if they are still the scene's.
*/
export function registerStyleStages(scene, stages) {
if (!scene || typeof scene !== 'object') return () => {};
records.set(scene, stages);
return () => {
if (records.get(scene) === stages) records.delete(scene);
};
}
/**
* @param {object} scene
* @returns {Record<string, {enabled: boolean, uniforms: Record<string, number>}> | null}
*/
export const styleStagesOf = (scene) =>
(scene && typeof scene === 'object' && records.get(scene)) || null;
-8
View File
@@ -501,14 +501,6 @@ export class ShellFacade {
return this._visualSettings.sharpenEnabled;
}
get _bloomStage() {
return this._visualEffects.bloomStage;
}
get _sharpenStage() {
return this._visualEffects.sharpenStage;
}
/** Settle only the search generation that still owns the shared input UI. */
_settleLocationSearchUi(generation, options) {
return this._navigation._settleLocationSearchUi(...arguments);
+135 -103
View File
@@ -1,22 +1,51 @@
import { PostProcessStage } from 'cesium';
import {
bloomStrengthFromIntensity,
clampBloomIntensity,
BLOOM_INTENSITY_DEFAULT,
} from '../bloom.js';
import {
STYLES,
SHARPEN_SHADER,
TRANSITION_DURATION_MS,
} from './visualPresets.js';
import { registerStyleStages } from '../styles/styleStages.js';
import { STYLES, TRANSITION_DURATION_MS } from './visualPresets.js';
/** Own the post-process stages and their animation, without DOM dependencies. */
/** Below this a style draws nothing, so it runs no pass. */
const OFF = 0.001;
/**
* A style as the renderer contract defines it
* (`src/render/contract/effects.d.ts`).
* @param {string} name
* @param {{fragmentShader: string, uniforms?: Record<string, {default: number, min: number, max: number}>}} shader
*/
export function styleDefinition(name, shader) {
return {
id: name,
params: Object.entries(shader.uniforms || {}).map(([key, meta]) => ({
key,
min: meta.min,
max: meta.max,
default: meta.default,
})),
animated: shader.fragmentShader.includes('uniform float time'),
shader: { glsl: shader.fragmentShader },
};
}
/**
* Core's records of the visual styles, bloom and sharpen, and their
* crossfades, driven onto the renderer's effects port. Each style's record
* (`stages[name]`) holds its intensity and parameter values in `uniforms`,
* written through to the renderer, and `enabled`, whether it draws. The
* renderer animates `time` for an animated style; this keeps only the
* crossfades' frames. No DOM dependencies.
*/
export class VisualEffects {
/**
* @param {object} options - Viewer, render ownership callbacks and optional clocks.
* Construction is inert; initStyles/initPostProcess retain startup ordering.
* @param {object} options - The renderer's effects port (null: records
* only), the viewer whose sky the anime style dims, render ownership
* callbacks and optional clocks. Construction is inert;
* initStyles/initPostProcess retain startup ordering.
*/
constructor({
effects = null,
viewer,
requestRender,
holdRender,
@@ -24,9 +53,8 @@ export class VisualEffects {
requestFrame = (callback) => requestAnimationFrame(callback),
cancelFrame = (id) => cancelAnimationFrame(id),
now = () => performance.now(),
wallNow = () => Date.now(),
createStage = (options) => new PostProcessStage(options),
}) {
this.effects = effects;
this.viewer = viewer;
this.requestRender = requestRender;
this.holdRender = holdRender;
@@ -34,93 +62,113 @@ export class VisualEffects {
this.requestFrame = requestFrame;
this.cancelFrame = cancelFrame;
this.now = now;
this.wallNow = wallNow;
this.createStage = createStage;
this.stages = {};
this.transitions = new Map();
this.startTime = wallNow();
this.bloomEnabled = false;
this.sharpenEnabled = false;
this.bloomIntensity = BLOOM_INTENSITY_DEFAULT;
this.sharpenIntensity = 0.49;
this.bloomStage = null;
this.sharpenStage = null;
this.postProcessReady = false;
this.frameId = null;
this.stopped = false;
this.destroyed = false;
this.stageEntries = [];
this.previousBloom = null;
// What the renderer draws: its style, and the styles it replaced while
// they fade out (`EffectsPort.setStyle`).
this.rendererStyle = null;
this.drawn = new Set();
this.offs = [];
this.forgetStages = () => {};
}
initStyles() {
if (this.stopped || this.stageEntries.length) return;
for (const [name, shader] of Object.entries(STYLES)) {
const uniforms = { intensity: 0.0 };
if (shader.fragmentShader.includes('uniform float time'))
uniforms.time = 0.0;
for (const [name, meta] of Object.entries(shader.uniforms || {}))
uniforms[name] = meta.default;
const stage = this.createStage({
name: `godsEyeView_${name}`,
fragmentShader: shader.fragmentShader,
uniforms,
});
stage.enabled = false;
this.viewer.scene.postProcessStages.add(stage);
this.stages[name] = stage;
const definition = styleDefinition(name, shader);
const off = this.effects?.defineStyle(definition);
if (off) this.offs.push(off);
this.effects?.setStyleIntensity(name, 0);
this.stages[name] = this.styleRecord(name, definition);
}
this.stageEntries = Object.entries(this.stages);
this.forgetStages = registerStyleStages(this.viewer?.scene, this.stages);
}
/** A style's record, its uniforms written through to the renderer. */
styleRecord(name, definition) {
let intensity = 0;
const values = Object.fromEntries(
definition.params.map((p) => [p.key, p.default]),
);
const uniforms = {};
Object.defineProperty(uniforms, 'intensity', {
enumerable: true,
get: () => intensity,
set: (value) => {
intensity = value;
this.pushIntensity(name, value);
},
});
for (const { key } of definition.params)
Object.defineProperty(uniforms, key, {
enumerable: true,
get: () => values[key],
set: (value) => {
values[key] = value;
if (!this.destroyed)
this.effects?.setStyleParams(name, { [key]: value });
},
});
return { name, enabled: false, uniforms };
}
/**
* A style's intensity on the renderer. Raised from nothing, it becomes
* the renderer's style, so it draws; the style it replaces fades out
* there at the intensity this keeps writing, as every stage with an
* intensity drew before.
*/
pushIntensity(name, value) {
if (this.destroyed || !this.effects) return;
this.effects.setStyleIntensity(name, value);
// Once stopped, a write only tunes what already draws.
if (this.stopped) return;
if (value > OFF) {
if (!this.drawn.has(name)) {
const previous = this.rendererStyle;
if (previous && !(this.stages[previous]?.uniforms.intensity > OFF))
this.drawn.delete(previous);
this.effects.setStyle(name);
this.rendererStyle = name;
this.drawn.add(name);
}
} else if (name !== this.rendererStyle) {
this.drawn.delete(name);
}
}
initPostProcess(sharpenIntensity = this.sharpenIntensity) {
if (this.stopped || this.sharpenStage) return;
this.bloomStage = this.viewer.scene.postProcessStages.bloom;
const names = [
'glowOnly',
'contrast',
'brightness',
'delta',
'sigma',
'stepSize',
];
this.previousBloom = {
enabled: this.bloomStage.enabled,
uniforms: Object.fromEntries(
names.map((name) => [name, this.bloomStage.uniforms[name]]),
),
};
this.bloomStage.enabled = false;
Object.assign(this.bloomStage.uniforms, {
glowOnly: false,
contrast: 256.0,
brightness: -0.35,
delta: 0.25,
sigma: 0.35,
stepSize: 1.0,
if (this.stopped || this.postProcessReady) return;
this.postProcessReady = true;
this.effects?.bloom({
enabled: false,
strength: bloomStrengthFromIntensity(this.bloomIntensity),
});
this.sharpenStage = this.createStage({
name: 'godsEyeView_sharpen',
fragmentShader: SHARPEN_SHADER,
uniforms: { amount: 1.3 },
});
this.sharpenStage.enabled = false;
this.viewer.scene.postProcessStages.add(this.sharpenStage);
this.effects?.sharpen({ enabled: false, amount: sharpenIntensity });
this.applySharpenIntensity(sharpenIntensity);
}
setStageIntensity(stage, value) {
if (this.stopped || !stage) return;
stage.uniforms.intensity = value;
stage.enabled = value > 0.001;
stage.enabled = value > OFF;
// Anime's bands turn the faint Milky Way into blotches, and depth cannot
// tell the sky from far geometry, so the glow fades with the style.
if (
stage === this.stages.anime &&
this.viewer.scene.skyBox?.milkyWayVisibility !== undefined
this.viewer?.scene?.skyBox?.milkyWayVisibility !== undefined
)
this.viewer.scene.skyBox.milkyWayVisibility = 1 - value;
if (stage.enabled && stage.uniforms.time !== undefined)
this.startAnimationLoop();
this.requestRender('style-stage');
}
@@ -130,26 +178,19 @@ export class VisualEffects {
}
syncBloomEnabled() {
if (this.stopped || !this.bloomStage) return;
this.bloomStage.enabled =
this.bloomEnabled &&
bloomStrengthFromIntensity(this.bloomIntensity) > 0.06;
if (this.stopped || !this.postProcessReady) return;
this.effects?.bloom({ enabled: this.bloomEnabled });
}
applyBloomIntensity(intensity) {
if (this.stopped) return;
this.bloomIntensity = clampBloomIntensity(intensity);
this.requestRender('bloom');
if (!this.bloomStage) return;
const rawStrength = bloomStrengthFromIntensity(this.bloomIntensity);
const strength = rawStrength <= 0.06 ? 0.0 : (rawStrength - 0.06) / 0.94;
const eased = strength * strength * (3.0 - 2.0 * strength);
this.bloomStage.uniforms.contrast = 255.0 - eased * 168.0;
this.bloomStage.uniforms.brightness = -0.5 + eased * 0.36;
this.bloomStage.uniforms.sigma = 0.28 + eased * 6.3;
this.bloomStage.uniforms.delta = 0.2 + eased * 2.25;
this.bloomStage.uniforms.stepSize = 1.0 + eased * 1.25;
this.syncBloomEnabled();
if (!this.postProcessReady) return;
this.effects?.bloom({
enabled: this.bloomEnabled,
strength: bloomStrengthFromIntensity(this.bloomIntensity),
});
}
setBloomEnabled(enabled) {
@@ -162,20 +203,18 @@ export class VisualEffects {
/**
* Sharpen runs only when it is on and its slider is above 0, as the style
* stages do: the pass costs the same at any amount (~1.5 ms GPU per frame
* on the Arc, 2026-10-24), and `0.1 + value * 2` still sharpened at 0.
* on the Arc, 2026-10-24), and its gain still sharpened at 0.
*/
syncSharpenEnabled() {
if (this.stopped || !this.sharpenStage) return;
this.sharpenStage.enabled =
this.sharpenEnabled && this.sharpenIntensity > 0.001;
if (this.stopped || !this.postProcessReady) return;
this.effects?.sharpen({ enabled: this.sharpenEnabled });
}
applySharpenIntensity(value) {
if (this.stopped) return;
this.sharpenIntensity = value;
if (this.sharpenStage)
this.sharpenStage.uniforms.amount = 0.1 + value * 2.0;
this.syncSharpenEnabled();
if (this.postProcessReady)
this.effects?.sharpen({ enabled: this.sharpenEnabled, amount: value });
this.requestRender('sharpen');
}
@@ -192,12 +231,12 @@ export class VisualEffects {
this.startAnimationLoop();
}
/** Steps the crossfades each frame, holding frames while one runs. */
startAnimationLoop() {
if (this.stopped || this.frameId !== null) return;
const update = () => {
if (this.stopped) return;
const now = this.now();
const elapsedSec = (this.wallNow() - this.startTime) / 1000.0;
for (const [name, transition] of this.transitions) {
const t = Math.min(
(now - transition.start) / TRANSITION_DURATION_MS,
@@ -213,14 +252,7 @@ export class VisualEffects {
this.transitions.delete(name);
}
}
let animatedStageVisible = false;
for (const [, stage] of this.stageEntries) {
if (stage.enabled && stage.uniforms.time !== undefined) {
stage.uniforms.time = elapsedSec;
if (stage.uniforms.intensity > 0.001) animatedStageVisible = true;
}
}
const needed = this.transitions.size > 0 || animatedStageVisible;
const needed = this.transitions.size > 0;
if (needed) this.holdRender('style-anim');
else this.releaseRender('style-anim');
this.frameId = needed ? this.requestFrame(update) : null;
@@ -228,7 +260,7 @@ export class VisualEffects {
this.frameId = this.requestFrame(update);
}
/** Revoke animation synchronously while callers finish releasing the stages. */
/** Revoke animation synchronously while callers finish releasing the styles. */
stop() {
if (this.stopped) return;
this.stopped = true;
@@ -238,17 +270,17 @@ export class VisualEffects {
this.transitions.clear();
}
/** Remove owned stages and restore the borrowed bloom stage after callers release it. */
/** Remove the styles from the renderer and turn bloom and sharpen off. */
destroy() {
if (this.destroyed) return;
this.stop();
this.destroyed = true;
const stages = this.viewer.scene.postProcessStages;
for (const [, stage] of this.stageEntries) stages.remove(stage);
if (this.sharpenStage) stages.remove(this.sharpenStage);
if (this.bloomStage && this.previousBloom) {
Object.assign(this.bloomStage.uniforms, this.previousBloom.uniforms);
this.bloomStage.enabled = this.previousBloom.enabled;
this.forgetStages();
for (const off of this.offs) off();
this.offs = [];
if (this.postProcessReady) {
this.effects?.bloom({ enabled: false });
this.effects?.sharpen({ enabled: false });
}
}
}
+119 -28
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { VisualEffects } from './visualEffects.js';
import { createCesiumEffects } from '../render/cesium/effects.js';
import { styleStagesOf } from '../styles/styleStages.js';
import { MILKY_WAY_INTENSITY, StarrySky } from '../sky/starrySky.js';
import {
GLOBAL_POST_DEFAULTS,
@@ -14,9 +16,10 @@ function fixture() {
const frames = new Map();
const stages = new Set();
const holds = new Set();
const portHolds = new Set();
const requests = [];
const bloom = {
enabled: true,
enabled: false,
uniforms: {
glowOnly: true,
contrast: 3,
@@ -36,10 +39,33 @@ function fixture() {
return stages.delete(stage);
},
};
const effects = new VisualEffects({
viewer: {
scene: { postProcessStages: pipeline, skyBox: { milkyWayVisibility: 1 } },
const scene = {
postProcessStages: pipeline,
skyBox: { milkyWayVisibility: 1 },
};
const preRender = new Set();
const renderer = createCesiumEffects(scene, {
capabilities: new Set(['styles', 'bloom', 'sharpen']),
frame: {
on(phase, cb) {
preRender.add(cb);
return () => preRender.delete(cb);
},
holdContinuous(reason) {
const hold = {};
portHolds.add(hold);
return () => portHolds.delete(hold);
},
requestRender() {},
},
report: (error) => {
throw error;
},
createStage: (options) => ({ ...options, enabled: true }),
});
const effects = new VisualEffects({
effects: renderer.port,
viewer: { scene },
requestRender: (reason) => requests.push(reason),
holdRender: (reason) => holds.add(reason),
releaseRender: (reason) => holds.delete(reason),
@@ -50,8 +76,6 @@ function fixture() {
},
cancelFrame: (id) => frames.delete(id),
now: () => time,
wallNow: () => 10_000 + time,
createStage: (options) => ({ ...options, enabled: true }),
});
function tick(value) {
time = value;
@@ -59,11 +83,20 @@ function fixture() {
frames.clear();
for (const callback of pending) callback();
}
/** The renderer's stage for a style, or sharpen. */
const stage = (name) =>
[...stages].find(
(s) => s.name === `effects_style_${name}` || s.name === `effects_${name}`,
);
return {
effects,
renderer,
frames,
stages,
stage,
holds,
portHolds,
preRender,
requests,
bloom,
originalBloom,
@@ -86,7 +119,10 @@ test('construction is inert and initialization creates one owned pipeline', () =
f.effects.initStyles();
f.effects.initPostProcess();
assert.equal(f.stages.size, 6);
assert.ok([...f.stages].every((stage) => !stage.enabled));
assert.equal(styleStagesOf(f.effects.viewer.scene), f.effects.stages);
f.effects.destroy();
assert.equal(styleStagesOf(f.effects.viewer.scene), null);
});
test('crossfade uses the established 500ms easing and settles without idle frames', () => {
@@ -104,6 +140,8 @@ test('crossfade uses the established 500ms easing and settles without idle frame
assert.equal(f.effects.stages.noir.uniforms.intensity, 0.125);
f.tick(250);
assert.equal(f.effects.stages.noir.uniforms.intensity, 0.5);
assert.equal(f.stage('noir').uniforms.intensity, 0.5);
assert.equal(f.stage('noir').enabled, true);
f.tick(500);
assert.equal(f.effects.stages.noir.uniforms.intensity, 1);
assert.equal(f.effects.transitions.size, 0);
@@ -132,32 +170,82 @@ test('a superseding transition starts from the current rendered intensity', () =
f.effects.destroy();
});
test('only visible animated stages keep the animation clock and render hold alive', () => {
test('only visible animated stages keep the renderer’s clock and hold alive', () => {
const f = fixture();
f.effects.initStyles();
const animated = f.effects.stages.retro;
assert.notEqual(animated.uniforms.time, undefined);
f.effects.setStageIntensity(animated, 1);
f.tick(1000);
assert.equal(animated.uniforms.time, 1);
assert.equal(f.frames.size, 1);
assert.equal(f.holds.size, 1);
assert.equal(f.portHolds.size, 1);
for (const cb of f.preRender) cb({ now: performance.now() + 1000 });
assert.ok(f.stage('retro').uniforms.time > 0.9);
assert.equal(f.frames.size, 0, 'no frames of its own');
f.effects.setStageIntensity(animated, 0);
f.tick(1100);
assert.equal(f.frames.size, 0);
assert.equal(f.portHolds.size, 0);
assert.equal(f.holds.size, 0);
f.effects.destroy();
});
test('a crossfade draws both styles until the old one reaches 0', () => {
const f = fixture();
f.effects.initStyles();
f.effects.setStageIntensity(f.effects.stages.noir, 1);
f.effects.startTransition('noir', 1, 0);
f.effects.startTransition('thermal', 0, 1);
f.tick(250);
assert.equal(f.stage('noir').enabled, true);
assert.equal(f.stage('thermal').enabled, true);
assert.equal(f.stage('noir').uniforms.intensity, 0.5);
assert.equal(f.stage('thermal').uniforms.intensity, 0.5);
f.tick(500);
assert.equal(f.stage('noir').enabled, false);
assert.equal(f.stage('thermal').enabled, true);
// Back again before the first fade-in ends: all three may draw at once.
f.effects.startTransition('thermal', 1, 0);
f.effects.startTransition('noir', 0, 1);
f.tick(600);
f.effects.startTransition('retro', 0, 1);
f.tick(700);
assert.deepEqual(
['retro', 'thermal', 'noir'].map((name) => f.stage(name).enabled),
[true, true, true],
);
f.tick(1300);
assert.deepEqual(
Object.entries(f.effects.stages)
.filter(([, stage]) => stage.enabled)
.map(([name]) => name)
.sort(),
['noir', 'retro'],
);
assert.deepEqual(
['retro', 'thermal', 'noir'].map((name) => f.stage(name).enabled),
[true, false, true],
);
f.effects.destroy();
});
test('style parameters are written through to the renderer', () => {
const f = fixture();
f.effects.initStyles();
assert.equal(f.effects.stages.thermal.uniforms.pixelation, 1.5);
f.effects.stages.thermal.uniforms.pixelation = 4;
assert.equal(f.stage('thermal').uniforms.pixelation, 4);
assert.equal(f.effects.stages.thermal.uniforms.pixelation, 4);
assert.equal(f.effects.stages.thermal.uniforms.unknown, undefined);
f.effects.destroy();
});
test('direct Cockpit intensity writes are reconciled before rendering', () => {
const f = fixture();
f.effects.initStyles();
f.effects.stages.thermal.uniforms.intensity = 1;
f.effects.syncStagesEnabledFromIntensity();
assert.equal(f.effects.stages.thermal.enabled, true);
assert.equal(f.stage('thermal').enabled, true);
f.effects.stages.thermal.uniforms.intensity = 0;
f.effects.syncStagesEnabledFromIntensity();
assert.equal(f.effects.stages.thermal.enabled, false);
assert.equal(f.stage('thermal').enabled, false);
f.effects.destroy();
});
@@ -199,8 +287,11 @@ test('bloom retains its dead zone, normalized intensity and independent toggle',
f.effects.setBloomEnabled(true);
f.effects.applyBloomIntensity(0);
assert.equal(f.bloom.enabled, false);
f.effects.applyBloomIntensity(12);
assert.equal(f.bloom.enabled, false, 'the dead zone');
f.effects.applyBloomIntensity(200);
assert.equal(f.bloom.enabled, true);
assert.equal(f.bloom.uniforms.glowOnly, false);
assert.equal(f.bloom.uniforms.contrast, 87);
f.effects.setBloomEnabled(false);
assert.equal(f.effects.bloomIntensity, 200);
@@ -213,14 +304,14 @@ test('bloom retains its dead zone, normalized intensity and independent toggle',
test('sharpen uses the existing uniform scale and keeps values while disabled', () => {
const f = fixture();
f.effects.initPostProcess(0.49);
assert.equal(f.effects.sharpenStage.uniforms.amount, 1.08);
assert.equal(f.stage('sharpen').uniforms.amount, 1.08);
f.effects.applySharpenIntensity(1);
f.effects.setSharpenEnabled(true);
assert.equal(f.effects.sharpenStage.uniforms.amount, 2.1);
assert.equal(f.effects.sharpenStage.enabled, true);
assert.equal(f.stage('sharpen').uniforms.amount, 2.1);
assert.equal(f.stage('sharpen').enabled, true);
f.effects.setSharpenEnabled(false);
assert.equal(f.effects.sharpenIntensity, 1);
assert.equal(f.effects.sharpenStage.enabled, false);
assert.equal(f.stage('sharpen').enabled, false);
f.effects.destroy();
});
@@ -228,17 +319,13 @@ test('sharpen at 0 runs no pass, and comes back with the slider', () => {
const f = fixture();
f.effects.initPostProcess(0.49);
f.effects.setSharpenEnabled(true);
assert.equal(f.effects.sharpenStage.enabled, true);
assert.equal(f.stage('sharpen').enabled, true);
f.effects.applySharpenIntensity(0);
assert.equal(
f.effects.sharpenStage.enabled,
false,
'no residual sharpen at 0',
);
assert.equal(f.stage('sharpen').enabled, false, 'no residual sharpen at 0');
f.effects.setSharpenEnabled(true);
assert.equal(f.effects.sharpenStage.enabled, false, 'on at 0 is still off');
assert.equal(f.stage('sharpen').enabled, false, 'on at 0 is still off');
f.effects.applySharpenIntensity(0.2);
assert.equal(f.effects.sharpenStage.enabled, true);
assert.equal(f.stage('sharpen').enabled, true);
f.effects.destroy();
});
@@ -262,13 +349,17 @@ test('stop revokes pending work immediately but retains stages until final destr
assert.equal(f.frames.size, 0);
assert.equal(f.effects.stages.retro.uniforms.intensity, intensity);
f.effects.destroy();
assert.equal(f.stages.size, 1, 'the styles go; sharpen is the renderer’s');
assert.equal(f.stage('sharpen').enabled, false);
assert.equal(f.bloom.enabled, false);
f.effects.destroy();
f.renderer.dispose();
assert.equal(f.stages.size, 0);
assert.deepEqual(
f.bloom,
f.originalBloom,
'borrowed bloom configuration is restored',
'the renderer gives the bloom configuration back',
);
f.effects.destroy();
});
test('destroying one instance does not remove another pipeline or clock', () => {
+2
View File
@@ -5,6 +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 {
VisualEffects,
STYLES,
@@ -195,6 +196,7 @@ export class VisualSettings {
setStyleParameter: (name, value) => this.setStyleParameter(name, value),
});
this._visualEffects = new VisualEffects({
effects: rendererOf(viewer)?.effects ?? null,
viewer,
requestRender: services.governorRequestRender,
holdRender: services.holdContinuousRender,