style: format visual effects and shader definitions

This commit is contained in:
Sameh Khamis committed 2026-09-12 21:55:18 -07:00
1 parent c30f7cc3be
commit 1bbb6d9c70
8 files changed
+356 -83

No files matched your search

+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);
}
+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' },
},
+6 -1
View File
@@ -1,2 +1,7 @@
export { VisualEffects } from './visualEffects.js';
export { STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './visualPresets.js';
export {
STYLES,
GLOBAL_POST_DEFAULTS,
STYLE_PRESET_DEFAULTS,
MILITARY_DETECTION_PRESET,
} from './visualPresets.js';
+71 -18
View File
@@ -1,6 +1,14 @@
import { PostProcessStage } from 'cesium';
import { bloomStrengthFromIntensity, clampBloomIntensity, BLOOM_INTENSITY_DEFAULT } from '../bloom.js';
import { STYLES, SHARPEN_SHADER, TRANSITION_DURATION_MS } from './visualPresets.js';
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 {
@@ -8,10 +16,15 @@ 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,
constructor({
viewer,
requestRender,
holdRender,
releaseRender,
requestFrame = (callback) => requestAnimationFrame(callback),
cancelFrame = (id) => cancelAnimationFrame(id),
now = () => performance.now(), wallNow = () => Date.now(),
now = () => performance.now(),
wallNow = () => Date.now(),
createStage = (options) => new PostProcessStage(options),
}) {
this.viewer = viewer;
@@ -43,9 +56,15 @@ export class VisualEffects {
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 });
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;
@@ -56,11 +75,34 @@ export class VisualEffects {
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]])) };
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 } });
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);
@@ -70,17 +112,21 @@ export class VisualEffects {
if (this.stopped || !stage) return;
stage.uniforms.intensity = value;
stage.enabled = value > 0.001;
if (stage.enabled && stage.uniforms.time !== undefined) this.startAnimationLoop();
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);
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;
this.bloomStage.enabled =
this.bloomEnabled &&
bloomStrengthFromIntensity(this.bloomIntensity) > 0.06;
}
applyBloomIntensity(intensity) {
@@ -89,7 +135,7 @@ export class VisualEffects {
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 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;
@@ -109,7 +155,8 @@ export class VisualEffects {
applySharpenIntensity(value) {
if (this.stopped) return;
this.sharpenIntensity = value;
if (this.sharpenStage) this.sharpenStage.uniforms.amount = 0.1 + value * 2.0;
if (this.sharpenStage)
this.sharpenStage.uniforms.amount = 0.1 + value * 2.0;
this.requestRender('sharpen');
}
@@ -133,9 +180,15 @@ export class VisualEffects {
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 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);
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);
+78 -14
View File
@@ -1,7 +1,11 @@
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';
import {
GLOBAL_POST_DEFAULTS,
STYLE_PRESET_DEFAULTS,
MILITARY_DETECTION_PRESET,
} from './visualPresets.js';
function fixture() {
let time = 0;
@@ -10,15 +14,40 @@ function fixture() {
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 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 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,
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) {
@@ -27,7 +56,16 @@ function fixture() {
frames.clear();
for (const callback of pending) callback();
}
return { effects, frames, stages, holds, requests, bloom, originalBloom, tick };
return {
effects,
frames,
stages,
holds,
requests,
bloom,
originalBloom,
tick,
};
}
test('construction is inert and initialization creates one owned pipeline', () => {
@@ -37,7 +75,11 @@ test('construction is inert and initialization creates one owned pipeline', () =
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));
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);
@@ -50,7 +92,11 @@ test('crossfade uses the established 500ms easing and settles without idle frame
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');
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);
@@ -69,7 +115,11 @@ test('a superseding transition starts from the current rendered intensity', () =
f.effects.initStyles();
f.effects.startTransition('noir', 0, 1);
f.tick(250);
f.effects.startTransition('noir', f.effects.stages.noir.uniforms.intensity, 0);
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);
@@ -147,7 +197,11 @@ test('stop revokes pending work immediately but retains stages until final destr
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');
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);
@@ -156,14 +210,21 @@ test('stop revokes pending work immediately but retains stages until final destr
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');
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); }
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);
@@ -179,7 +240,10 @@ test('existing baseline and military presets keep one detection default', () =>
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[name].detection,
MILITARY_DETECTION_PRESET,
);
}
assert.equal(STYLE_PRESET_DEFAULTS.normal, undefined);
});
+12 -2
View File
@@ -9,7 +9,14 @@ 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 };
export const STYLES = {
retro: retroShader,
surveillance: nightVisionShader,
thermal: thermalShader,
anime: animeShader,
noir: noirShader,
snow: snowShader,
};
/**
* The tactical detection look: Dense at 75%.
@@ -28,7 +35,10 @@ export const STYLES = { retro: retroShader, surveillance: nightVisionShader, the
* 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 });
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 = {