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,