Merge pull request #393 from bilawalsidhu/refactor/visual-effects

refactor(ui): separate visual effects from Display controls
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-12 22:04:43 -07:00
commit 78199c2ab3
18 files changed
+972 -312

No files matched your search

+2
View File
@@ -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
+10
View File
@@ -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.
+8
View File
@@ -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.
+3 -1
View File
@@ -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"
}
}
+13 -1
View File
@@ -136,5 +136,17 @@
"src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs",
"src/ui/displayControls.js",
"src/ui/displayControls.test.mjs"
"src/ui/displayControls.test.mjs",
"src/ui/effects.js",
"src/ui/visualEffects.js",
"src/ui/visualPresets.js",
"src/ui/visualEffects.test.mjs",
"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",
"scripts/qa-visual-effects.mjs"
]
+16
View File
@@ -299,5 +299,21 @@
"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"]
}
}
+228
View File
@@ -0,0 +1,228 @@
#!/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;
+9 -2
View File
@@ -43,7 +43,11 @@ export function clampBloomIntensity(value) {
* @returns {number} equivalent v2 intensity
*/
export function legacyBloomToV2(value) {
const legacy = clamp(Math.round(Number(value) || 0), 0, LEGACY_BLOOM_INTENSITY_MAX);
const legacy = clamp(
Math.round(Number(value) || 0),
0,
LEGACY_BLOOM_INTENSITY_MAX,
);
return clampBloomIntensity((LEGACY_BLOOM_INTENSITY_MAX - legacy) * 2);
}
@@ -59,7 +63,10 @@ export function decodeBloomIntensity(value, version = 1) {
if (!Number.isFinite(num)) return BLOOM_INTENSITY_DEFAULT;
const normalizedVersion = Number(version);
if (Number.isFinite(normalizedVersion) && normalizedVersion >= BLOOM_SCALE_VERSION) {
if (
Number.isFinite(normalizedVersion) &&
normalizedVersion >= BLOOM_SCALE_VERSION
) {
return clampBloomIntensity(num);
}
+4 -2
View File
@@ -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]) };
+3 -2
View File
@@ -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(
+4 -2
View File
@@ -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\(\),/,
+1
View File
@@ -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\(\)/);
});
+1 -1
View File
@@ -13,7 +13,7 @@ export const nightVisionShader = {
name: 'surveillance',
uniforms: {
gain: { default: 0.55, min: 0, max: 1, label: 'Gain' },
bloom: { default: 0.30, min: 0, max: 1, label: 'Bloom' },
bloom: { default: 0.3, min: 0, max: 1, label: 'Bloom' },
scanlineStr: { default: 1.0, min: 0, max: 1, label: 'Scanlines' },
pixelation: { default: 2.5, min: 1, max: 6, label: 'Pixelation' },
},
+34 -301
View File
@@ -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();
}
}
+7
View File
@@ -0,0 +1,7 @@
export { VisualEffects } from './visualEffects.js';
export {
STYLES,
GLOBAL_POST_DEFAULTS,
STYLE_PRESET_DEFAULTS,
MILITARY_DETECTION_PRESET,
} from './visualPresets.js';
+235
View File
@@ -0,0 +1,235 @@
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;
}
}
}
+249
View File
@@ -0,0 +1,249 @@
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);
});
+145
View File
@@ -0,0 +1,145 @@
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);
}
`;