mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 12:54:01 +00:00
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:
1 parent
cf915e8c16
commit
d6b553646e
11 files changed
+814
-9
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+367
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Vendored
+10
-2
@@ -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;
|
||||
|
||||
@@ -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'] : []),
|
||||
|
||||
Reference in new issue
Block a user