mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor(ui): separate visual effects and animation ownership
This commit is contained in:
1 parent
5f60d86bc9
commit
c30f7cc3be
15 files changed
+684
-308
No files matched your search
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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 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
|
- Extract application shortcuts and shader-parameter controls into reusable UI
|
||||||
|
|||||||
@@ -250,3 +250,13 @@ facade's asynchronous teardown can yield.
|
|||||||
DOM elements and explicit actions, imports no application or effect singleton,
|
DOM elements and explicit actions, imports no application or effect singleton,
|
||||||
and releases every listener on destruction. Settings and rendering remain with
|
and releases every listener on destruction. Settings and rendering remain with
|
||||||
the caller.
|
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.
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# God's Eye View Current State
|
# 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 control ownership
|
||||||
|
|
||||||
Display button, selector and slider listeners have a single destroyable owner.
|
Display button, selector and slider listeners have a single destroyable owner.
|
||||||
|
|||||||
+3
-1
@@ -123,6 +123,8 @@
|
|||||||
"./ui/surfaces": "./src/ui/surfaceKeyboard.js",
|
"./ui/surfaces": "./src/ui/surfaceKeyboard.js",
|
||||||
"./ui/layout": "./src/ui/panelRails.js",
|
"./ui/layout": "./src/ui/panelRails.js",
|
||||||
"./ui/input": "./src/ui/visualInput.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,5 +299,10 @@
|
|||||||
"exports": ["./ui/display"],
|
"exports": ["./ui/display"],
|
||||||
"modules": ["src/ui/displayControls.js"],
|
"modules": ["src/ui/displayControls.js"],
|
||||||
"external": []
|
"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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -26,7 +26,9 @@ import {
|
|||||||
} from './data/detection.js';
|
} from './data/detection.js';
|
||||||
import { canonicalizeDensity } from './data/detectionPolicy.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
|
* 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 MILITARY_PRESET = (() => {
|
||||||
const match = uiSource.match(
|
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');
|
assert.ok(match, 'ui.js must expose one shared military detection preset');
|
||||||
return { mode: match[1].toUpperCase(), densityPct: Number(match[2]) };
|
return { mode: match[1].toUpperCase(), densityPct: Number(match[2]) };
|
||||||
|
|||||||
@@ -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', () => {
|
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 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, /createElement\(\s*['"]canvas['"]\s*\)/);
|
||||||
assert.doesNotMatch(source, /postRender\.addEventListener/);
|
assert.doesNotMatch(source, /postRender\.addEventListener/);
|
||||||
assert.doesNotMatch(source, /['"]detection-overlay['"]/);
|
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.
|
// three times" — same guarantee, and the copies can no longer drift.
|
||||||
assert.match(
|
assert.match(
|
||||||
uiSource,
|
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%',
|
'the tactical detection default is still Dense @ 75%',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ import {
|
|||||||
} from './scopeMask.js';
|
} from './scopeMask.js';
|
||||||
import { ShareLinkManager } from './sharelink.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 indexHtml = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||||
const shareSource = fs.readFileSync(new URL('./sharelink.js', 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.
|
// 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
|
// It is now the baseline for all of them, reusing the SAME frozen object, so
|
||||||
// "the tactical look" cannot fork into two definitions.
|
// "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%');
|
'the tactical look is still Dense @ 75%');
|
||||||
const baseline = uiBlock('const GLOBAL_POST_DEFAULTS = {', '\n};');
|
const baseline = uiBlock('const GLOBAL_POST_DEFAULTS = {', '\n};');
|
||||||
assert.match(baseline, /detectionMode: MILITARY_DETECTION_PRESET\.mode\.toUpperCase\(\),/,
|
assert.match(baseline, /detectionMode: MILITARY_DETECTION_PRESET\.mode\.toUpperCase\(\),/,
|
||||||
|
|||||||
@@ -690,6 +690,7 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
|
|||||||
assert.ok(firstAwait > 0);
|
assert.ok(firstAwait > 0);
|
||||||
const synchronous = disposal.slice(0, firstAwait);
|
const synchronous = disposal.slice(0, firstAwait);
|
||||||
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
|
||||||
|
assert.match(synchronous, /this\._visualEffects\.stop\(\)/);
|
||||||
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
|
||||||
});
|
});
|
||||||
@@ -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 { bindDisplayControls } from './ui/displayControls.js';
|
||||||
import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js';
|
import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js';
|
||||||
import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
|
import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
|
||||||
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
|
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
|
||||||
import * as Cesium from 'cesium';
|
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 {
|
import {
|
||||||
BLOOM_INTENSITY_DEFAULT,
|
BLOOM_INTENSITY_DEFAULT,
|
||||||
BLOOM_SCALE_VERSION,
|
BLOOM_SCALE_VERSION,
|
||||||
bloomStrengthFromIntensity,
|
|
||||||
clampBloomIntensity,
|
clampBloomIntensity,
|
||||||
decodeBloomIntensity,
|
decodeBloomIntensity,
|
||||||
} from './bloom.js';
|
} from './bloom.js';
|
||||||
@@ -192,10 +186,6 @@ import {
|
|||||||
speedRulerTicks,
|
speedRulerTicks,
|
||||||
} from './cockpitMath.js';
|
} 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. */
|
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
|
||||||
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
||||||
const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
||||||
@@ -361,135 +351,13 @@ const STYLE_STATUS_LABELS = {
|
|||||||
noir: 'NOIR',
|
noir: 'NOIR',
|
||||||
snow: 'SNOW',
|
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.
|
* Central UI orchestrator for the God's Eye View application.
|
||||||
*
|
*
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* - CesiumJS PostProcessStage pipeline: registers per-style GLSL stages
|
* - Visual controls and presets backed by the VisualEffects controller.
|
||||||
* (NVG, FLIR, CRT, anime, noir, snow) and manages intensity crossfades.
|
|
||||||
* - Bloom and sharpen post-processing toggle/intensity control.
|
* - Bloom and sharpen post-processing toggle/intensity control.
|
||||||
* - Draggable/collapsible panel system with localStorage persistence,
|
* - Draggable/collapsible panel system with localStorage persistence,
|
||||||
* z-order stacking, and viewport-clamped positioning.
|
* z-order stacking, and viewport-clamped positioning.
|
||||||
@@ -2190,26 +2058,24 @@ export class StyleManager {
|
|||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
this.mapStackController = mapStackController;
|
this.mapStackController = mapStackController;
|
||||||
this.placeSearch = placeSearch;
|
this.placeSearch = placeSearch;
|
||||||
this.stages = {};
|
this._visualEffects = new VisualEffects({
|
||||||
|
viewer,
|
||||||
|
requestRender: governorRequestRender,
|
||||||
|
holdRender: holdContinuousRender,
|
||||||
|
releaseRender: releaseContinuousRender,
|
||||||
|
});
|
||||||
this.activeStyle = 'normal';
|
this.activeStyle = 'normal';
|
||||||
document.documentElement.dataset.gevStyle = this.activeStyle;
|
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).
|
// True once the user manually changes detection (button/key/slider/voice).
|
||||||
// Gates per-style detection defaults so they never stomp an explicit choice.
|
// Gates per-style detection defaults so they never stomp an explicit choice.
|
||||||
this._detectionUserOverridden = false;
|
this._detectionUserOverridden = false;
|
||||||
|
|
||||||
// Bloom/sharpen state
|
// Bloom/sharpen state
|
||||||
this.bloomEnabled = false;
|
|
||||||
this.sharpenEnabled = false;
|
|
||||||
this._bloomStage = null;
|
|
||||||
this._sharpenStage = null;
|
|
||||||
this._recordingMode = false;
|
this._recordingMode = false;
|
||||||
this._recordingConfig = { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' };
|
this._recordingConfig = { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' };
|
||||||
this._preRecordingHudState = null;
|
this._preRecordingHudState = null;
|
||||||
this._panelZCounter = PANEL_Z_BASE + 10;
|
this._panelZCounter = PANEL_Z_BASE + 10;
|
||||||
this._animFrameId = null;
|
|
||||||
this._lastLoadingFeedbackUpdateAt = 0;
|
this._lastLoadingFeedbackUpdateAt = 0;
|
||||||
this._loadingFeedbackState = createLoadingFeedbackState();
|
this._loadingFeedbackState = createLoadingFeedbackState();
|
||||||
this._loadingFeedbackEvent = null;
|
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. */
|
/** Advance camera authority and settle any older search UI immediately. */
|
||||||
_stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) {
|
_stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) {
|
||||||
this._navigationGeneration += 1;
|
this._navigationGeneration += 1;
|
||||||
@@ -3017,42 +2892,7 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_initStages() {
|
_initStages() {
|
||||||
for (const [name, shader] of Object.entries(STYLES)) {
|
this._visualEffects.initStyles();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3065,13 +2905,7 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_setStageIntensity(stage, value) {
|
_setStageIntensity(stage, value) {
|
||||||
if (!stage) return;
|
this._visualEffects.setStageIntensity(stage, value);
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3087,10 +2921,7 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_syncStagesEnabledFromIntensity() {
|
_syncStagesEnabledFromIntensity() {
|
||||||
if (!this.stages) return;
|
this._visualEffects.syncStagesEnabledFromIntensity();
|
||||||
for (const stage of Object.values(this.stages)) {
|
|
||||||
this._setStageIntensity(stage, stage.uniforms.intensity);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3252,37 +3083,15 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_initBloomSharpen() {
|
_initBloomSharpen() {
|
||||||
// Bloom — use Cesium's built-in bloom
|
this._visualEffects.initPostProcess(this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) / 100 : 0.6);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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).
|
* @returns {number} Clamped bloom intensity (0-200).
|
||||||
*/
|
*/
|
||||||
_getBloomIntensity() {
|
_getBloomIntensity() {
|
||||||
return clampBloomIntensity(parseInt(this._bloomSlider?.value || `${BLOOM_INTENSITY_DEFAULT}`, 10));
|
return this._visualEffects.bloomIntensity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3291,9 +3100,7 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_syncBloomStageEnabled() {
|
_syncBloomStageEnabled() {
|
||||||
if (!this._bloomStage) return;
|
this._visualEffects.syncBloomEnabled();
|
||||||
const strength = bloomStrengthFromIntensity(this._getBloomIntensity());
|
|
||||||
this._bloomStage.enabled = this.bloomEnabled && strength > 0.06;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3320,22 +3127,7 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_applyBloomIntensity(intensity) {
|
_applyBloomIntensity(intensity) {
|
||||||
if (!this._bloomStage) return;
|
this._visualEffects.applyBloomIntensity(intensity);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3345,7 +3137,7 @@ export class StyleManager {
|
|||||||
*/
|
*/
|
||||||
_setBloomEnabled(enabled) {
|
_setBloomEnabled(enabled) {
|
||||||
governorRequestRender('bloom');
|
governorRequestRender('bloom');
|
||||||
this.bloomEnabled = !!enabled;
|
this._visualEffects.setBloomEnabled(enabled);
|
||||||
this._syncBloomStageEnabled();
|
this._syncBloomStageEnabled();
|
||||||
this._bloomBtn.classList.toggle('active', this.bloomEnabled);
|
this._bloomBtn.classList.toggle('active', this.bloomEnabled);
|
||||||
this._bloomSliderRow.classList.toggle('visible', this.bloomEnabled);
|
this._bloomSliderRow.classList.toggle('visible', this.bloomEnabled);
|
||||||
@@ -3363,9 +3155,7 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_applySharpenIntensity(val) {
|
_applySharpenIntensity(val) {
|
||||||
governorRequestRender('sharpen');
|
this._visualEffects.applySharpenIntensity(val);
|
||||||
if (!this._sharpenStage || !this._sharpenStage.uniforms) return;
|
|
||||||
this._sharpenStage.uniforms.amount = 0.1 + val * 2.0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3375,8 +3165,7 @@ export class StyleManager {
|
|||||||
*/
|
*/
|
||||||
_setSharpenEnabled(enabled) {
|
_setSharpenEnabled(enabled) {
|
||||||
governorRequestRender('sharpen');
|
governorRequestRender('sharpen');
|
||||||
this.sharpenEnabled = !!enabled;
|
this._visualEffects.setSharpenEnabled(enabled);
|
||||||
this._sharpenStage.enabled = this.sharpenEnabled;
|
|
||||||
this._sharpenBtn.classList.toggle('active', this.sharpenEnabled);
|
this._sharpenBtn.classList.toggle('active', this.sharpenEnabled);
|
||||||
if (this._sharpenSliderRow) {
|
if (this._sharpenSliderRow) {
|
||||||
this._sharpenSliderRow.classList.toggle('visible', this.sharpenEnabled);
|
this._sharpenSliderRow.classList.toggle('visible', this.sharpenEnabled);
|
||||||
@@ -8624,12 +8413,7 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_startTransition(styleName, fromValue, toValue) {
|
_startTransition(styleName, fromValue, toValue) {
|
||||||
this.transitions.set(styleName, {
|
this._visualEffects.startTransition(styleName, fromValue, toValue);
|
||||||
start: performance.now(),
|
|
||||||
from: fromValue,
|
|
||||||
to: toValue,
|
|
||||||
});
|
|
||||||
this._startAnimationLoop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8699,53 +8483,7 @@ export class StyleManager {
|
|||||||
* interval (see _startTrafficChipTicker).
|
* interval (see _startTrafficChipTicker).
|
||||||
*/
|
*/
|
||||||
_startAnimationLoop() {
|
_startAnimationLoop() {
|
||||||
if (this._animFrameId) return; // already running
|
this._visualEffects.startAnimationLoop();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9663,6 +9401,7 @@ export class StyleManager {
|
|||||||
this._disposed = true;
|
this._disposed = true;
|
||||||
this._applicationShortcuts?.destroy();
|
this._applicationShortcuts?.destroy();
|
||||||
this._displayControls?.destroy();
|
this._displayControls?.destroy();
|
||||||
|
this._visualEffects.stop();
|
||||||
this._styleParameters?.destroy();
|
this._styleParameters?.destroy();
|
||||||
for (const control of this._panelDisclosureControls || []) control.destroy();
|
for (const control of this._panelDisclosureControls || []) control.destroy();
|
||||||
this._panelDisclosureControls = [];
|
this._panelDisclosureControls = [];
|
||||||
@@ -9777,13 +9516,8 @@ export class StyleManager {
|
|||||||
document.removeEventListener('keydown', this._poiKeydownHandler);
|
document.removeEventListener('keydown', this._poiKeydownHandler);
|
||||||
this._poiKeydownHandler = null;
|
this._poiKeydownHandler = null;
|
||||||
}
|
}
|
||||||
// Cancel the rAF animation loop and release its governor hold; also stop
|
// Stop the independently owned traffic and loading feedback tickers.
|
||||||
// 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');
|
|
||||||
if (this._trafficChipTicker) {
|
if (this._trafficChipTicker) {
|
||||||
clearInterval(this._trafficChipTicker);
|
clearInterval(this._trafficChipTicker);
|
||||||
this._trafficChipTicker = null;
|
this._trafficChipTicker = null;
|
||||||
@@ -9847,7 +9581,6 @@ export class StyleManager {
|
|||||||
destroyTrackedReadout();
|
destroyTrackedReadout();
|
||||||
destroyDetection();
|
destroyDetection();
|
||||||
destroyWorldOverlay();
|
destroyWorldOverlay();
|
||||||
// Clear transitions
|
this._visualEffects.destroy();
|
||||||
this.transitions.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export { VisualEffects } from './visualEffects.js';
|
||||||
|
export { STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './visualPresets.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
Reference in new issue
Block a user