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); });