mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Glow the Navara frame with the Display bloom behind an optional render.setBloom capability
postprocessing's BloomEffect runs after the overlays and before the visual styles, follows Cesium's eased strength and dead zone with its threshold set in display terms, and VisualEffects drives it when the engine has the bloom capability.
This commit is contained in:
1 parent
074f4af10e
commit
c392d6fb50
15 files changed
+893
-178
No files matched your search
@@ -248,6 +248,17 @@
|
||||
* Every style starts at intensity 0 with its shader's defaults.
|
||||
* @property {(name: string) => StyleState} [style] The style's state last
|
||||
* set (a copy).
|
||||
* @property {(options: { enabled?: boolean, strength?: number }) => void} [setBloom]
|
||||
* Optional, with bloom (capability `bloom`): a glow around the bright parts
|
||||
* of the final frame, before the visual styles. `strength` is the app's
|
||||
* Bloom slider normalized to 0..1 (src/bloom.js
|
||||
* bloomStrengthFromIntensity); at or under 0.06 nothing glows, as with
|
||||
* Cesium's bloom stage, whose easing the engine follows. Omitted fields
|
||||
* keep their value; a non-boolean `enabled` or a strength outside 0..1
|
||||
* throws a TypeError. Off at strength 0 until set. Cesium has none: in
|
||||
* Cesium mode the app drives the scene's own bloom stage.
|
||||
* @property {() => { enabled: boolean, strength: number }} [bloom] The bloom
|
||||
* last set (a copy).
|
||||
*
|
||||
* @typedef {{ intensity: number, uniforms: Record<string, number> }} StyleState
|
||||
*
|
||||
@@ -666,6 +677,10 @@ const OPTIONAL_SHAPE = {
|
||||
'render.setStyle': isFunction,
|
||||
'render.style': isFunction,
|
||||
},
|
||||
bloom: {
|
||||
'render.setBloom': isFunction,
|
||||
'render.bloom': isFunction,
|
||||
},
|
||||
};
|
||||
|
||||
/** The optional capability flags, e.g. 'visualStyles'. */
|
||||
@@ -807,3 +822,24 @@ export function mergeStyle(current, { intensity, uniforms } = {}, names = []) {
|
||||
}
|
||||
return { intensity: intensity ?? current.intensity, uniforms: next };
|
||||
}
|
||||
|
||||
/** The bloom an engine starts with (GlobeRender.bloom). */
|
||||
export const BLOOM_INITIAL = Object.freeze({ enabled: false, strength: 0 });
|
||||
|
||||
/**
|
||||
* GlobeRender.setBloom's argument merged onto `current`: a new state, or a
|
||||
* TypeError for a non-boolean `enabled` or a strength outside 0..1.
|
||||
*/
|
||||
export function mergeBloom(current, { enabled, strength } = {}) {
|
||||
if (enabled !== undefined && !isBoolean(enabled))
|
||||
throw new TypeError(`Bloom enabled must be a boolean: ${enabled}`);
|
||||
if (
|
||||
strength !== undefined &&
|
||||
!(isNumber(strength) && strength >= 0 && strength <= 1)
|
||||
)
|
||||
throw new TypeError(`Bloom strength must be a number in 0..1: ${strength}`);
|
||||
return {
|
||||
enabled: enabled ?? current.enabled,
|
||||
strength: strength ?? current.strength,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GLOBE_ENGINE_MEMBERS,
|
||||
INPUT_TYPES,
|
||||
MODIFIERS,
|
||||
BLOOM_INITIAL,
|
||||
ENGINE_CAPABILITIES,
|
||||
OPTIONAL_ENGINE_MEMBERS,
|
||||
SHARPEN_INITIAL,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
createEngineEvent,
|
||||
engineCapabilities,
|
||||
isEngineEvent,
|
||||
mergeBloom,
|
||||
mergeSharpen,
|
||||
mergeStyle,
|
||||
} from './contract.js';
|
||||
@@ -449,3 +451,28 @@ test('a fake engine with visual styles passes the optional style check', async (
|
||||
});
|
||||
assert.ok(!plain.some(({ name }) => /visualStyles/.test(name)));
|
||||
});
|
||||
|
||||
test('the bloom capability: whole or absent, merged and checked', async (t) => {
|
||||
assert.ok(ENGINE_CAPABILITIES.includes('bloom'));
|
||||
assert.deepEqual(OPTIONAL_ENGINE_MEMBERS.bloom, [
|
||||
'render.setBloom',
|
||||
'render.bloom',
|
||||
]);
|
||||
const engine = createFakeEngine({ bloom: true });
|
||||
assert.equal(engineCapabilities(engine).bloom, true);
|
||||
assert.equal(engineCapabilities(engine).visualStyles, false);
|
||||
assert.deepEqual(engine.render.bloom(), BLOOM_INITIAL);
|
||||
const half = { ...engine, render: { ...engine.render, bloom: undefined } };
|
||||
assert.throws(() => assertGlobeEngine(half), /render\.bloom\b/);
|
||||
assert.deepEqual(mergeBloom(BLOOM_INITIAL, { strength: 0.5 }), {
|
||||
enabled: false,
|
||||
strength: 0.5,
|
||||
});
|
||||
for (const bad of [{ enabled: 'on' }, { strength: 2 }, { strength: -1 }])
|
||||
assert.throws(() => mergeBloom(BLOOM_INITIAL, bad), TypeError);
|
||||
const checks = createContractSuite(engine, {
|
||||
waitFrames: async (count) => engine.fake.frames(count),
|
||||
}).filter(({ name }) => /\(bloom\)/.test(name));
|
||||
assert.equal(checks.length, 1);
|
||||
for (const check of checks) await t.test(check.name, () => check.run());
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// The app's Bloom control on Navara: postprocessing's BloomEffect (mipmap
|
||||
// blur) as a Navara custom effect, with the settings of bloomSettings.js.
|
||||
// Navara 0.2.1 has no bloom of its own. It runs just before the visual
|
||||
// styles (stylePasses.js), after everything the scene and its overlays
|
||||
// draw, as Cesium's bloom stage precedes the app's post stages and glows
|
||||
// billboards too. A hidden pass is skipped: off costs nothing.
|
||||
|
||||
import { Effect as NavaraEffect, EffectDesc } from '@navaramap/three';
|
||||
import { BloomEffect } from 'postprocessing';
|
||||
import { createBloomPort } from './bloomSettings.js';
|
||||
import { pipelinePassNames } from './internals.js';
|
||||
|
||||
export const BLOOM_EFFECT_KEY = 'gevBloom';
|
||||
|
||||
function apply(effect, settings) {
|
||||
effect.luminanceMaterial.threshold = settings.luminanceThreshold;
|
||||
effect.luminanceMaterial.smoothing = settings.luminanceSmoothing;
|
||||
effect.mipmapBlurPass.radius = settings.radius;
|
||||
effect.intensity = settings.intensity;
|
||||
}
|
||||
|
||||
/** config.gevBloom: { settings } (bloomSettingsFromStrength). */
|
||||
class BloomEffectDesc extends EffectDesc {
|
||||
static key = BLOOM_EFFECT_KEY;
|
||||
// Nothing anchors after it but the styles: before them, else before the
|
||||
// final copy (the styles anchor after it).
|
||||
static insertAfter = [];
|
||||
static insertBefore = ['gevStyles', 'final'];
|
||||
|
||||
constructor(view, ctx, config) {
|
||||
super(view, ctx, config);
|
||||
this.settings = config[BLOOM_EFFECT_KEY].settings;
|
||||
config[BLOOM_EFFECT_KEY].onContext?.(ctx);
|
||||
}
|
||||
|
||||
createPass() {
|
||||
const camera = this.view.camera.raw;
|
||||
if (!camera) throw new Error('Camera not available for the bloom');
|
||||
this.effect = new BloomEffect({ mipmapBlur: true, levels: 6 });
|
||||
apply(this.effect, this.settings);
|
||||
return new NavaraEffect(camera, this.effect);
|
||||
}
|
||||
|
||||
onUpdateConfig(updates) {
|
||||
super.onUpdateConfig(updates);
|
||||
const settings = updates?.[BLOOM_EFFECT_KEY]?.settings;
|
||||
if (!settings) return;
|
||||
this.settings = settings;
|
||||
if (this.effect) apply(this.effect, settings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bloom port createNavaraEngine takes (bloomSettings.js
|
||||
* createBloomPort) on a view with the effect registered.
|
||||
* @param {object} view an initialised ThreeView
|
||||
*/
|
||||
export function createNavaraBloom(view) {
|
||||
view.registerEffect(BLOOM_EFFECT_KEY, BloomEffectDesc);
|
||||
let context = null;
|
||||
const port = createBloomPort((settings) => {
|
||||
const handle = view.addEffect({
|
||||
[BLOOM_EFFECT_KEY]: {
|
||||
settings,
|
||||
onContext: (ctx) => {
|
||||
context = ctx;
|
||||
},
|
||||
},
|
||||
visible: true,
|
||||
});
|
||||
return {
|
||||
update: ({ settings: next, visible }) =>
|
||||
handle.update({ [BLOOM_EFFECT_KEY]: { settings: next }, visible }),
|
||||
delete: () => handle.delete(),
|
||||
};
|
||||
});
|
||||
return {
|
||||
...port,
|
||||
/** Navara only (QA): whether bloom draws, and the pipeline order. */
|
||||
diagnostics: () => ({
|
||||
shown: port.shown(),
|
||||
passes: pipelinePassNames(context),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// The app's Bloom control on Navara, without Navara itself (Node tests):
|
||||
// the strength the Display slider sets (src/bloom.js
|
||||
// bloomStrengthFromIntensity, 0..1) as postprocessing BloomEffect settings,
|
||||
// and the bloom port createNavaraEngine takes. bloom.js adds the effect.
|
||||
//
|
||||
// Cesium's bloom stage (src/ui/visualEffects.js applyBloomIntensity) eases
|
||||
// the strength above a 0.06 dead zone (smoothstep) into a lower contrast, a
|
||||
// brighter offset and a wider blur: more of the frame glows, further out.
|
||||
// The same eased value drives the threshold, softness, blur radius and
|
||||
// strength here. The threshold is chosen in display (sRGB) terms, as
|
||||
// Cesium's stage reads display RGB, and handed to the effect in the linear
|
||||
// terms Navara's buffers hold (the exact transfer, as sharpen.js uses).
|
||||
|
||||
/** Strength at or under which bloom draws nothing (Cesium's dead zone). */
|
||||
export const BLOOM_DEAD_ZONE = 0.06;
|
||||
|
||||
const srgbToLinear = (value) =>
|
||||
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
|
||||
/** The dead zone removed and smoothstepped, as Cesium's stage eases it. */
|
||||
export function easedBloomStrength(strength) {
|
||||
const raw = Math.min(1, Math.max(0, Number(strength) || 0));
|
||||
if (raw <= BLOOM_DEAD_ZONE) return 0;
|
||||
const t = (raw - BLOOM_DEAD_ZONE) / (1 - BLOOM_DEAD_ZONE);
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* BloomEffect settings for a strength: the luminance threshold falls from
|
||||
* display 0.92 to 0.72, its smoothing widens, the mipmap blur reaches
|
||||
* further and the glow strengthens. At full strength a bright map keeps its
|
||||
* detail under the glow, as on Cesium (QA: qa-navara-bloom.mjs).
|
||||
*/
|
||||
export function bloomSettingsFromStrength(strength) {
|
||||
const eased = easedBloomStrength(strength);
|
||||
return {
|
||||
eased,
|
||||
luminanceThreshold: srgbToLinear(0.92 - 0.2 * eased),
|
||||
luminanceSmoothing: 0.04 + 0.14 * eased,
|
||||
radius: 0.4 + 0.4 * eased,
|
||||
intensity: 0.25 + 0.75 * eased,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bloom port createNavaraEngine takes: `set({ enabled, strength })`
|
||||
* (already checked) adds the effect the first time bloom draws, updates its
|
||||
* settings and hides it while off or in the dead zone, so a view that never
|
||||
* blooms has no pass. `add(settings)` adds the effect and returns its
|
||||
* handle ({ update, delete }); bloom.js supplies it.
|
||||
*/
|
||||
export function createBloomPort(add) {
|
||||
let handle = null;
|
||||
let shown = false;
|
||||
return {
|
||||
set({ enabled, strength }) {
|
||||
const settings = bloomSettingsFromStrength(strength);
|
||||
const next = enabled && settings.eased > 0;
|
||||
if (!handle) {
|
||||
if (!next) return;
|
||||
handle = add(settings);
|
||||
shown = true;
|
||||
return;
|
||||
}
|
||||
handle.update({ settings, visible: next });
|
||||
shown = next;
|
||||
},
|
||||
/** Navara only (QA): whether the bloom pass draws. */
|
||||
shown: () => shown,
|
||||
destroy() {
|
||||
handle?.delete();
|
||||
handle = null;
|
||||
shown = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { bloomStrengthFromIntensity } from '../../bloom.js';
|
||||
import {
|
||||
BLOOM_DEAD_ZONE,
|
||||
bloomSettingsFromStrength,
|
||||
createBloomPort,
|
||||
easedBloomStrength,
|
||||
} from './bloomSettings.js';
|
||||
|
||||
const close = (actual, expected, label) =>
|
||||
assert.ok(Math.abs(actual - expected) < 1e-9, `${label}: ${actual}`);
|
||||
|
||||
test('the strength eases above the dead zone as Cesium bloom stage does', () => {
|
||||
assert.equal(BLOOM_DEAD_ZONE, 0.06);
|
||||
assert.equal(easedBloomStrength(0), 0);
|
||||
assert.equal(easedBloomStrength(0.06), 0);
|
||||
assert.equal(easedBloomStrength(1), 1);
|
||||
assert.equal(easedBloomStrength(2), 1, 'clamped');
|
||||
assert.equal(easedBloomStrength(Number.NaN), 0);
|
||||
// VisualEffects.applyBloomIntensity: (s - 0.06) / 0.94, smoothstepped.
|
||||
const s = bloomStrengthFromIntensity(100);
|
||||
const t = (s - 0.06) / 0.94;
|
||||
close(easedBloomStrength(s), t * t * (3 - 2 * t), 'slider 100');
|
||||
});
|
||||
|
||||
test('a stronger bloom lowers the threshold and widens and strengthens the glow', () => {
|
||||
const weak = bloomSettingsFromStrength(0.2);
|
||||
const strong = bloomSettingsFromStrength(1);
|
||||
assert.ok(strong.luminanceThreshold < weak.luminanceThreshold);
|
||||
assert.ok(strong.luminanceSmoothing > weak.luminanceSmoothing);
|
||||
assert.ok(strong.radius > weak.radius);
|
||||
assert.ok(strong.intensity > weak.intensity);
|
||||
// The threshold is display 0.92..0.72 in the linear terms Navara holds.
|
||||
const linear = (v) => ((v + 0.055) / 1.055) ** 2.4;
|
||||
close(bloomSettingsFromStrength(0).luminanceThreshold, linear(0.92), 'off');
|
||||
close(strong.luminanceThreshold, linear(0.72), 'full');
|
||||
assert.equal(strong.eased, 1);
|
||||
});
|
||||
|
||||
test('the bloom port adds the effect when it first glows and hides it after', () => {
|
||||
const added = [];
|
||||
const updates = [];
|
||||
let deleted = 0;
|
||||
const port = createBloomPort((settings) => {
|
||||
added.push(settings);
|
||||
return {
|
||||
update: (next) => updates.push(next),
|
||||
delete: () => deleted++,
|
||||
};
|
||||
});
|
||||
port.set({ enabled: true, strength: 0.05 });
|
||||
port.set({ enabled: false, strength: 1 });
|
||||
assert.equal(added.length, 0, 'no glow, no pass');
|
||||
port.set({ enabled: true, strength: 1 });
|
||||
assert.equal(added.length, 1);
|
||||
assert.deepEqual(added[0], bloomSettingsFromStrength(1));
|
||||
assert.equal(port.shown(), true);
|
||||
port.set({ enabled: true, strength: 0.03 });
|
||||
assert.equal(port.shown(), false, 'the dead zone hides it');
|
||||
port.set({ enabled: true, strength: 0.5 });
|
||||
port.set({ enabled: false, strength: 0.5 });
|
||||
assert.deepEqual(
|
||||
updates.map(({ visible }) => visible),
|
||||
[false, true, false],
|
||||
);
|
||||
assert.deepEqual(updates[1].settings, bloomSettingsFromStrength(0.5));
|
||||
port.destroy();
|
||||
port.destroy();
|
||||
assert.equal(deleted, 1);
|
||||
});
|
||||
@@ -8,9 +8,11 @@
|
||||
|
||||
import * as geo from '../../geo/index.js';
|
||||
import {
|
||||
BLOOM_INITIAL,
|
||||
SHARPEN_INITIAL,
|
||||
STYLE_INITIAL,
|
||||
createEngineEvent,
|
||||
mergeBloom,
|
||||
mergeSharpen,
|
||||
mergeStyle,
|
||||
} from '../contract.js';
|
||||
@@ -90,12 +92,15 @@ function rayRaisedEllipsoid({ origin, direction }, height) {
|
||||
* clock?: object,
|
||||
* appClock?: import('../../time/clock.js').AppClock,
|
||||
* sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void },
|
||||
* bloom?: { set: (state: { enabled: boolean, strength: number }) => void, destroy: () => void },
|
||||
* styles?: { names: readonly string[], uniformNames: (name: string) => string[], defaults: (name: string) => Record<string, number>, set: (name: string, state: { intensity: number, uniforms: Record<string, number> }) => void, destroy: () => void },
|
||||
* }} options `hooks` are internals.js; `container` defaults to the
|
||||
* canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without
|
||||
* it the engine only keeps the state) and the engine destroys it;
|
||||
* `styles` draws render.setStyle (stylePasses.js; without it the engine
|
||||
* has no visualStyles capability) and the engine destroys it;
|
||||
* has no visualStyles capability) and the engine destroys it; `bloom`
|
||||
* draws render.setBloom (bloom.js; without it no bloom capability), also
|
||||
* destroyed with the engine;
|
||||
* `appClock` (the engine clock's time) defaults to the page's app clock;
|
||||
* `movers` is engine.movers on the view's three runtime (movers/index.js;
|
||||
* without it createSet throws), `lines` engine.lines (lines/index.js;
|
||||
@@ -115,6 +120,7 @@ export function createNavaraEngine(
|
||||
lines: linesPort = null,
|
||||
sharpen: sharpenPort = null,
|
||||
styles: stylesPort = null,
|
||||
bloom: bloomPort = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!view?.camera?.raw)
|
||||
@@ -310,6 +316,21 @@ export function createNavaraEngine(
|
||||
},
|
||||
}
|
||||
: {};
|
||||
let bloom = { ...BLOOM_INITIAL };
|
||||
const bloomRender = bloomPort
|
||||
? {
|
||||
setBloom(options) {
|
||||
const next = mergeBloom(bloom, options);
|
||||
const changed =
|
||||
next.enabled !== bloom.enabled || next.strength !== bloom.strength;
|
||||
bloom = next;
|
||||
if (!changed || destroyed) return;
|
||||
bloomPort.set({ ...next });
|
||||
view.forceUpdate();
|
||||
},
|
||||
bloom: () => ({ ...bloom }),
|
||||
}
|
||||
: {};
|
||||
return {
|
||||
kind: 'navara',
|
||||
host: { container: container ?? canvas.parentElement, canvas, size },
|
||||
@@ -339,6 +360,7 @@ export function createNavaraEngine(
|
||||
},
|
||||
sharpen: () => ({ ...sharpen }),
|
||||
...styleRender,
|
||||
...bloomRender,
|
||||
},
|
||||
surface,
|
||||
input: input.input,
|
||||
@@ -360,13 +382,23 @@ export function createNavaraEngine(
|
||||
maps.destroy();
|
||||
sharpenPort?.destroy();
|
||||
stylesPort?.destroy();
|
||||
bloomPort?.destroy();
|
||||
moversPort?.destroy();
|
||||
linesPort?.destroy();
|
||||
},
|
||||
isDestroyed: () => destroyed,
|
||||
// Navara only, not contract: the post pipeline as QA reads it (the
|
||||
// passes in draw order, whether the styles pass shows).
|
||||
pipeline: () => stylesPort?.diagnostics?.() ?? null,
|
||||
pipeline: () => {
|
||||
const styles = stylesPort?.diagnostics?.();
|
||||
const glow = bloomPort?.diagnostics?.();
|
||||
if (!styles && !glow) return null;
|
||||
return {
|
||||
shown: styles?.shown ?? false,
|
||||
bloomShown: glow?.shown ?? false,
|
||||
passes: styles?.passes ?? glow?.passes ?? null,
|
||||
};
|
||||
},
|
||||
// Navara only, not contract: the ground the floor last sampled under
|
||||
// the camera and the margin it keeps above it (QA measures against it).
|
||||
groundFloor: {
|
||||
|
||||
@@ -264,10 +264,39 @@ test('setStyle hands changed style states to the styles port and draws a frame',
|
||||
assert.equal(port.destroyed, 1);
|
||||
});
|
||||
|
||||
test('setBloom hands changed states to the bloom port and draws a frame', () => {
|
||||
const view = fakeView();
|
||||
const shown = [];
|
||||
let destroyed = 0;
|
||||
const engine = createNavaraEngine(view, {
|
||||
hooks,
|
||||
bloom: { set: (state) => shown.push(state), destroy: () => destroyed++ },
|
||||
});
|
||||
assertGlobeEngine(engine);
|
||||
assert.equal(engineCapabilities(engine).bloom, true);
|
||||
assert.deepEqual(engine.render.bloom(), { enabled: false, strength: 0 });
|
||||
engine.render.setBloom({ strength: 0.5 });
|
||||
engine.render.setBloom({ enabled: true });
|
||||
assert.deepEqual(view.calls.at(-1), ['forceUpdate']);
|
||||
const calls = view.calls.length;
|
||||
engine.render.setBloom({ enabled: true, strength: 0.5 });
|
||||
assert.equal(view.calls.length, calls, 'an unchanged state draws nothing');
|
||||
assert.throws(() => engine.render.setBloom({ strength: 3 }), TypeError);
|
||||
assert.deepEqual(shown, [
|
||||
{ enabled: false, strength: 0.5 },
|
||||
{ enabled: true, strength: 0.5 },
|
||||
]);
|
||||
engine.destroy();
|
||||
engine.destroy();
|
||||
assert.equal(destroyed, 1);
|
||||
});
|
||||
|
||||
test('without a styles port the engine has no visualStyles capability', () => {
|
||||
const { engine } = setup();
|
||||
assert.equal(engineCapabilities(engine).visualStyles, false);
|
||||
assert.equal(engineCapabilities(engine).bloom, false);
|
||||
assert.equal(engine.render.setStyle, undefined);
|
||||
assert.equal(engine.render.setBloom, undefined);
|
||||
});
|
||||
|
||||
test("the camera starts from Navara's drawn camera and pushes poses with setCamera", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { buildNavaraLook } from './look.js';
|
||||
import { createNavaraSharpen } from './sharpen.js';
|
||||
import { createNavaraStyles } from './stylePasses.js';
|
||||
import { createNavaraBloom } from './bloom.js';
|
||||
import { retroShader } from '../../styles/retro.js';
|
||||
import { nightVisionShader } from '../../styles/surveillance.js';
|
||||
import { thermalShader } from '../../styles/thermal.js';
|
||||
@@ -157,6 +158,7 @@ export async function createNavaraGlobe({
|
||||
container,
|
||||
...(appClock ? { appClock } : {}),
|
||||
sharpen: createNavaraSharpen(view),
|
||||
bloom: createNavaraBloom(view),
|
||||
styles: createNavaraStyles(view, {
|
||||
shaders: STYLE_SHADERS,
|
||||
onStarsDimmed: (amount) => look.setStarsDimmed(amount),
|
||||
|
||||
@@ -2771,9 +2771,68 @@ export function createContractSuite(
|
||||
const capabilities = engineCapabilities(engine);
|
||||
if (capabilities.visualStyles)
|
||||
checks.push(...styleChecks(engine.render, settle));
|
||||
if (capabilities.bloom) checks.push(...bloomChecks(engine.render, settle));
|
||||
return checks.map(([name, run]) => ({ name, run }));
|
||||
}
|
||||
|
||||
// The optional bloom capability (render.setBloom).
|
||||
function bloomChecks(render, settle) {
|
||||
return [
|
||||
[
|
||||
'render.setBloom (bloom): merges, copies, rejects bad values, draws',
|
||||
async () => {
|
||||
const previous = render.bloom();
|
||||
ensure(
|
||||
typeof previous.enabled === 'boolean' &&
|
||||
Number.isFinite(previous.strength),
|
||||
`bloom() ${JSON.stringify(previous)} is not { enabled, strength }`,
|
||||
);
|
||||
const same = (expected, label) => {
|
||||
const actual = render.bloom();
|
||||
ensure(
|
||||
actual.enabled === expected.enabled &&
|
||||
actual.strength === expected.strength,
|
||||
`${label}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`,
|
||||
);
|
||||
};
|
||||
try {
|
||||
render.setBloom({ enabled: true, strength: 0.8 });
|
||||
same({ enabled: true, strength: 0.8 }, 'after enabling');
|
||||
render.bloom().strength = 0;
|
||||
same({ enabled: true, strength: 0.8 }, 'bloom() is a copy');
|
||||
// A frame with the bloom on draws without errors.
|
||||
await settle(2);
|
||||
render.setBloom({ strength: 0.05 });
|
||||
same({ enabled: true, strength: 0.05 }, 'in the dead zone');
|
||||
render.setBloom({ enabled: false });
|
||||
same({ enabled: false, strength: 0.05 }, 'after disabling');
|
||||
for (const bad of [
|
||||
{ enabled: 1 },
|
||||
{ strength: -0.1 },
|
||||
{ strength: 1.5 },
|
||||
{ strength: Number.NaN },
|
||||
]) {
|
||||
let error = null;
|
||||
try {
|
||||
render.setBloom(bad);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
ensure(
|
||||
error instanceof TypeError,
|
||||
`setBloom(${JSON.stringify(bad)}) did not throw a TypeError`,
|
||||
);
|
||||
}
|
||||
same({ enabled: false, strength: 0.05 }, 'after rejected values');
|
||||
} finally {
|
||||
render.setBloom(previous);
|
||||
await settle(1);
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// The optional visualStyles capability (render.setStyle).
|
||||
function styleChecks(render, settle) {
|
||||
return [
|
||||
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
INPUT_TYPES,
|
||||
MODIFIERS,
|
||||
normalizeInputOptions,
|
||||
BLOOM_INITIAL,
|
||||
SHARPEN_INITIAL,
|
||||
STYLE_INITIAL,
|
||||
createEngineEvent,
|
||||
mergeBloom,
|
||||
mergeSharpen,
|
||||
mergeStyle,
|
||||
} from '../contract.js';
|
||||
@@ -229,6 +231,19 @@ function rectangleFromCartographics(points) {
|
||||
return { west, south, east, north };
|
||||
}
|
||||
|
||||
// render.setBloom/bloom when asked; nothing otherwise.
|
||||
function fakeBloom(enabled, record) {
|
||||
if (!enabled) return {};
|
||||
let bloom = { ...BLOOM_INITIAL };
|
||||
return {
|
||||
setBloom(options) {
|
||||
record('render.setBloom', [options]);
|
||||
bloom = mergeBloom(bloom, options);
|
||||
},
|
||||
bloom: () => ({ ...bloom }),
|
||||
};
|
||||
}
|
||||
|
||||
// render.visualStyles/setStyle/style for `visualStyles` ({ name: uniform
|
||||
// names }); nothing without it.
|
||||
function fakeStyles(visualStyles, record) {
|
||||
@@ -264,7 +279,7 @@ function fakeStyles(visualStyles, record) {
|
||||
* longitude?: number, latitude?: number, altitude?: number,
|
||||
* container?: object, canvas?: object, nowMs?: number,
|
||||
* appClock?: import('../../time/clock.js').AppClock,
|
||||
* visualStyles?: Record<string, string[]> }} [options]
|
||||
* visualStyles?: Record<string, string[]>, bloom?: boolean }} [options]
|
||||
* Canvas size in CSS px, angles in radians; `container`/`canvas` stand in
|
||||
* for the host elements (DOM mocks for shell tests). The initial pose looks straight
|
||||
* down from `altitude` metres (10 000 km). The engine clock reads
|
||||
@@ -272,7 +287,8 @@ function fakeStyles(visualStyles, record) {
|
||||
* `nowMs` (2026-01-01T00:00Z) and moves only with `fake.frame(dt)` and
|
||||
* `fake.advanceTime(ms)` (a given appClock keeps its own wall clock).
|
||||
* `visualStyles` ({ style: its uniform names }) adds the optional
|
||||
* visualStyles capability (render.setStyle), each uniform starting at 0.
|
||||
* visualStyles capability (render.setStyle), each uniform starting at 0;
|
||||
* `bloom: true` the optional bloom capability (render.setBloom).
|
||||
* @returns {import('../contract.js').GlobeEngine & { fake: object }}
|
||||
*/
|
||||
export function createFakeEngine(options = {}) {
|
||||
@@ -959,6 +975,7 @@ export function createFakeEngine(options = {}) {
|
||||
},
|
||||
sharpen: () => ({ ...sharpen }),
|
||||
...fakeStyles(options.visualStyles, record),
|
||||
...fakeBloom(options.bloom, record),
|
||||
},
|
||||
surface: {
|
||||
sampleHeight(position, exclude) {
|
||||
|
||||
+26
-5
@@ -53,7 +53,8 @@ export class VisualEffects {
|
||||
/**
|
||||
* @param {object} options - Viewer, render ownership callbacks and optional clocks.
|
||||
* Without a viewer, `engine` (the globe engine) draws the sharpening
|
||||
* through render.setSharpen. Construction is inert; initStyles and
|
||||
* through render.setSharpen, and the styles and bloom through
|
||||
* render.setStyle and render.setBloom when it has them. Construction is inert; initStyles and
|
||||
* initPostProcess retain startup ordering.
|
||||
*/
|
||||
constructor({
|
||||
@@ -76,8 +77,11 @@ export class VisualEffects {
|
||||
typeof engine?.render?.setSharpen === 'function' ? engine.render : null;
|
||||
// Without a viewer, an engine that draws the styles itself
|
||||
// (render.setStyle) gets them through stand-in stages (initStyles).
|
||||
const capabilities = engineCapabilities(engine);
|
||||
this.engineStyles =
|
||||
!viewer && engineCapabilities(engine).visualStyles ? engine.render : null;
|
||||
!viewer && capabilities.visualStyles ? engine.render : null;
|
||||
// Likewise the bloom (render.setBloom) for Cesium's bloom stage.
|
||||
this.engineBloom = !viewer && capabilities.bloom ? engine.render : null;
|
||||
this.requestRender = requestRender;
|
||||
this.holdRender = holdRender;
|
||||
this.releaseRender = releaseRender;
|
||||
@@ -136,7 +140,16 @@ export class VisualEffects {
|
||||
}
|
||||
|
||||
initPostProcess(sharpenIntensity = this.sharpenIntensity) {
|
||||
if (!this.viewer || this.stopped || this.sharpenStage) return;
|
||||
if (this.stopped || this.sharpenStage) return;
|
||||
// An engine that blooms starts from this owner's bloom (off), whatever
|
||||
// a previous owner left on it.
|
||||
if (!this.viewer) {
|
||||
this.engineBloom?.setBloom({
|
||||
enabled: false,
|
||||
strength: bloomStrengthFromIntensity(this.bloomIntensity),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.bloomStage = this.viewer.scene.postProcessStages.bloom;
|
||||
const names = [
|
||||
'glowOnly',
|
||||
@@ -195,16 +208,24 @@ export class VisualEffects {
|
||||
}
|
||||
|
||||
syncBloomEnabled() {
|
||||
if (this.stopped || !this.bloomStage) return;
|
||||
this.bloomStage.enabled =
|
||||
if (this.stopped) return;
|
||||
const enabled =
|
||||
this.bloomEnabled &&
|
||||
bloomStrengthFromIntensity(this.bloomIntensity) > 0.06;
|
||||
if (this.bloomStage) this.bloomStage.enabled = enabled;
|
||||
this.engineBloom?.setBloom({ enabled });
|
||||
}
|
||||
|
||||
applyBloomIntensity(intensity) {
|
||||
if (this.stopped) return;
|
||||
this.bloomIntensity = clampBloomIntensity(intensity);
|
||||
this.requestRender('bloom');
|
||||
if (this.engineBloom) {
|
||||
this.engineBloom.setBloom({
|
||||
strength: bloomStrengthFromIntensity(this.bloomIntensity),
|
||||
});
|
||||
this.syncBloomEnabled();
|
||||
}
|
||||
if (!this.bloomStage) return;
|
||||
const rawStrength = bloomStrengthFromIntensity(this.bloomIntensity);
|
||||
const strength = rawStrength <= 0.06 ? 0.0 : (rawStrength - 0.06) / 0.94;
|
||||
|
||||
@@ -459,6 +459,33 @@ test('Anime on an engine without a viewer reaches no sky box', () => {
|
||||
effects.destroy();
|
||||
});
|
||||
|
||||
test('without a viewer an engine that blooms gets the slider strength and dead zone', () => {
|
||||
const { engine, effects, requests } = engineFixture(
|
||||
createFakeEngine({ kind: 'navara', bloom: true }),
|
||||
);
|
||||
engine.render.setBloom({ enabled: true, strength: 1 });
|
||||
effects.initPostProcess(0.49);
|
||||
assert.deepEqual(
|
||||
engine.render.bloom(),
|
||||
{ enabled: false, strength: 0 },
|
||||
'a new owner starts the engine from its own bloom',
|
||||
);
|
||||
effects.applyBloomIntensity(150);
|
||||
assert.deepEqual(engine.render.bloom(), { enabled: false, strength: 0.75 });
|
||||
effects.setBloomEnabled(true);
|
||||
assert.deepEqual(engine.render.bloom(), { enabled: true, strength: 0.75 });
|
||||
// Cesium's dead zone: the toggle stays on, nothing glows.
|
||||
effects.applyBloomIntensity(10);
|
||||
assert.deepEqual(engine.render.bloom(), { enabled: false, strength: 0.05 });
|
||||
effects.applyBloomIntensity(200);
|
||||
assert.deepEqual(engine.render.bloom(), { enabled: true, strength: 1 });
|
||||
effects.setBloomEnabled(false);
|
||||
assert.equal(engine.render.bloom().enabled, false);
|
||||
assert.ok(requests.includes('bloom'));
|
||||
assert.equal(effects.bloomStage, null);
|
||||
effects.destroy();
|
||||
});
|
||||
|
||||
test('without the visualStyles capability an engine gets no stages', () => {
|
||||
const { effects } = engineFixture(createFakeEngine({ kind: 'navara' }));
|
||||
effects.initStyles();
|
||||
|
||||
Reference in new issue
Block a user