From d0ef3a902bdf5aae13c93fc3b260c060a7ae3fd7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 16:01:22 -0800 Subject: [PATCH] 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. --- docs/CURRENT-STATE.md | 13 +- scripts/package-boundaries.json | 28 +++ scripts/qa-anime-sky.mjs | 2 +- scripts/qa-visual-effects.mjs | 28 +-- scripts/qa-visual-input.mjs | 11 +- src/data/infrastructure.test.mjs | 1 + src/input/dispatcher.test.mjs | 6 +- src/input/sensorPick.js | 7 +- src/layers/transit/lifecycle.test.mjs | 17 +- src/render/cesium/appRenderer.js | 14 +- src/render/cesium/appRenderer.test.mjs | 1 - src/render/cesiumAllowList.json | 1 - src/render/unboundCamera.test.mjs | 8 +- src/styles/styleStages.js | 29 +++ src/ui/shellFacade.js | 8 - src/ui/visualEffects.js | 238 ++++++++++++++----------- src/ui/visualEffects.test.mjs | 147 ++++++++++++--- src/ui/visualSettings.js | 2 + 18 files changed, 375 insertions(+), 186 deletions(-) create mode 100644 src/styles/styleStages.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 96daeb09a..5eb1860c1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 923f9b54a..03b5613f7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-anime-sky.mjs b/scripts/qa-anime-sky.mjs index a0a0c9935..040ba76f8 100644 --- a/scripts/qa-anime-sky.mjs +++ b/scripts/qa-anime-sky.mjs @@ -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 { diff --git a/scripts/qa-visual-effects.mjs b/scripts/qa-visual-effects.mjs index f3b9ab8bd..8cdf2acdb 100644 --- a/scripts/qa-visual-effects.mjs +++ b/scripts/qa-visual-effects.mjs @@ -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( diff --git a/scripts/qa-visual-input.mjs b/scripts/qa-visual-input.mjs index 3c096acdc..0706c08e5 100644 --- a/scripts/qa-visual-input.mjs +++ b/scripts/qa-visual-input.mjs @@ -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 ); }), ); diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 30cd35a86..60d021fa6 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -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( diff --git a/src/input/dispatcher.test.mjs b/src/input/dispatcher.test.mjs index 20908077f..c702e2745 100644 --- a/src/input/dispatcher.test.mjs +++ b/src/input/dispatcher.test.mjs @@ -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]); diff --git a/src/input/sensorPick.js b/src/input/sensorPick.js index 9506475f8..91978ac27 100644 --- a/src/input/sensorPick.js +++ b/src/input/sensorPick.js @@ -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, diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index f2536f271..948449730 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -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); diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index d09524360..193bb7282 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -27,16 +27,13 @@ import { cesiumCapabilities, createCesiumPorts } from './ports.js'; * The members a transitional renderer has today. * @typedef {Pick} + * | '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, diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index 8198716d4..26524cd63 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -337,7 +337,6 @@ const EXPECTED_MISSING = [ 'draw.clampState', 'draw.on', 'frame.setTime', - 'renderer.effects', 'renderer.globe', 'renderer.quality', 'renderer.readPixels', diff --git a/src/render/cesiumAllowList.json b/src/render/cesiumAllowList.json index 1032b1cfa..e4c04d0ae 100644 --- a/src/render/cesiumAllowList.json +++ b/src/render/cesiumAllowList.json @@ -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" ] diff --git a/src/render/unboundCamera.test.mjs b/src/render/unboundCamera.test.mjs index f2d346a1c..06f26f4db 100644 --- a/src/render/unboundCamera.test.mjs +++ b/src/render/unboundCamera.test.mjs @@ -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')); diff --git a/src/styles/styleStages.js b/src/styles/styleStages.js new file mode 100644 index 000000000..e1601b31a --- /dev/null +++ b/src/styles/styleStages.js @@ -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}>} 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}> | null} + */ +export const styleStagesOf = (scene) => + (scene && typeof scene === 'object' && records.get(scene)) || null; diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 95bf791d4..b554a8dba 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -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); diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index 9d10f0cef..1a1904064 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -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}} 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 }); } } } diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index 142bbbe4f..105a1542b 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -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', () => { diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 736e4a49e..4661493d1 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -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,