refactor(ui): separate visual effects and animation ownership

This commit is contained in:
Sameh Khamis committed 2026-09-12 21:55:18 -07:00
1 parent 5f60d86bc9
commit c30f7cc3be
15 files changed
+684 -308

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
export { VisualEffects } from './visualEffects.js';
export { STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './visualPresets.js';
+182
View File
@@ -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;
}
}
}
+185
View File
@@ -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);
});
+135
View File
@@ -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);
}
`;