Files
gods-eye-view/src/render/cesium/effects.test.mjs
T
Sameh Khamis 910a691494 Give a viewer with no renderer an effects port, as cameras get
The renderer bridge builds and keeps an effects port over a viewer no
renderer is bound to, with a factory the Cesium effects module provides,
so visual settings on a consumer's own viewer draw their styles, bloom
and sharpen as before and give the scene's bloom back when destroyed.
The application shell loads the factory; a test checks every export
that reaches the effects lookup loads it.
2026-11-06 16:33:40 -08:00

391 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { bloomUniforms, createCesiumEffects } from './effects.js';
import { findEffects, releaseUnboundEffects } from '../rendererOf.js';
const ALL = new Set(['styles', 'bloom', 'sharpen']);
function fixture({ capabilities = ALL } = {}) {
const stages = [];
const removed = [];
const bloom = {
enabled: true,
uniforms: {
glowOnly: true,
contrast: 3,
brightness: 4,
delta: 5,
sigma: 6,
stepSize: 7,
},
};
const original = structuredClone(bloom);
const scene = {
postProcessStages: {
bloom,
add: (stage) => stages.push(stage),
remove(stage) {
const i = stages.indexOf(stage);
if (i < 0) return false;
stages.splice(i, 1);
removed.push(stage);
return true;
},
},
};
const holds = [];
const requests = [];
const preRender = new Set();
const frame = {
on(phase, cb) {
assert.equal(phase, 'preRender');
preRender.add(cb);
return () => preRender.delete(cb);
},
holdContinuous(reason) {
const hold = { reason, live: true };
holds.push(hold);
return () => (hold.live = false);
},
requestRender: (reason) => requests.push(reason),
};
const part = createCesiumEffects(scene, {
capabilities,
frame,
report: (error) => {
throw error;
},
createStage: (options) => ({ ...options, enabled: true }),
});
return {
effects: part.port,
dispose: part.dispose,
stages,
removed,
bloom,
original,
requests,
liveHolds: () => holds.filter((h) => h.live).map((h) => h.reason),
render(now) {
for (const cb of [...preRender]) cb({ now });
},
preRender,
};
}
const style = (id, extra = {}) => ({
id,
params: [{ key: 'grain', min: 0, max: 1, default: 0.25 }],
animated: false,
shader: { glsl: `void main() { out_FragColor = vec4(0.0); }` },
...extra,
});
const stageOf = (f, id) =>
f.stages.find((s) => s.name === `effects_style_${id}`);
test('building the port changes nothing in the scene', () => {
const f = fixture();
assert.equal(f.stages.length, 0);
assert.deepEqual(f.bloom, f.original);
assert.deepEqual(f.effects.diagnostics(), { style: null, passes: [] });
});
test('a defined style is one disabled stage with its uniforms', () => {
const f = fixture();
const off = f.effects.defineStyle(style('retro'));
assert.equal(typeof off, 'function');
assert.equal(f.stages.length, 1);
const stage = stageOf(f, 'retro');
assert.equal(stage.fragmentShader, style('retro').shader.glsl);
assert.deepEqual(stage.uniforms, { intensity: 1, time: 0, grain: 0.25 });
assert.equal(stage.enabled, false);
assert.deepEqual(f.effects.styles(), [
{
id: 'retro',
params: [{ key: 'grain', min: 0, max: 1, default: 0.25 }],
animated: false,
},
]);
off();
assert.equal(f.stages.length, 0);
assert.deepEqual(f.effects.styles(), []);
});
test('reserved or malformed parameter keys are refused', () => {
const f = fixture();
for (const key of ['time', 'intensity', 'colorTexture', '1x', 'a-b'])
assert.throws(
() =>
f.effects.defineStyle(
style('x', { params: [{ key, min: 0, max: 1, default: 0 }] }),
),
TypeError,
);
assert.equal(f.stages.length, 0);
});
test('setStyle enables the style; intensity and params reach the stage', () => {
const f = fixture();
f.effects.defineStyle(style('retro'));
const changes = [];
f.effects.on('change', (d) => changes.push(d));
f.effects.setStyle('retro', { grain: 0.8, unknown: 3 });
const stage = stageOf(f, 'retro');
assert.equal(stage.enabled, true);
assert.equal(stage.uniforms.grain, 0.8);
assert.equal('unknown' in stage.uniforms, false);
f.effects.setStyleIntensity('retro', 0.4);
assert.equal(stage.uniforms.intensity, 0.4);
f.effects.setStyleParams('retro', { grain: 0.1 });
assert.equal(stage.uniforms.grain, 0.1);
f.effects.setStyleIntensity('retro', 0);
assert.equal(stage.enabled, false, 'no pass at 0');
assert.deepEqual(f.effects.diagnostics(), { style: 'retro', passes: [] });
assert.equal(changes.length, 4);
assert.ok(f.requests.length >= 4);
});
test('a replaced style fades out under the new one', () => {
const f = fixture();
f.effects.defineStyle(style('a'));
f.effects.defineStyle(style('b'));
f.effects.setStyle('a');
f.effects.setStyleIntensity('b', 0);
f.effects.setStyle('b');
assert.equal(stageOf(f, 'a').enabled, true);
assert.equal(stageOf(f, 'b').enabled, false);
f.effects.setStyleIntensity('b', 0.5);
assert.deepEqual(f.effects.diagnostics().passes, ['style:a', 'style:b']);
f.effects.setStyleIntensity('a', 0);
f.effects.setStyleIntensity('a', 1);
assert.equal(stageOf(f, 'a').enabled, false, 'faded out for good');
f.effects.setStyle('a');
assert.deepEqual(f.effects.diagnostics(), {
style: 'a',
passes: ['style:a', 'style:b'],
});
});
test('stages keep definition order, sharpen last', () => {
const f = fixture();
f.effects.defineStyle(style('a'));
f.effects.sharpen({ enabled: true, amount: 0.5 });
f.effects.defineStyle(style('b'));
assert.deepEqual(
f.stages.map((s) => s.name),
['effects_style_a', 'effects_style_b', 'effects_sharpen'],
);
assert.equal(f.stages[2].enabled, true);
assert.equal(f.stages[2].uniforms.amount, 1.1);
});
test('a replacement keeps the style on, its intensity and its values', () => {
const f = fixture();
const off = f.effects.defineStyle(style('a'));
f.effects.setStyle('a', { grain: 0.9 });
f.effects.setStyleIntensity('a', 0.3);
const offAgain = f.effects.defineStyle(
style('a', {
params: [
{ key: 'grain', min: 0, max: 1, default: 0.25 },
{ key: 'fresh', min: 0, max: 1, default: 0.6 },
],
}),
);
const stage = stageOf(f, 'a');
assert.deepEqual(stage.uniforms, {
intensity: 0.3,
time: 0,
grain: 0.9,
fresh: 0.6,
});
assert.equal(stage.enabled, true);
off();
assert.equal(f.effects.styles().length, 1, 'the old Off leaves the new');
offAgain();
assert.deepEqual(f.effects.diagnostics(), { style: null, passes: [] });
offAgain();
});
test('an animated style holds frames and advances time only while it draws', () => {
const f = fixture();
f.effects.defineStyle(style('anim', { animated: true }));
f.effects.setStyleIntensity('anim', 0);
f.effects.setStyle('anim');
assert.deepEqual(f.liveHolds(), [], 'no hold at 0');
f.effects.setStyleIntensity('anim', 1);
assert.deepEqual(f.liveHolds(), ['effects:animated']);
const stage = stageOf(f, 'anim');
const before = stage.uniforms.time;
f.render(performance.now() + 2000);
assert.ok(stage.uniforms.time >= before + 1.9);
f.effects.setStyle(null);
assert.deepEqual(f.liveHolds(), ['effects:animated'], 'fading still draws');
f.effects.setStyleIntensity('anim', 0);
assert.deepEqual(f.liveHolds(), []);
assert.equal(f.preRender.size, 0);
});
test('bloom keeps its dead zone and eased uniforms, and is given back', () => {
const f = fixture();
f.effects.bloom({ enabled: true, strength: 0.05 });
assert.equal(f.bloom.enabled, false, 'below the floor');
assert.equal(f.bloom.uniforms.glowOnly, false);
f.effects.bloom({ enabled: true, strength: 0.5 });
assert.equal(f.bloom.enabled, true);
assert.deepEqual(
Object.fromEntries(
Object.keys(bloomUniforms(0.5)).map((k) => [k, f.bloom.uniforms[k]]),
),
bloomUniforms(0.5),
);
f.effects.bloom({ enabled: false });
assert.equal(f.bloom.enabled, false);
f.effects.bloom({ enabled: true });
assert.equal(f.bloom.enabled, true, 'strength kept while off');
assert.deepEqual(f.effects.diagnostics().passes, ['bloom']);
f.dispose();
assert.deepEqual(f.bloom, f.original);
});
test('the bloom mapping matches the eased curve', () => {
assert.deepEqual(bloomUniforms(0), {
contrast: 255,
brightness: -0.5,
sigma: 0.28,
delta: 0.2,
stepSize: 1,
});
const full = bloomUniforms(1);
assert.ok(Math.abs(full.contrast - 87) < 1e-9);
assert.ok(Math.abs(full.sigma - 6.58) < 1e-9);
});
test('sharpen at 0 runs no pass and keeps its amount while off', () => {
const f = fixture();
f.effects.sharpen({ enabled: true, amount: 0 });
const stage = f.stages.find((s) => s.name === 'effects_sharpen');
assert.equal(stage.enabled, false);
assert.equal(stage.uniforms.amount, 0.1);
f.effects.sharpen({ enabled: false, amount: 0.49 });
assert.equal(stage.enabled, false);
f.effects.sharpen({ enabled: true });
assert.equal(stage.enabled, true);
assert.ok(Math.abs(stage.uniforms.amount - 1.08) < 1e-9);
});
test('without capabilities nothing is defined or drawn', () => {
const f = fixture({ capabilities: new Set() });
assert.equal(f.effects.defineStyle(style('a')), null);
assert.deepEqual(f.effects.styles(), []);
f.effects.bloom({ enabled: true, strength: 1 });
f.effects.sharpen({ enabled: true, amount: 1 });
assert.equal(f.stages.length, 0);
assert.deepEqual(f.bloom, f.original);
assert.equal(
fixture().effects.defineStyle({ id: 'w', params: [], shader: {} }),
null,
'no GLSL',
);
});
test('dispose removes every stage and releases the clock', () => {
const f = fixture();
f.effects.defineStyle(style('anim', { animated: true }));
f.effects.setStyle('anim');
f.effects.sharpen({ enabled: true, amount: 1 });
f.dispose();
assert.equal(f.stages.length, 0);
assert.deepEqual(f.liveHolds(), []);
f.effects.setStyle('anim');
assert.equal(f.stages.length, 0);
});
test('a viewer with no renderer gets an effects port over its scene', () => {
const events = () => {
const cbs = new Set();
return {
cbs,
addEventListener(cb) {
cbs.add(cb);
return () => cbs.delete(cb);
},
raise: () => [...cbs].forEach((cb) => cb()),
};
};
const stages = [];
let requests = 0;
const scene = {
postProcessStages: {
bloom: { enabled: false, uniforms: {} },
add: (stage) => stages.push(stage),
remove: (stage) => stages.splice(stages.indexOf(stage), 1),
},
preRender: events(),
postRender: events(),
requestRender: () => (requests += 1),
};
const viewer = { scene };
const effects = findEffects(viewer);
assert.ok(effects, 'built');
assert.equal(findEffects(viewer), effects, 'kept');
assert.equal(findEffects({ scene: {} }), null, 'not a Cesium scene');
effects.bloom({ enabled: true, strength: 1 });
assert.equal(scene.postProcessStages.bloom.enabled, true);
// An animated style draws every frame while it shows.
effects.defineStyle(style('anim', { animated: true }));
effects.setStyle('anim');
assert.equal(scene.postRender.cbs.size, 1, 'held');
const before = requests;
scene.postRender.raise();
assert.equal(requests, before + 1, 'the next frame is asked for');
effects.setStyleIntensity('anim', 0);
assert.equal(scene.postRender.cbs.size, 0, 'released');
assert.equal(scene.preRender.cbs.size, 0);
releaseUnboundEffects(viewer, {});
assert.ok(stages.length > 0, 'another port is not let go of');
releaseUnboundEffects(viewer, effects);
assert.equal(stages.length, 0, 'let go: its passes are gone');
assert.equal(
scene.postProcessStages.bloom.enabled,
false,
'bloom given back',
);
assert.notEqual(findEffects(viewer), effects, 'a later lookup builds anew');
});
test('a destroyed scene is let go of without touching it', () => {
let destroyed = false;
const scene = {
isDestroyed: () => destroyed,
postProcessStages: {
bloom: { enabled: false, uniforms: {} },
add() {},
remove() {
if (destroyed) throw new Error('destroyed');
},
},
preRender: { addEventListener: () => () => {} },
postRender: { addEventListener: () => () => {} },
requestRender() {
if (destroyed) throw new Error('destroyed');
},
};
const viewer = { scene, isDestroyed: () => destroyed };
const effects = findEffects(viewer);
effects.defineStyle(style('anim', { animated: true }));
effects.setStyle('anim');
effects.bloom({ enabled: true, strength: 1 });
effects.sharpen({ enabled: true, amount: 1 });
const off = effects.defineStyle(style('plain'));
destroyed = true;
// Core's own teardown after the scene went: nothing throws.
off();
effects.bloom({ enabled: false });
effects.sharpen({ enabled: false });
effects.setStyle(null);
releaseUnboundEffects(viewer, effects);
assert.equal(findEffects(viewer), null);
});