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.
This commit is contained in:
Sameh Khamis committed 2026-11-06 16:32:39 -08:00
1 parent cf915e8c16
commit d6b553646e
11 files changed
+814 -9

No files matched your search

+1
View File
@@ -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",
+8 -1
View File
@@ -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,
+367
View File
@@ -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<string>,
* 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;
}
},
};
}
+302
View File
@@ -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);
});
+4
View File
@@ -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',
+11 -2
View File
@@ -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<string>} [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;
+11 -2
View File
@@ -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',
],
);
});
+2 -1
View File
@@ -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,
+82
View File
@@ -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',
+10 -2
View File
@@ -55,6 +55,7 @@ export interface ModulationSector {
export interface EffectsDiagnostics {
style: string | null;
/** The passes that draw: `style:<id>` 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<string, number>): 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<string, number>): 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;
+16 -1
View File
@@ -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'] : []),