diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f654587..5855ec39c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown. + - Extract Display control bindings with synchronous listener cleanup; preserve existing visual actions and native input behavior. - Extract application shortcuts and shader-parameter controls into reusable UI diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 607315d4b..db1e450bc 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -250,3 +250,13 @@ facade's asynchronous teardown can yield. DOM elements and explicit actions, imports no application or effect singleton, and releases every listener on destruction. Settings and rendering remain with the caller. + +## Visual effects + +`ui/effects` exports the effects controller and existing preset definitions. +It owns shader stages and their clock, with explicit render ownership callbacks. +Construction installs no stages or frame callbacks. Stop animation before +releasing UI consumers, then destroy to remove owned stages and restore the +borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers +without loading the renderer. UI presentation and product-action coordination +remain in their callers. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 61d996883..2461def9e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +## 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. + ## Display control ownership Display button, selector and slider listeners have a single destroyable owner. diff --git a/package.json b/package.json index aae850dc0..d6ecae4fb 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,8 @@ "./ui/surfaces": "./src/ui/surfaceKeyboard.js", "./ui/layout": "./src/ui/panelRails.js", "./ui/input": "./src/ui/visualInput.js", - "./ui/display": "./src/ui/displayControls.js" + "./ui/display": "./src/ui/displayControls.js", + "./ui/effects": "./src/ui/effects.js", + "./ui/effects/bloom": "./src/bloom.js" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 91296df96..946066474 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -299,5 +299,10 @@ "exports": ["./ui/display"], "modules": ["src/ui/displayControls.js"], "external": [] + }, + "visual-effects": { + "exports": ["./ui/effects", "./ui/effects/bloom"], + "modules": ["src/ui/effects.js", "src/ui/visualEffects.js", "src/ui/visualPresets.js", "src/bloom.js", "src/styles/retro.js", "src/styles/surveillance.js", "src/styles/thermal.js", "src/styles/anime.js", "src/styles/noir.js", "src/styles/snow.js"], + "external": ["cesium"] } } diff --git a/scripts/qa-visual-effects.mjs b/scripts/qa-visual-effects.mjs new file mode 100644 index 000000000..b2a5fe656 --- /dev/null +++ b/scripts/qa-visual-effects.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** Rendered acceptance for style transitions and effect-state ownership. */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', ...(process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu'] : ['--use-gl=angle', '--use-angle=swiftshader'])] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +function check(name, passed) { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failures++; +} +try { + await page.setViewport({ width: 1440, height: 900 }); + const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); + url.searchParams.set('welcome', '0'); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.styleManager?._visualEffects && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60_000 }); + await page.evaluate(() => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.scene.tweens?.removeAll?.(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ longitude: -97.7431 * Math.PI / 180, latitude: 30.2568 * Math.PI / 180, height: 1200 }), + orientation: { heading: 0, pitch: -0.85, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const settled = await page.waitForFunction(() => { + const primitives = window.__godsEyeView.viewer.scene.primitives; + for (let i = 0; i < primitives.length; i++) { + const primitive = primitives.get(i); + if (typeof primitive.tilesLoaded === 'boolean' && !primitive.tilesLoaded) return false; + } + return true; + }, { timeout: 60_000 }).then(() => true, () => false); + check('visible tile content settles before visual captures', settled); + fs.mkdirSync('qa-shots/visual-effects', { recursive: true }); + for (const style of ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow', 'normal']) { + await page.evaluate((name) => window.__godsEyeView.styleManager.setStyle(name), style); + await page.waitForFunction(() => window.__godsEyeView.styleManager.transitions.size === 0, { timeout: 10_000 }); + const state = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const effects = manager._visualEffects; + const visible = Object.entries(effects.stages).filter(([, stage]) => stage.enabled).map(([name]) => name); + const animated = Object.values(effects.stages).some((stage) => stage.enabled && stage.uniforms.time !== undefined && stage.uniforms.intensity > 0.001); + return { active: manager.activeStyle, visible, clock: effects.frameId !== null, animated }; + }); + check(`${style}: only the selected style remains visible`, state.active === style && JSON.stringify(state.visible) === JSON.stringify(style === 'normal' ? [] : [style])); + check(`${style}: the animation clock follows visible animated stages`, state.clock === state.animated); + if (['normal', 'surveillance', 'thermal'].includes(style)) { + await page.screenshot({ path: `qa-shots/visual-effects/${style}.png` }); + if (style === 'surveillance') await page.evaluate(() => window.__godsEyeView.viewer.camera.lookRight(0.2)); + } + } + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setStyle('noir'); manager.setStyle('retro'); manager.setStyle('normal'); + }); + await page.waitForFunction(() => window.__godsEyeView.styleManager.transitions.size === 0, { timeout: 10_000 }); + check('rapid switches settle to Normal without an old style or clock', await page.evaluate(() => { + const effects = window.__godsEyeView.styleManager._visualEffects; + return effects.frameId === null && Object.values(effects.stages).every((stage) => !stage.enabled); + })); + check('bloom actions, effect state and saved snapshot agree', await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const result = manager.setBloom({ enabled: true, intensityPct: 98 }); + const snapshot = manager.getVisualState(); + return result.ok && result.bloom.intensityPct === 98 && snapshot.bloom.intensity === 98 && manager._visualEffects.bloomIntensity === 98 && manager._bloomStage.enabled; + })); + check('sharpen actions, effect state and saved snapshot agree', await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const result = manager.setSharpen({ enabled: true, intensityPct: 61 }); + return result.ok && result.sharpen.intensityPct === 61 && manager.getVisualState().sharpen.intensity === 61 && manager._visualEffects.sharpenIntensity === 0.61 && Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9; + })); + await page.setViewport({ width: 620, height: 900 }); + await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, { persist: false, syncShare: false })); + await page.screenshot({ path: 'qa-shots/visual-effects/narrow.png' }); + const teardown = await page.evaluate(async () => { + 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 pending = manager.dispose(); + const stoppedBeforeAwait = effects.stopped && effects.frameId === null && effects.transitions.size === 0; + 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), + }; + }); + check('UI teardown stops effect animation before its first await', teardown.stoppedBeforeAwait); + check('UI teardown removes owned stages and restores borrowed bloom', teardown.removed && teardown.restored); + check('effects do not introduce uncaught browser errors', errors.length === 0); + if (errors.length) console.log(JSON.stringify(errors)); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs index 0dcb5a6f0..98b2401ef 100644 --- a/src/contactsDetectionPolicy.test.mjs +++ b/src/contactsDetectionPolicy.test.mjs @@ -26,7 +26,9 @@ import { } from './data/detection.js'; import { canonicalizeDensity } from './data/detectionPolicy.js'; -const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +// Follow the UI wiring and its extracted preset definitions. +const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8') + + '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8'); /** * The tactical preset ui.js hands Contacts. Read out of the source so this test @@ -34,7 +36,7 @@ const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); */ const MILITARY_PRESET = (() => { const match = uiSource.match( - /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: '(\w+)', densityPct: (\d+) \}\)/, + /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: '(\w+)',\s*densityPct: (\d+),?\s*\}\)/, ); assert.ok(match, 'ui.js must expose one shared military detection preset'); return { mode: match[1].toUpperCase(), densityPct: Number(match[2]) }; diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index acacff5ce..b51776ff5 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -671,7 +671,8 @@ test('pathological detection paint holds alternate frames without freezing share test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => { const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8'); - const uiSource = readFileSync(new URL('../ui.js', import.meta.url), 'utf8'); + const uiSource = readFileSync(new URL('../ui.js', import.meta.url), 'utf8') + + '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8'); assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/); assert.doesNotMatch(source, /postRender\.addEventListener/); assert.doesNotMatch(source, /['"]detection-overlay['"]/); @@ -694,7 +695,7 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle // three times" — same guarantee, and the copies can no longer drift. assert.match( uiSource, - /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: 'dense', densityPct: 75 \}\);/, + /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: 'dense',\s*densityPct: 75,?\s*\}\);/, 'the tactical detection default is still Dense @ 75%', ); assert.equal( diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index b007128c8..c8ccff310 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -41,7 +41,9 @@ import { } from './scopeMask.js'; import { ShareLinkManager } from './sharelink.js'; -const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +// Follow the UI wiring and its extracted preset definitions. +const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8') + + '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8'); const indexHtml = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); @@ -210,7 +212,7 @@ test('first run opens with detection on, in every style, using the one tactical // Normal used to start OFF while only CRT/NVG/FLIR auto-applied the preset. // It is now the baseline for all of them, reusing the SAME frozen object, so // "the tactical look" cannot fork into two definitions. - assert.match(uiSource, /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: 'dense', densityPct: 75 \}\);/, + assert.match(uiSource, /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: 'dense',\s*densityPct: 75,?\s*\}\);/, 'the tactical look is still Dense @ 75%'); const baseline = uiBlock('const GLOBAL_POST_DEFAULTS = {', '\n};'); assert.match(baseline, /detectionMode: MILITARY_DETECTION_PRESET\.mode\.toUpperCase\(\),/, diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index c655401cf..e85ba2211 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -690,6 +690,7 @@ test('visual input listeners are revoked before asynchronous UI teardown', () => assert.ok(firstAwait > 0); const synchronous = disposal.slice(0, firstAwait); assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/); + assert.match(synchronous, /this\._visualEffects\.stop\(\)/); assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/); assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/); }); diff --git a/src/ui.js b/src/ui.js index e614eae70..5315fa4d9 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,18 +1,12 @@ +import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js'; import { bindDisplayControls } from './ui/displayControls.js'; import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js'; import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js'; import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js'; import * as Cesium from 'cesium'; -import { retroShader } from './styles/retro.js'; -import { animeShader } from './styles/anime.js'; -import { noirShader } from './styles/noir.js'; -import { snowShader } from './styles/snow.js'; -import { nightVisionShader } from './styles/surveillance.js'; -import { thermalShader } from './styles/thermal.js'; import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION, - bloomStrengthFromIntensity, clampBloomIntensity, decodeBloomIntensity, } from './bloom.js'; @@ -192,10 +186,6 @@ import { speedRulerTicks, } from './cockpitMath.js'; -/** Duration (ms) for shader intensity crossfade between style presets. */ -const TRANSITION_DURATION_MS = 500; -/** Map of style name to its GLSL shader module for post-process stages. */ -const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader }; /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; const SHARE_PANEL_STATE_SPECS = Object.freeze([ @@ -361,135 +351,13 @@ const STYLE_STATUS_LABELS = { noir: 'NOIR', snow: 'SNOW', }; -/** - * The tactical detection look: Dense at 75%. - * - * Owner playtest 2026-08-18: "detection mode… 75% weighted, with the 16% fade - * and 5% outside, whatever we had. I want that as the default. It should just - * happen." Fade and outside opacity live in GLOBAL_POST_DEFAULTS, so "whatever - * we had" still needs nothing here — but they are 7% and 1% now, the outside - * default having moved 5 → 3 → 1 as the owner locked final tuning after field trials (2026-08-24). What the quote asked for is the - * baseline of the day, not the two numbers it happened to name. - * - * ONE object, shared by the first-load baseline below, by every military style, - * AND by the Contacts context mode (which OWNS detection while active and - * restores the prior state on exit — see contactsDetectionPolicy.js). Cockpit - * deliberately does NOT touch detection: entering it with SPARSE selected leaves - * SPARSE. Declared ahead of GLOBAL_POST_DEFAULTS because that baseline now reads - * from it. - */ -const MILITARY_DETECTION_PRESET = Object.freeze({ mode: 'dense', densityPct: 75 }); -/** Baseline post-processing settings applied on first load (before share-link restore). */ -const GLOBAL_POST_DEFAULTS = { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - hudVariant: 'tactical', - hudVisible: true, - // Detection is ON for EVERY style on a first run, Normal included (owner - // directive 2026-08-22: "detect should also be on by default"). It is the - // same preset object the military styles and Contacts already apply, so there - // is one tactical look, not several that can drift. - // - // This is a first-LOAD baseline, not an override: `_applyGlobalPostDefaults` - // runs before any share-link restore, so a link's `dm`/`dd` still lands on top - // of it. It also deliberately leaves `_detectionUserOverridden` alone — the - // flag means the OPERATOR hand-edited detection, and a factory default is not - // that. Turning detection off by hand therefore still sets the flag and still - // suppresses the military-style auto-enable for the rest of the session. - detectionMode: MILITARY_DETECTION_PRESET.mode.toUpperCase(), - detectionDensity: MILITARY_DETECTION_PRESET.densityPct, - detectionAllocation: 'ELASTIC', - detectionFadePct: 7, - detectionOutsideOpacityPct: 1, - celestialRing: false, -}; - -// Tactical style defaults applied when users select military style presets. -const STYLE_PRESET_DEFAULTS = { - retro: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - retro: { - pixelation: 1.0, - distortion: 0, - instability: 0.42, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, - surveillance: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - surveillance: { - gain: 0.18, - bloom: 0.22, - scanlineStr: 0.96, - pixelation: 1.0, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, - thermal: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - thermal: { - sensitivity: 0.85, - bloom: 0.2, - mode: 0.33, - pixelation: 1.0, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, -}; - -/** - * GLSL fragment shader implementing an unsharp-mask sharpening filter. - * Samples a 3x3 neighborhood, computes box blur, then adds the - * difference (center - blur) scaled by `amount` for edge enhancement. - */ -const SHARPEN_SHADER = /* glsl */ ` - uniform sampler2D colorTexture; - uniform vec2 colorTextureDimensions; - uniform float amount; - in vec2 v_textureCoordinates; - - void main() { - vec2 uv = v_textureCoordinates; - vec2 texel = 1.0 / colorTextureDimensions; - vec4 center = texture(colorTexture, uv); - vec4 blur = ( - texture(colorTexture, uv + vec2(-texel.x, -texel.y)) + - texture(colorTexture, uv + vec2( 0.0, -texel.y)) + - texture(colorTexture, uv + vec2( texel.x, -texel.y)) + - texture(colorTexture, uv + vec2(-texel.x, 0.0)) + - center + - texture(colorTexture, uv + vec2( texel.x, 0.0)) + - texture(colorTexture, uv + vec2(-texel.x, texel.y)) + - texture(colorTexture, uv + vec2( 0.0, texel.y)) + - texture(colorTexture, uv + vec2( texel.x, texel.y)) - ) / 9.0; - vec4 sharpened = center + (center - blur) * amount; - out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a); - } -`; /** * Central UI orchestrator for the God's Eye View application. * * Responsibilities: - * - CesiumJS PostProcessStage pipeline: registers per-style GLSL stages - * (NVG, FLIR, CRT, anime, noir, snow) and manages intensity crossfades. + * - Visual controls and presets backed by the VisualEffects controller. * - Bloom and sharpen post-processing toggle/intensity control. * - Draggable/collapsible panel system with localStorage persistence, * z-order stacking, and viewport-clamped positioning. @@ -2190,26 +2058,24 @@ export class StyleManager { this.viewer = viewer; this.mapStackController = mapStackController; this.placeSearch = placeSearch; - this.stages = {}; + this._visualEffects = new VisualEffects({ + viewer, + requestRender: governorRequestRender, + holdRender: holdContinuousRender, + releaseRender: releaseContinuousRender, + }); this.activeStyle = 'normal'; document.documentElement.dataset.gevStyle = this.activeStyle; - this.transitions = new Map(); - this.startTime = Date.now(); // True once the user manually changes detection (button/key/slider/voice). // Gates per-style detection defaults so they never stomp an explicit choice. this._detectionUserOverridden = false; // Bloom/sharpen state - this.bloomEnabled = false; - this.sharpenEnabled = false; - this._bloomStage = null; - this._sharpenStage = null; this._recordingMode = false; this._recordingConfig = { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' }; this._preRecordingHudState = null; this._panelZCounter = PANEL_Z_BASE + 10; - this._animFrameId = null; this._lastLoadingFeedbackUpdateAt = 0; this._loadingFeedbackState = createLoadingFeedbackState(); this._loadingFeedbackEvent = null; @@ -2830,6 +2696,15 @@ export class StyleManager { ); } + // Compatibility reads for existing controls, scene snapshots and Cockpit. + get stages() { return this._visualEffects.stages; } + get transitions() { return this._visualEffects.transitions; } + get bloomEnabled() { return this._visualEffects.bloomEnabled; } + get sharpenEnabled() { return this._visualEffects.sharpenEnabled; } + get _bloomStage() { return this._visualEffects.bloomStage; } + get _sharpenStage() { return this._visualEffects.sharpenStage; } + + /** Advance camera authority and settle any older search UI immediately. */ _stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) { this._navigationGeneration += 1; @@ -3017,42 +2892,7 @@ export class StyleManager { * @returns {void} */ _initStages() { - for (const [name, shader] of Object.entries(STYLES)) { - const uniforms = { intensity: 0.0 }; - - // Auto-detect time uniform — animated shaders (CRT scanlines, snow, etc.) - // declare `uniform float time` and receive elapsed seconds each frame. - if (shader.fragmentShader.includes('uniform float time')) { - uniforms.time = 0.0; - } - - // Initialize custom uniforms from shader metadata (e.g. gain, pixelation) - if (shader.uniforms) { - for (const [uName, uMeta] of Object.entries(shader.uniforms)) { - uniforms[uName] = uMeta.default; - } - } - - const stage = new Cesium.PostProcessStage({ - name: `godsEyeView_${name}`, - fragmentShader: shader.fragmentShader, - uniforms, - }); - - // Zero-intensity stages are DISABLED (perf wave 1). History: the - // first attempt at this deleted the product's signature scope — the - // circular starfield mask was an EMERGENT artifact of these six - // stacked "identity" passes, not an implemented feature. The owner - // ruled to reimplement the scope explicitly (src/scopeMask.js, a - // featherable zero-per-frame canvas), which frees these passes for - // real. If the scope ever looks wrong, look there — not here. - stage.enabled = false; - this.viewer.scene.postProcessStages.add(stage); - this.stages[name] = stage; - } - // Frozen after init — cached so the per-frame animation loop doesn't - // rebuild Object.entries arrays every frame. - this._stageEntries = Object.entries(this.stages); + this._visualEffects.initStyles(); } /** @@ -3065,13 +2905,7 @@ export class StyleManager { * @returns {void} */ _setStageIntensity(stage, value) { - if (!stage) return; - stage.uniforms.intensity = value; - stage.enabled = value > 0.001; - // An animated shader becoming visible needs the style loop (its clock) - // running again; the loop self-stops when nothing visible animates. - if (stage.enabled && stage.uniforms.time !== undefined) this._startAnimationLoop(); - governorRequestRender('style-stage'); + this._visualEffects.setStageIntensity(stage, value); } /** @@ -3087,10 +2921,7 @@ export class StyleManager { * @returns {void} */ _syncStagesEnabledFromIntensity() { - if (!this.stages) return; - for (const stage of Object.values(this.stages)) { - this._setStageIntensity(stage, stage.uniforms.intensity); - } + this._visualEffects.syncStagesEnabledFromIntensity(); } /** @@ -3252,37 +3083,15 @@ export class StyleManager { * @returns {void} */ _initBloomSharpen() { - // Bloom — use Cesium's built-in bloom - this._bloomStage = this.viewer.scene.postProcessStages.bloom; - this._bloomStage.enabled = false; - this._bloomStage.uniforms.glowOnly = false; - this._bloomStage.uniforms.contrast = 256.0; - this._bloomStage.uniforms.brightness = -0.35; - this._bloomStage.uniforms.delta = 0.25; - this._bloomStage.uniforms.sigma = 0.35; - this._bloomStage.uniforms.stepSize = 1.0; - - // Sharpen — custom unsharp mask PostProcessStage - this._sharpenStage = new Cesium.PostProcessStage({ - name: 'godsEyeView_sharpen', - fragmentShader: SHARPEN_SHADER, - uniforms: { - amount: 1.3, - }, - }); - this._sharpenStage.enabled = false; - this.viewer.scene.postProcessStages.add(this._sharpenStage); - if (this._sharpenSlider) { - this._applySharpenIntensity(parseInt(this._sharpenSlider.value, 10) / 100); - } + this._visualEffects.initPostProcess(this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) / 100 : 0.6); } /** - * Reads the current bloom intensity percentage from the UI slider. + * Reads the current bloom intensity percentage from the effects controller. * @returns {number} Clamped bloom intensity (0-200). */ _getBloomIntensity() { - return clampBloomIntensity(parseInt(this._bloomSlider?.value || `${BLOOM_INTENSITY_DEFAULT}`, 10)); + return this._visualEffects.bloomIntensity; } /** @@ -3291,9 +3100,7 @@ export class StyleManager { * @returns {void} */ _syncBloomStageEnabled() { - if (!this._bloomStage) return; - const strength = bloomStrengthFromIntensity(this._getBloomIntensity()); - this._bloomStage.enabled = this.bloomEnabled && strength > 0.06; + this._visualEffects.syncBloomEnabled(); } /** @@ -3320,22 +3127,7 @@ export class StyleManager { * @returns {void} */ _applyBloomIntensity(intensity) { - if (!this._bloomStage) return; - const rawStrength = bloomStrengthFromIntensity(intensity); - // Dead-zone: strengths below 0.06 are imperceptible, clamp to zero. - const strength = rawStrength <= 0.06 ? 0.0 : ((rawStrength - 0.06) / 0.94); - // Smoothstep easing for perceptually linear bloom ramp - const eased = strength * strength * (3.0 - 2.0 * strength); - - // Mapping tuned for intuitive UX: - // 0 => effectively no glow, 200 => strong glow. - // Keep threshold strict at low values so only very bright highlights bloom. - 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._syncBloomStageEnabled(); + this._visualEffects.applyBloomIntensity(intensity); } /** @@ -3345,7 +3137,7 @@ export class StyleManager { */ _setBloomEnabled(enabled) { governorRequestRender('bloom'); - this.bloomEnabled = !!enabled; + this._visualEffects.setBloomEnabled(enabled); this._syncBloomStageEnabled(); this._bloomBtn.classList.toggle('active', this.bloomEnabled); this._bloomSliderRow.classList.toggle('visible', this.bloomEnabled); @@ -3363,9 +3155,7 @@ export class StyleManager { * @returns {void} */ _applySharpenIntensity(val) { - governorRequestRender('sharpen'); - if (!this._sharpenStage || !this._sharpenStage.uniforms) return; - this._sharpenStage.uniforms.amount = 0.1 + val * 2.0; + this._visualEffects.applySharpenIntensity(val); } /** @@ -3375,8 +3165,7 @@ export class StyleManager { */ _setSharpenEnabled(enabled) { governorRequestRender('sharpen'); - this.sharpenEnabled = !!enabled; - this._sharpenStage.enabled = this.sharpenEnabled; + this._visualEffects.setSharpenEnabled(enabled); this._sharpenBtn.classList.toggle('active', this.sharpenEnabled); if (this._sharpenSliderRow) { this._sharpenSliderRow.classList.toggle('visible', this.sharpenEnabled); @@ -8624,12 +8413,7 @@ export class StyleManager { * @returns {void} */ _startTransition(styleName, fromValue, toValue) { - this.transitions.set(styleName, { - start: performance.now(), - from: fromValue, - to: toValue, - }); - this._startAnimationLoop(); + this._visualEffects.startTransition(styleName, fromValue, toValue); } /** @@ -8699,53 +8483,7 @@ export class StyleManager { * interval (see _startTrafficChipTicker). */ _startAnimationLoop() { - if (this._animFrameId) return; // already running - const update = () => { - const now = performance.now(); - const elapsedSec = (Date.now() - this.startTime) / 1000.0; - - // Update transitions — interpolate each active crossfade - for (const [styleName, transition] of this.transitions) { - const elapsed = now - transition.start; - const t = Math.min(elapsed / TRANSITION_DURATION_MS, 1.0); - // Ease-in-out quadratic: smooth acceleration then deceleration - const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; - const value = transition.from + (transition.to - transition.from) * eased; - - this._setStageIntensity(this.stages[styleName], value); - - if (t >= 1.0) { - this._setStageIntensity(this.stages[styleName], transition.to); - this.transitions.delete(styleName); - } - } - - // Update time uniforms for animated shaders. Zero-intensity stages - // are disabled (see _initStages — the scope is now the explicit - // scopeMask canvas), so enabled === visible here; only these keep - // the loop and its continuous-render hold alive. - let animatedStageVisible = false; - for (const [, stage] of this._stageEntries) { - if (stage.enabled && stage.uniforms.time !== undefined) { - stage.uniforms.time = elapsedSec; - // Chain mode keeps zero-intensity stages ENABLED for pass parity — - // only a stage that is actually VISIBLE keeps the loop (and the - // continuous-render hold) alive, or a settled CRT session would - // hold the loop forever via an invisible snow stage. - if (stage.uniforms.intensity > 0.001) animatedStageVisible = true; - } - } - - const needed = this.transitions.size > 0 || animatedStageVisible; - if (needed) holdContinuousRender('style-anim'); - else releaseContinuousRender('style-anim'); - if (!needed) { - this._animFrameId = null; - return; // settled — the next transition/animated stage re-arms us - } - this._animFrameId = requestAnimationFrame(update); - }; - this._animFrameId = requestAnimationFrame(update); + this._visualEffects.startAnimationLoop(); } /** @@ -9663,6 +9401,7 @@ export class StyleManager { this._disposed = true; this._applicationShortcuts?.destroy(); this._displayControls?.destroy(); + this._visualEffects.stop(); this._styleParameters?.destroy(); for (const control of this._panelDisclosureControls || []) control.destroy(); this._panelDisclosureControls = []; @@ -9777,13 +9516,8 @@ export class StyleManager { document.removeEventListener('keydown', this._poiKeydownHandler); this._poiKeydownHandler = null; } - // Cancel the rAF animation loop and release its governor hold; also stop - // the traffic-chip ticker the loop no longer carries. (perf wave 2 fix) - if (this._animFrameId) { - cancelAnimationFrame(this._animFrameId); - this._animFrameId = null; - } - releaseContinuousRender('style-anim'); + // Stop the independently owned traffic and loading feedback tickers. + if (this._trafficChipTicker) { clearInterval(this._trafficChipTicker); this._trafficChipTicker = null; @@ -9847,7 +9581,6 @@ export class StyleManager { destroyTrackedReadout(); destroyDetection(); destroyWorldOverlay(); - // Clear transitions - this.transitions.clear(); + this._visualEffects.destroy(); } } diff --git a/src/ui/effects.js b/src/ui/effects.js new file mode 100644 index 000000000..d215f3500 --- /dev/null +++ b/src/ui/effects.js @@ -0,0 +1,2 @@ +export { VisualEffects } from './visualEffects.js'; +export { STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './visualPresets.js'; diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js new file mode 100644 index 000000000..b0b54ba0b --- /dev/null +++ b/src/ui/visualEffects.js @@ -0,0 +1,182 @@ +import { PostProcessStage } from 'cesium'; +import { bloomStrengthFromIntensity, clampBloomIntensity, BLOOM_INTENSITY_DEFAULT } from '../bloom.js'; +import { STYLES, SHARPEN_SHADER, TRANSITION_DURATION_MS } from './visualPresets.js'; + +/** Own the post-process stages and their animation, without DOM dependencies. */ +export class VisualEffects { + /** + * @param {object} options - Viewer, render ownership callbacks and optional clocks. + * Construction is inert; initStyles/initPostProcess retain startup ordering. + */ + constructor({ viewer, requestRender, holdRender, releaseRender, + requestFrame = (callback) => requestAnimationFrame(callback), + cancelFrame = (id) => cancelAnimationFrame(id), + now = () => performance.now(), wallNow = () => Date.now(), + createStage = (options) => new PostProcessStage(options), + }) { + this.viewer = viewer; + this.requestRender = requestRender; + this.holdRender = holdRender; + this.releaseRender = releaseRender; + 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.frameId = null; + this.stopped = false; + this.destroyed = false; + this.stageEntries = []; + this.previousBloom = null; + } + + 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; + } + this.stageEntries = Object.entries(this.stages); + } + + 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 }); + 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.applySharpenIntensity(sharpenIntensity); + } + + setStageIntensity(stage, value) { + if (this.stopped || !stage) return; + stage.uniforms.intensity = value; + stage.enabled = value > 0.001; + if (stage.enabled && stage.uniforms.time !== undefined) this.startAnimationLoop(); + this.requestRender('style-stage'); + } + + syncStagesEnabledFromIntensity() { + for (const [, stage] of this.stageEntries) this.setStageIntensity(stage, stage.uniforms.intensity); + } + + syncBloomEnabled() { + if (this.stopped || !this.bloomStage) return; + this.bloomStage.enabled = this.bloomEnabled && bloomStrengthFromIntensity(this.bloomIntensity) > 0.06; + } + + 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(); + } + + setBloomEnabled(enabled) { + if (this.stopped) return; + this.bloomEnabled = !!enabled; + this.syncBloomEnabled(); + this.requestRender('bloom'); + } + + applySharpenIntensity(value) { + if (this.stopped) return; + this.sharpenIntensity = value; + if (this.sharpenStage) this.sharpenStage.uniforms.amount = 0.1 + value * 2.0; + this.requestRender('sharpen'); + } + + setSharpenEnabled(enabled) { + if (this.stopped) return; + this.sharpenEnabled = !!enabled; + if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled; + this.requestRender('sharpen'); + } + + startTransition(styleName, from, to) { + if (this.stopped) return; + this.transitions.set(styleName, { start: this.now(), from, to }); + this.startAnimationLoop(); + } + + 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, 1.0); + const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + this.setStageIntensity(this.stages[name], transition.from + (transition.to - transition.from) * eased); + if (t >= 1.0) { + this.setStageIntensity(this.stages[name], transition.to); + 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; + if (needed) this.holdRender('style-anim'); + else this.releaseRender('style-anim'); + this.frameId = needed ? this.requestFrame(update) : null; + }; + this.frameId = this.requestFrame(update); + } + + /** Revoke animation synchronously while callers finish releasing the stages. */ + stop() { + if (this.stopped) return; + this.stopped = true; + if (this.frameId !== null) this.cancelFrame(this.frameId); + this.frameId = null; + this.releaseRender('style-anim'); + this.transitions.clear(); + } + + /** Remove owned stages and restore the borrowed bloom stage after callers release it. */ + 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; + } + } +} diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs new file mode 100644 index 000000000..f28288e52 --- /dev/null +++ b/src/ui/visualEffects.test.mjs @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { VisualEffects } from './visualEffects.js'; +import { GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './visualPresets.js'; + +function fixture() { + let time = 0; + let next = 0; + const frames = new Map(); + const stages = new Set(); + const holds = new Set(); + const requests = []; + const bloom = { enabled: true, uniforms: { glowOnly: true, contrast: 3, brightness: 4, delta: 5, sigma: 6, stepSize: 7 } }; + const originalBloom = structuredClone(bloom); + const pipeline = { bloom, add(stage) { stages.add(stage); }, remove(stage) { return stages.delete(stage); } }; + const effects = new VisualEffects({ + viewer: { scene: { postProcessStages: pipeline } }, + requestRender: (reason) => requests.push(reason), + holdRender: (reason) => holds.add(reason), releaseRender: (reason) => holds.delete(reason), + requestFrame: (callback) => { const id = next++; frames.set(id, callback); return id; }, + cancelFrame: (id) => frames.delete(id), now: () => time, wallNow: () => 10_000 + time, + createStage: (options) => ({ ...options, enabled: true }), + }); + function tick(value) { + time = value; + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(); + } + return { effects, frames, stages, holds, requests, bloom, originalBloom, tick }; +} + +test('construction is inert and initialization creates one owned pipeline', () => { + const f = fixture(); + assert.equal(f.stages.size, 0); + assert.equal(f.frames.size, 0); + f.effects.initStyles(); + f.effects.initPostProcess(); + assert.equal(f.stages.size, 7); + assert.ok(Object.values(f.effects.stages).every((stage) => !stage.enabled && stage.uniforms.intensity === 0)); + f.effects.initStyles(); + f.effects.initPostProcess(); + assert.equal(f.stages.size, 7); + f.effects.destroy(); +}); + +test('crossfade uses the established 500ms easing and settles without idle frames', () => { + const f = fixture(); + f.effects.initStyles(); + f.effects.startTransition('noir', 0, 1); + assert.equal(f.effects.frameId, 0, 'zero is a valid pending animation id'); + f.effects.startAnimationLoop(); + assert.equal(f.frames.size, 1, 'starting twice cannot schedule duplicate frames'); + f.tick(125); + assert.equal(f.effects.stages.noir.uniforms.intensity, 0.125); + f.tick(250); + assert.equal(f.effects.stages.noir.uniforms.intensity, 0.5); + f.tick(500); + assert.equal(f.effects.stages.noir.uniforms.intensity, 1); + assert.equal(f.effects.transitions.size, 0); + // Noir's shader has no animated time uniform. + assert.equal(f.frames.size, 0); + assert.equal(f.holds.size, 0); + f.effects.destroy(); +}); + +test('a superseding transition starts from the current rendered intensity', () => { + const f = fixture(); + f.effects.initStyles(); + f.effects.startTransition('noir', 0, 1); + f.tick(250); + f.effects.startTransition('noir', f.effects.stages.noir.uniforms.intensity, 0); + f.tick(500); + assert.equal(f.effects.stages.noir.uniforms.intensity, 0.25); + f.tick(750); + assert.equal(f.effects.stages.noir.uniforms.intensity, 0); + assert.equal(f.effects.stages.noir.enabled, false); + assert.equal(f.frames.size, 0); + f.effects.destroy(); +}); + +test('only visible animated stages keep the animation clock and render 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); + f.effects.setStageIntensity(animated, 0); + f.tick(1100); + assert.equal(f.frames.size, 0); + assert.equal(f.holds.size, 0); + 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); + f.effects.stages.thermal.uniforms.intensity = 0; + f.effects.syncStagesEnabledFromIntensity(); + assert.equal(f.effects.stages.thermal.enabled, false); + f.effects.destroy(); +}); + +test('bloom retains its dead zone, normalized intensity and independent toggle', () => { + const f = fixture(); + f.effects.initPostProcess(); + f.effects.setBloomEnabled(true); + f.effects.applyBloomIntensity(0); + assert.equal(f.bloom.enabled, false); + f.effects.applyBloomIntensity(200); + assert.equal(f.bloom.enabled, true); + assert.equal(f.bloom.uniforms.contrast, 87); + f.effects.setBloomEnabled(false); + assert.equal(f.effects.bloomIntensity, 200); + assert.equal(f.bloom.enabled, false); + f.effects.applyBloomIntensity(300); + assert.equal(f.effects.bloomIntensity, 200); + f.effects.destroy(); +}); + +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); + f.effects.applySharpenIntensity(1); + f.effects.setSharpenEnabled(true); + assert.equal(f.effects.sharpenStage.uniforms.amount, 2.1); + assert.equal(f.effects.sharpenStage.enabled, true); + f.effects.setSharpenEnabled(false); + assert.equal(f.effects.sharpenIntensity, 1); + assert.equal(f.effects.sharpenStage.enabled, false); + f.effects.destroy(); +}); + +test('stop revokes pending work immediately but retains stages until final destruction', () => { + const f = fixture(); + f.effects.initStyles(); + f.effects.initPostProcess(); + f.effects.startTransition('retro', 0, 1); + const staleFrame = [...f.frames.values()][0]; + f.effects.stop(); + assert.equal(f.frames.size, 0); + assert.equal(f.stages.size, 7, 'Context and Cockpit may still be releasing these stages'); + const intensity = f.effects.stages.retro.uniforms.intensity; + staleFrame(); + f.effects.startTransition('retro', 0, 1); + f.effects.setStageIntensity(f.effects.stages.retro, 1); + assert.equal(f.frames.size, 0); + assert.equal(f.effects.stages.retro.uniforms.intensity, intensity); + f.effects.destroy(); + assert.equal(f.stages.size, 0); + assert.deepEqual(f.bloom, f.originalBloom, 'borrowed bloom configuration is restored'); + f.effects.destroy(); +}); + +test('destroying one instance does not remove another pipeline or clock', () => { + const a = fixture(); + const b = fixture(); + for (const f of [a, b]) { f.effects.initStyles(); f.effects.startTransition('retro', 0, 1); } + a.effects.destroy(); + b.tick(250); + assert.equal(a.stages.size, 0); + assert.equal(b.stages.size, 6); + assert.equal(b.effects.stages.retro.uniforms.intensity, 0.5); + b.effects.destroy(); +}); + +test('existing baseline and military presets keep one detection default', () => { + assert.equal(GLOBAL_POST_DEFAULTS.detectionMode, 'DENSE'); + assert.equal(GLOBAL_POST_DEFAULTS.detectionDensity, 75); + assert.equal(GLOBAL_POST_DEFAULTS.sharpen.intensity, 49); + assert.equal(GLOBAL_POST_DEFAULTS.detectionFadePct, 7); + assert.equal(GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct, 1); + for (const name of ['retro', 'surveillance', 'thermal']) { + assert.equal(STYLE_PRESET_DEFAULTS[name].detection, MILITARY_DETECTION_PRESET); + } + assert.equal(STYLE_PRESET_DEFAULTS.normal, undefined); +}); diff --git a/src/ui/visualPresets.js b/src/ui/visualPresets.js new file mode 100644 index 000000000..a1415efcc --- /dev/null +++ b/src/ui/visualPresets.js @@ -0,0 +1,135 @@ +import { retroShader } from '../styles/retro.js'; +import { animeShader } from '../styles/anime.js'; +import { noirShader } from '../styles/noir.js'; +import { snowShader } from '../styles/snow.js'; +import { nightVisionShader } from '../styles/surveillance.js'; +import { thermalShader } from '../styles/thermal.js'; +import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js'; + +/** Duration (ms) for shader intensity crossfade between style presets. */ +export const TRANSITION_DURATION_MS = 500; +/** Map of style name to its GLSL shader module for post-process stages. */ +export const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader }; + +/** + * The tactical detection look: Dense at 75%. + * + * Owner playtest 2026-08-18: "detection mode… 75% weighted, with the 16% fade + * and 5% outside, whatever we had. I want that as the default. It should just + * happen." Fade and outside opacity live in GLOBAL_POST_DEFAULTS, so "whatever + * we had" still needs nothing here — but they are 7% and 1% now, the outside + * default having moved 5 → 3 → 1 as the owner locked final tuning after field trials (2026-08-24). What the quote asked for is the + * baseline of the day, not the two numbers it happened to name. + * + * ONE object, shared by the first-load baseline below, by every military style, + * AND by the Contacts context mode (which OWNS detection while active and + * restores the prior state on exit — see contactsDetectionPolicy.js). Cockpit + * deliberately does NOT touch detection: entering it with SPARSE selected leaves + * SPARSE. Declared ahead of GLOBAL_POST_DEFAULTS because that baseline now reads + * from it. + */ +export const MILITARY_DETECTION_PRESET = Object.freeze({ mode: 'dense', densityPct: 75 }); + +/** Baseline post-processing settings applied on first load (before share-link restore). */ +export const GLOBAL_POST_DEFAULTS = { + bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, + sharpen: { enabled: true, intensity: 49 }, + hudVariant: 'tactical', + hudVisible: true, + // Detection is ON for EVERY style on a first run, Normal included (owner + // directive 2026-08-22: "detect should also be on by default"). It is the + // same preset object the military styles and Contacts already apply, so there + // is one tactical look, not several that can drift. + // + // This is a first-LOAD baseline, not an override: `_applyGlobalPostDefaults` + // runs before any share-link restore, so a link's `dm`/`dd` still lands on top + // of it. It also deliberately leaves `_detectionUserOverridden` alone — the + // flag means the OPERATOR hand-edited detection, and a factory default is not + // that. Turning detection off by hand therefore still sets the flag and still + // suppresses the military-style auto-enable for the rest of the session. + detectionMode: MILITARY_DETECTION_PRESET.mode.toUpperCase(), + detectionDensity: MILITARY_DETECTION_PRESET.densityPct, + detectionAllocation: 'ELASTIC', + detectionFadePct: 7, + detectionOutsideOpacityPct: 1, + celestialRing: false, +}; + +// Tactical style defaults applied when users select military style presets. +export const STYLE_PRESET_DEFAULTS = { + retro: { + bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, + sharpen: { enabled: true, intensity: 49 }, + styleParams: { + retro: { + pixelation: 1.0, + distortion: 0, + instability: 0.42, + }, + }, + hudVariant: 'tactical', + hudVisible: true, + detection: MILITARY_DETECTION_PRESET, + }, + surveillance: { + bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, + sharpen: { enabled: true, intensity: 49 }, + styleParams: { + surveillance: { + gain: 0.18, + bloom: 0.22, + scanlineStr: 0.96, + pixelation: 1.0, + }, + }, + hudVariant: 'tactical', + hudVisible: true, + detection: MILITARY_DETECTION_PRESET, + }, + thermal: { + bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, + sharpen: { enabled: true, intensity: 49 }, + styleParams: { + thermal: { + sensitivity: 0.85, + bloom: 0.2, + mode: 0.33, + pixelation: 1.0, + }, + }, + hudVariant: 'tactical', + hudVisible: true, + detection: MILITARY_DETECTION_PRESET, + }, +}; + +/** + * GLSL fragment shader implementing an unsharp-mask sharpening filter. + * Samples a 3x3 neighborhood, computes box blur, then adds the + * difference (center - blur) scaled by `amount` for edge enhancement. + */ +export const SHARPEN_SHADER = /* glsl */ ` + uniform sampler2D colorTexture; + uniform vec2 colorTextureDimensions; + uniform float amount; + in vec2 v_textureCoordinates; + + void main() { + vec2 uv = v_textureCoordinates; + vec2 texel = 1.0 / colorTextureDimensions; + vec4 center = texture(colorTexture, uv); + vec4 blur = ( + texture(colorTexture, uv + vec2(-texel.x, -texel.y)) + + texture(colorTexture, uv + vec2( 0.0, -texel.y)) + + texture(colorTexture, uv + vec2( texel.x, -texel.y)) + + texture(colorTexture, uv + vec2(-texel.x, 0.0)) + + center + + texture(colorTexture, uv + vec2( texel.x, 0.0)) + + texture(colorTexture, uv + vec2(-texel.x, texel.y)) + + texture(colorTexture, uv + vec2( 0.0, texel.y)) + + texture(colorTexture, uv + vec2( texel.x, texel.y)) + ) / 9.0; + vec4 sharpened = center + (center - blur) * amount; + out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a); + } +`;