From d6b553646ea56ce5f2377312d1b8b3e8c72d6fce Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 15:29:13 -0800 Subject: [PATCH] Run styles, bloom and sharpen through a Cesium effects port The effects port puts each style Core defines on its own post-process stage, takes the scene's bloom stage and adds a sharpen stage, with the mappings the application uses today. An animated style holds frames and advances its time uniform while it draws. A replaced style keeps drawing until its intensity is 0, so Core can crossfade; the contract, the stub and a new conformance check say so. The conformance renderer has the port; the application renderer does not yet. --- scripts/package-boundaries.json | 1 + src/render/cesium/appRenderer.js | 9 +- src/render/cesium/effects.js | 367 +++++++++++++++++++++++++ src/render/cesium/effects.test.mjs | 302 ++++++++++++++++++++ src/render/cesium/harness.js | 4 + src/render/cesium/ports.js | 13 +- src/render/cesium/ports.test.mjs | 13 +- src/render/cesium/sceneRenderer.js | 3 +- src/render/conformance/checks/ports.js | 82 ++++++ src/render/contract/effects.d.ts | 12 +- src/render/stub/index.js | 17 +- 11 files changed, 814 insertions(+), 9 deletions(-) create mode 100644 src/render/cesium/effects.js create mode 100644 src/render/cesium/effects.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5551f7747..923f9b54a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2736,6 +2736,7 @@ "src/render/rendererOf.js", "src/render/cesium/ports.js", "src/render/cesium/credits.js", + "src/render/cesium/effects.js", "src/render/cesium/common.js", "src/render/cesium/camera.js", "src/render/cesium/follow.js", diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index 526f9919f..d09524360 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -34,6 +34,9 @@ import { cesiumCapabilities, createCesiumPorts } from './ports.js'; const viewers = new WeakMap(); +/** Capabilities of ports the application renderer does not have yet. */ +const NOT_YET = ['styles', 'bloom', 'sharpen']; + /** A Set no one can change once built, so `capabilities` stays true. */ export class ReadonlySet extends Set { #sealed; @@ -91,7 +94,11 @@ export function createCesiumRendererPlugin({ let capabilities; try { viewer = createViewer({ container, creditContainer }); - capabilities = new ReadonlySet(cesiumCapabilities(viewer.scene)); + // Effects are not the application's yet: its styles still run + // through stages of their own. + capabilities = new ReadonlySet( + cesiumCapabilities(viewer.scene).filter((c) => !NOT_YET.includes(c)), + ); ports = createCesiumPorts(viewer, { capabilities }); input = createCesiumInput(viewer, { query: ports.query, diff --git a/src/render/cesium/effects.js b/src/render/cesium/effects.js new file mode 100644 index 000000000..d260c3858 --- /dev/null +++ b/src/render/cesium/effects.js @@ -0,0 +1,367 @@ +/** + * @module render/cesium/effects + * @description The Cesium renderer's effects port + * (`src/render/contract/effects.d.ts`) over `scene.postProcessStages`: each + * style Core defines is one full-screen `PostProcessStage`, bloom is the + * scene's own bloom stage, sharpen a stage of the port's own. + * + * A style's GLSL goes to Cesium as written: Cesium supplies `colorTexture`, + * `colorTextureDimensions` and `v_textureCoordinates`, and declares + * `out_FragColor` itself when the shader writes it. The port supplies + * `intensity`, `time` and one uniform per parameter. + * + * Stages run in the order styles were defined, then sharpen. A style draws + * while it is the style, and the styles it replaced while they fade out + * (until their intensity goes to 0). An animated style that draws holds the + * loop continuous and advances `time` each frame; nothing else asks for + * frames but the one each change needs. + * + * Building the port changes nothing in the scene; the first `bloom` call + * takes the scene's bloom stage, and disposal gives it back as it was. + * There is no `screenModulation`: GEV's sweep shades each icon and label + * in its own shader, not the frame. + */ +import * as Cesium from 'cesium'; +import { listenerList } from './common.js'; + +/** Below this a style, bloom or sharpen draws nothing, so it runs no pass. */ +const OFF = 0.001; +/** Bloom below this strength runs no pass. */ +const BLOOM_FLOOR = 0.06; +/** Uniform names a parameter key may not take. */ +const RESERVED = new Set([ + 'colorTexture', + 'colorTextureDimensions', + 'v_textureCoordinates', + 'intensity', + 'time', +]); +const BLOOM_UNIFORMS = [ + 'glowOnly', + 'contrast', + 'brightness', + 'delta', + 'sigma', + 'stepSize', +]; + +/** + * An unsharp mask over the 3x3 neighbourhood: the centre plus the + * difference from its box blur, times `amount`. + */ +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); + } +`; + +/** + * The bloom stage's uniforms for a strength, 0..1: none below + * `BLOOM_FLOOR`, then eased from a faint glow to a wide one. + * @param {number} strength + */ +export function bloomUniforms(strength) { + const s = strength <= BLOOM_FLOOR ? 0 : (strength - BLOOM_FLOOR) / 0.94; + const eased = s * s * (3 - 2 * s); + return { + contrast: 255 - eased * 168, + brightness: -0.5 + eased * 0.36, + sigma: 0.28 + eased * 6.3, + delta: 0.2 + eased * 2.25, + stepSize: 1 + eased * 1.25, + }; +} + +const clamp01 = (value) => Math.min(1, Math.max(0, Number(value) || 0)); + +/** + * @param {object} scene A Cesium scene. + * @param {{ + * capabilities: ReadonlySet, + * frame: import('../contract/frame').FramePort, + * report: (error: unknown) => void, + * createStage?: (options: object) => object, + * }} options `createStage` makes a `PostProcessStage`; tests pass fakes. + * @returns {{port: import('../contract/effects').EffectsPort, + * dispose: () => void}} + */ +export function createCesiumEffects( + scene, + { + capabilities, + frame, + report, + createStage = (options) => new Cesium.PostProcessStage(options), + }, +) { + const stages = scene.postProcessStages; + /** Defined styles in definition order: id → record. */ + const styles = new Map(); + /** The style, and those it replaced still fading out. */ + let current = null; + const fading = new Set(); + const listeners = listenerList(report); + + let bloomTaken = null; + let bloomEnabled = false; + let bloomStrength = 0; + let sharpenStage = null; + let sharpenEnabled = false; + let sharpenAmount = 0; + + // The animation clock: running while an animated style draws. + let release = null; + let offFrame = null; + let disposed = false; + const started = performance.now(); + + const drawing = (record) => + (record.id === current || fading.has(record.id)) && record.intensity > OFF; + + function makeStage(record) { + const uniforms = { intensity: record.intensity, time: 0 }; + for (const p of record.params) uniforms[p.key] = record.values[p.key]; + const stage = createStage({ + name: `effects_style_${record.id}`, + fragmentShader: record.glsl, + uniforms, + }); + stage.enabled = false; + stages.add(stage); + return stage; + } + + function makeSharpen() { + sharpenStage = createStage({ + name: 'effects_sharpen', + fragmentShader: SHARPEN_SHADER, + uniforms: { amount: 0.1 + sharpenAmount * 2 }, + }); + sharpenStage.enabled = false; + stages.add(sharpenStage); + } + + /** Puts each stage's enabled flag and the clock in step with the state. */ + function apply() { + let animated = false; + for (const record of styles.values()) { + const on = drawing(record); + record.stage.enabled = on; + record.stage.uniforms.intensity = record.intensity; + if (on && record.animated) animated = true; + } + if (bloomTaken) + scene.postProcessStages.bloom.enabled = + bloomEnabled && bloomStrength > BLOOM_FLOOR; + if (sharpenStage) + sharpenStage.enabled = sharpenEnabled && sharpenAmount > OFF; + if (animated && !release) { + offFrame = frame.on('preRender', tick); + release = frame.holdContinuous('effects:animated'); + } else if (!animated && release) { + stopClock(); + } + } + + function tick(t) { + const seconds = ((t?.now ?? performance.now()) - started) / 1000; + for (const record of styles.values()) + if (record.animated) record.stage.uniforms.time = seconds; + } + + function stopClock() { + offFrame?.(); + offFrame = null; + release?.(); + release = null; + } + + function changed(reason) { + if (disposed) return; + apply(); + frame.requestRender(reason); + listeners.fire(port.diagnostics()); + } + + /** Whether `id` names a style; changes to unknown ones are ignored. */ + const known = (id) => capabilities.has('styles') && styles.has(id); + + const port = { + styles: () => + capabilities.has('styles') + ? [...styles.values()].map(({ id, params, animated }) => ({ + id, + params: params.map((p) => ({ ...p })), + animated, + })) + : [], + defineStyle(definition) { + if ( + disposed || + !capabilities.has('styles') || + typeof definition?.shader?.glsl !== 'string' + ) + return null; + const id = String(definition.id); + const params = (definition.params ?? []).map((p) => ({ ...p })); + for (const p of params) + if (RESERVED.has(p.key) || !/^[A-Za-z_]\w*$/.test(p.key)) + throw new TypeError(`Style parameter name not allowed: ${p.key}`); + const previous = styles.get(id); + const record = { + id, + params, + animated: Boolean(definition.animated), + glsl: definition.shader.glsl, + intensity: previous?.intensity ?? 1, + values: Object.fromEntries( + params.map((p) => [ + p.key, + previous && p.key in previous.values + ? previous.values[p.key] + : p.default, + ]), + ), + stage: null, + }; + if (previous) { + stages.remove(previous.stage); + styles.delete(id); + } + record.stage = makeStage(record); + styles.set(id, record); + // Sharpen runs after every style, so it moves behind a new one. + if (sharpenStage) { + stages.remove(sharpenStage); + makeSharpen(); + } + changed('effects-style'); + let removed = false; + return () => { + if (removed) return; + removed = true; + if (disposed || styles.get(id) !== record) return; + styles.delete(id); + stages.remove(record.stage); + fading.delete(id); + if (current === id) current = null; + changed('effects-style'); + }; + }, + setStyle(id, params) { + if (disposed || !capabilities.has('styles')) return; + const next = id === null || id === undefined ? null : String(id); + if (next !== null && !styles.has(next)) return; + if (next !== current) { + if (current !== null && styles.get(current)?.intensity > OFF) + fading.add(current); + if (next !== null) fading.delete(next); + current = next; + } + if (next !== null && params) assignParams(styles.get(next), params); + changed('effects-style'); + }, + setStyleIntensity(id, intensity) { + if (disposed || !known(id)) return; + const record = styles.get(id); + record.intensity = clamp01(intensity); + if (record.intensity <= OFF) fading.delete(id); + changed('effects-style'); + }, + setStyleParams(id, params) { + if (disposed || !known(id)) return; + assignParams(styles.get(id), params); + changed('effects-style'); + }, + bloom({ enabled, strength } = {}) { + if (disposed || !capabilities.has('bloom')) return; + const bloom = scene.postProcessStages.bloom; + if (!bloomTaken) { + bloomTaken = { + enabled: bloom.enabled, + uniforms: Object.fromEntries( + BLOOM_UNIFORMS.map((name) => [name, bloom.uniforms[name]]), + ), + }; + bloom.uniforms.glowOnly = false; + } + bloomEnabled = Boolean(enabled); + if (strength !== undefined) bloomStrength = clamp01(strength); + Object.assign(bloom.uniforms, bloomUniforms(bloomStrength)); + changed('effects-bloom'); + }, + sharpen({ enabled, amount } = {}) { + if (disposed || !capabilities.has('sharpen')) return; + sharpenEnabled = Boolean(enabled); + if (amount !== undefined) sharpenAmount = clamp01(amount); + if (!sharpenStage) makeSharpen(); + sharpenStage.uniforms.amount = 0.1 + sharpenAmount * 2; + changed('effects-sharpen'); + }, + diagnostics: () => ({ + style: current, + passes: [ + ...[...styles.values()] + .filter((record) => record.stage.enabled) + .map((record) => `style:${record.id}`), + ...(bloomTaken && scene.postProcessStages.bloom.enabled + ? ['bloom'] + : []), + ...(sharpenStage?.enabled ? ['sharpen'] : []), + ], + }), + on(event, cb) { + if (event !== 'change') return () => {}; + return listeners.on(cb); + }, + }; + + function assignParams(record, params) { + for (const [key, value] of Object.entries(params ?? {})) { + if (!(key in record.values)) continue; + record.values[key] = Number(value); + record.stage.uniforms[key] = Number(value); + } + } + + return { + port, + /** Removes the port's stages and gives the scene's bloom back. */ + dispose() { + if (disposed) return; + disposed = true; + stopClock(); + listeners.clear(); + for (const record of styles.values()) stages.remove(record.stage); + styles.clear(); + if (sharpenStage) stages.remove(sharpenStage); + sharpenStage = null; + if (bloomTaken) { + const bloom = scene.postProcessStages.bloom; + Object.assign(bloom.uniforms, bloomTaken.uniforms); + bloom.enabled = bloomTaken.enabled; + bloomTaken = null; + } + }, + }; +} diff --git a/src/render/cesium/effects.test.mjs b/src/render/cesium/effects.test.mjs new file mode 100644 index 000000000..9a56096b4 --- /dev/null +++ b/src/render/cesium/effects.test.mjs @@ -0,0 +1,302 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bloomUniforms, createCesiumEffects } from './effects.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); +}); diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index bb189c7c0..c1acf00eb 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -36,6 +36,10 @@ export const CESIUM_CHECKS = new Set([ 'draw.models', 'draw.pick', 'draw.sets', + 'effects.crossfade', + 'effects.defineStyle', + 'effects.styles', + 'effects.withoutStyles', 'frame.demand', 'frame.order', 'frame.prediction', diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index 392c229ba..ac64349a5 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -17,6 +17,7 @@ import { heightsFor } from '../../queries/heights.js'; import { createCesiumCamera } from './camera.js'; import { listenerList, toCartesian, uncaught } from './common.js'; import { createCesiumCredits } from './credits.js'; +import { createCesiumEffects } from './effects.js'; import { atEndOfTick, billboardLoadError, @@ -103,13 +104,14 @@ function cameraEqual(camera0, camera1, epsilon) { * What a Cesium scene can do, as contract capabilities; `only` limits them * to those it lists. Depth picks, height samples and ray casts all need * depth textures, which `sampleHeightSupported` and `pickPositionSupported` - * report. + * report. Styles, bloom and sharpen are post-process stages every scene + * runs. * @param {object} scene * @param {Iterable} [only] * @returns {string[]} */ export function cesiumCapabilities(scene, only) { - const all = ['models']; + const all = ['models', 'styles', 'bloom', 'sharpen']; if (scene.pickPositionSupported) all.push('pickPosition'); if (scene.sampleHeightSupported) all.push('sampleHeight', 'finestHeights', 'rayCast'); @@ -735,6 +737,11 @@ export function createCesiumPorts(widget, { capabilities }) { const cameraPart = createCesiumCamera({ scene, camera, report, widget }); const cameraPort = cameraPart.port; + const effectsPart = createCesiumEffects(scene, { + capabilities, + frame, + report, + }); const creditsPart = createCesiumCredits({ display: widget.creditDisplay, @@ -1086,6 +1093,7 @@ export function createCesiumPorts(widget, { capabilities }) { camera: cameraPort, credits: creditsPart.port, query, + effects: effectsPart.port, capture, modelSets, iconState, @@ -1102,6 +1110,7 @@ export function createCesiumPorts(widget, { capabilities }) { for (const list of Object.values(frameListeners)) list.clear(); cameraPart.dispose(); creditsPart.dispose(); + effectsPart.dispose(); for (const done of [...pendingRenders]) done(); // Late model loads find no host and destroy themselves. for (const record of sets.values()) record.host = null; diff --git a/src/render/cesium/ports.test.mjs b/src/render/cesium/ports.test.mjs index 9157ce853..e1e66f0d2 100644 --- a/src/render/cesium/ports.test.mjs +++ b/src/render/cesium/ports.test.mjs @@ -9,9 +9,11 @@ import { import { updateCameraChanged } from './internals.js'; test('capabilities follow what the scene supports', () => { - assert.deepEqual(cesiumCapabilities({}), ['models']); + const effects = ['styles', 'bloom', 'sharpen']; + assert.deepEqual(cesiumCapabilities({}), ['models', ...effects]); assert.deepEqual(cesiumCapabilities({ pickPositionSupported: true }), [ 'models', + ...effects, 'pickPosition', ]); assert.deepEqual( @@ -19,7 +21,14 @@ test('capabilities follow what the scene supports', () => { pickPositionSupported: true, sampleHeightSupported: true, }), - ['models', 'pickPosition', 'sampleHeight', 'finestHeights', 'rayCast'], + [ + 'models', + ...effects, + 'pickPosition', + 'sampleHeight', + 'finestHeights', + 'rayCast', + ], ); }); diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index 12c37b5d8..f999344c8 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -3,7 +3,7 @@ * @description The part of the Cesium renderer the reference scenes need, * through the contract's own members: a bare globe and sky, icons, points, * lines, shapes and models, `setView`, picks and projection, the frame - * phases, pointer input, credits, settling and capture. The + * phases, pointer input, credits, effects, settling and capture. The * `/dev/conformance/` page draws the scenes with it to make and check the * visual references. * @@ -280,6 +280,7 @@ export async function createCesiumSceneRenderer({ camera: ports.camera, query: ports.query, input: input.port, + effects: ports.effects, capture: ports.capture, credits: ports.credits, whenSettled, diff --git a/src/render/conformance/checks/ports.js b/src/render/conformance/checks/ports.js index d3c4285db..a7e1894ed 100644 --- a/src/render/conformance/checks/ports.js +++ b/src/render/conformance/checks/ports.js @@ -185,6 +185,27 @@ const SET_KINDS = [ ['orbits', 'orbits'], ]; +/** A plain style that swaps colour channels by its intensity. */ +const swapStyle = (id) => ({ + id, + params: [], + animated: false, + shader: { + glsl: `uniform sampler2D colorTexture; +uniform float intensity; +in vec2 v_textureCoordinates; +void main() { + vec4 c = texture(colorTexture, v_textureCoordinates); + out_FragColor = vec4(mix(c.rgb, c.gbr, intensity), 1.0); +}`, + wgsl: `@fragment +fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f { + let c = textureSample(colorTexture, colorSampler, uv); + return vec4f(mix(c.rgb, c.gbr, params.intensity), 1.0); +}`, + }, +}); + export function portChecks() { return [ // --- Shape ------------------------------------------------------------- @@ -1956,6 +1977,10 @@ export function portChecks() { 'effects.styles', 'a style is set, tuned and cleared; diagnostics follow', (h, r) => { + // Core defines its styles on a renderer with none of its own. + const off = r.effects.styles().length + ? null + : r.effects.defineStyle(swapStyle('conf:plain')); const styles = r.effects.styles(); ok(styles.length > 0, 'styles listed'); const changes = []; @@ -1966,6 +1991,8 @@ export function portChecks() { r.effects.setStyle(null); equal(r.effects.diagnostics().style, null, 'cleared'); ok(changes.length >= 2, 'change events'); + r.effects.setStyleIntensity(styles[0].id, 0); + off?.(); }, { capability: 'styles' }, ), @@ -2027,6 +2054,61 @@ fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f { }, { capability: 'styles' }, ), + check( + 'effects.crossfade', + 'a replaced style draws until its intensity is 0', + (h, r) => { + const define = (id) => r.effects.defineStyle(swapStyle(id)); + const offs = [define('conf:a'), define('conf:b')]; + const drawn = () => + r.effects + .diagnostics() + .passes.filter((p) => p === 'style:conf:a' || p === 'style:conf:b') + .sort(); + try { + r.effects.setStyle('conf:a'); + deepEqual(drawn(), ['style:conf:a'], 'the style draws'); + r.effects.setStyleIntensity('conf:b', 0); + r.effects.setStyle('conf:b'); + deepEqual( + drawn(), + ['style:conf:a'], + 'the new style at 0 draws nothing yet', + ); + r.effects.setStyleIntensity('conf:a', 0.6); + r.effects.setStyleIntensity('conf:b', 0.4); + deepEqual( + drawn(), + ['style:conf:a', 'style:conf:b'], + 'both draw mid-fade', + ); + r.effects.setStyleIntensity('conf:a', 0); + deepEqual(drawn(), ['style:conf:b'], 'the old one stops at 0'); + r.effects.setStyle('conf:a'); + r.effects.setStyle('conf:b'); + r.effects.setStyleIntensity('conf:a', 1); + deepEqual( + drawn(), + ['style:conf:b'], + 'a style replaced at 0 does not fade', + ); + r.effects.setStyleIntensity('conf:a', 0); + r.effects.setStyleIntensity('conf:a', 1); + deepEqual( + drawn(), + ['style:conf:b'], + 'a faded-out style does not come back by intensity alone', + ); + r.effects.setStyle(null); + deepEqual(drawn(), ['style:conf:b'], 'cleared, it fades'); + r.effects.setStyleIntensity('conf:b', 0); + deepEqual(drawn(), [], 'and stops'); + } finally { + for (const off of offs) off?.(); + } + }, + { capability: 'styles' }, + ), check( 'effects.withoutStyles', 'without styles, styles() is empty', diff --git a/src/render/contract/effects.d.ts b/src/render/contract/effects.d.ts index 42bf4ad81..a64c8662e 100644 --- a/src/render/contract/effects.d.ts +++ b/src/render/contract/effects.d.ts @@ -55,6 +55,7 @@ export interface ModulationSector { export interface EffectsDiagnostics { style: string | null; + /** The passes that draw: `style:` for each style, `bloom`, ... */ passes: string[]; } @@ -67,12 +68,19 @@ export interface EffectsPort { * languages; the Off removes it (turning it off when it is the style). */ defineStyle(definition: StyleDefinition): Off | null; + /** + * Make `id` the style, or none. The style it replaces, if its intensity + * is above 0, keeps drawing with the new one until its intensity is set + * to 0, so Core crossfades by fading one out as it fades the other in. + * Styles draw in the order they were defined. + */ setStyle(id: string | null, params?: Record): void; + /** A style's intensity, 0..1; kept while it is not the style. 1 at first. */ setStyleIntensity(id: string, intensity: number): void; setStyleParams(id: string, params: Record): void; - /** `bloom`. */ + /** `bloom`. `strength`, 0..1, is kept while bloom is off. */ bloom(settings: { enabled: boolean; strength?: number }): void; - /** `sharpen`. */ + /** `sharpen`. `amount`, 0..1, is kept while sharpen is off. */ sharpen(settings: { enabled: boolean; amount?: number }): void; /** `screenModulation`. */ screenModulation?(sector: ModulationSector | null): void; diff --git a/src/render/stub/index.js b/src/render/stub/index.js index 7a90e4ec5..6539842ac 100644 --- a/src/render/stub/index.js +++ b/src/render/stub/index.js @@ -968,6 +968,11 @@ export function createStubRenderer(options = {}) { modulation: null, }; const effectListeners = []; + /** The styles a setStyle replaced, drawing until their intensity is 0. */ + const fading = new Set(); + const styleIntensity = (id) => effectState.intensity[id] ?? 1; + const styleDraws = (id) => + (id === effectState.style || fading.has(id)) && styleIntensity(id) > 0; /** Styles Core defined, by id. */ const defined = new Map(); const STYLES = Object.freeze([ @@ -1015,17 +1020,24 @@ export function createStubRenderer(options = {}) { removed = true; if (defined.get(entry.id) !== entry) return; defined.delete(entry.id); + fading.delete(entry.id); if (effectState.style === entry.id) effectState.style = null; effectsChanged(); }; }, setStyle(id, params) { + if (id !== effectState.style) { + if (effectState.style !== null && styleIntensity(effectState.style) > 0) + fading.add(effectState.style); + fading.delete(id); + } effectState.style = id; if (params) effectState.params[id] = { ...params }; effectsChanged(); }, setStyleIntensity(id, value) { effectState.intensity[id] = value; + if (!(value > 0)) fading.delete(id); effectsChanged(); }, setStyleParams(id, params) { @@ -1045,7 +1057,10 @@ export function createStubRenderer(options = {}) { diagnostics: () => ({ style: effectState.style, passes: [ - ...(effectState.style ? [`style:${effectState.style}`] : []), + ...effects + .styles() + .filter((s) => styleDraws(s.id)) + .map((s) => `style:${s.id}`), ...(effectState.bloom?.enabled ? ['bloom'] : []), ...(effectState.sharpen?.enabled ? ['sharpen'] : []), ...(effectState.modulation ? ['modulation'] : []),