diff --git a/scripts/qa-navara-bloom.mjs b/scripts/qa-navara-bloom.mjs new file mode 100644 index 000000000..41a7dd1c7 --- /dev/null +++ b/scripts/qa-navara-bloom.mjs @@ -0,0 +1,201 @@ +// The app's Bloom on Navara (Stage 6 B2): the Display bloom glows the +// Navara frame (brighter, more at a higher slider), it sits after the +// overlays and before the visual styles, Cesium's dead zone draws nothing, +// turning it off restores the frame exactly, and on Cesium the scene's own +// bloom stage still runs with the engine left alone. Keyless preview under +// the strict default-deny network policy. +// +// node scripts/qa-navara-bloom.mjs http://127.0.0.1:/ [--out=DIR] + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + createStrict, + localPreview, + waitForEngine, +} from './qa-strict-harness.mjs'; +import { + capture, + checker, + diff, + freeze, + pickStyle, + saveCapture, + stats, + visualPageUrl, +} from './qa/navaraVisual.mjs'; + +const base = localPreview(process.argv[2]); +const outDir = process.argv + .find((arg) => arg.startsWith('--out=')) + ?.slice('--out='.length); +const strict = createStrict({ origin: base.origin, layers: [] }); +const report = { checks: [] }; +const check = checker(report, assert); +const save = (name, pixels) => saveCapture(outDir, name, pixels); +const luma = (s) => 0.299 * s.r + 0.587 * s.g + 0.114 * s.b; + +// The Display bloom, as its toggle and slider set it. +const setBloom = (page, enabled, intensityPct) => + page.evaluate( + (enabled, intensityPct) => { + const a = window.__godsEyeView; + a.styleManager.setBloom({ enabled, intensityPct }); + return { + control: a.styleManager.getControlState().bloom, + engine: a.engine.render.bloom?.() ?? null, + pipeline: a.engine.pipeline?.() ?? null, + }; + }, + enabled, + intensityPct, + ); + +await strict.run( + { args: ['--use-angle=metal', '--enable-gpu'] }, + async (browser) => { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.evaluateOnNewDocument(() => { + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + }); + await page.setViewport({ width: 1280, height: 800 }); + await page.goto(visualPageUrl(base.origin, 'navara'), { + waitUntil: 'domcontentloaded', + }); + await waitForEngine(page, 'navara'); + await freeze(page); + + const initial = await page.evaluate(() => { + const { engine } = window.__godsEyeView; + return { bloom: engine.render.bloom(), pipeline: engine.pipeline() }; + }); + check( + 'Navara blooms itself and starts with bloom off', + initial.bloom.enabled === false && !initial.pipeline?.bloomShown, + initial, + ); + const normal = await capture(page); + save('normal', normal); + const normalStats = stats(normal); + + const full = await setBloom(page, true, 200); + check( + 'the slider at 200 reaches the engine at full strength', + full.engine.enabled === true && + full.engine.strength === 1 && + full.pipeline.bloomShown === true, + full, + ); + const order = full.pipeline.passes ?? []; + const at = order.indexOf('gevBloom'); + check( + 'bloom runs after the scene and overlays, just before the final copy', + at >= 0 && + order[at + 1] === 'final' && + ['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every( + (key) => !order.includes(key) || order.indexOf(key) < at, + ), + order, + ); + const glowFull = await capture(page); + save('bloom-200', glowFull); + const fullChange = diff(normal, glowFull); + const fullStats = stats(glowFull); + check( + 'bloom at 200 glows the frame brighter', + fullChange.mean > 3 && luma(fullStats) > luma(normalStats) + 2, + { + ...fullChange, + normalLuma: luma(normalStats), + bloomLuma: luma(fullStats), + }, + ); + + await setBloom(page, true, 100); + const glowHalf = await capture(page); + save('bloom-100', glowHalf); + const halfChange = diff(normal, glowHalf); + check( + 'a lower slider glows less', + halfChange.mean > 0.5 && halfChange.mean < fullChange.mean, + { half: halfChange.mean, full: fullChange.mean }, + ); + + const dead = await setBloom(page, true, 10); + const deadFrame = await capture(page); + check( + "Cesium's dead zone: the toggle on, nothing glows", + dead.engine.enabled === false && + dead.pipeline.bloomShown === false && + diff(normal, deadFrame).mean === 0, + { engine: dead.engine, diff: diff(normal, deadFrame) }, + ); + + // With a style: bloom first, then the style, then the final copy. + await setBloom(page, true, 200); + const styled = await pickStyle(page, 'noir', 'normal'); + const styledOrder = styled.pipeline?.passes ?? []; + check( + 'bloom runs before the visual styles', + styledOrder.indexOf('gevBloom') >= 0 && + styledOrder.indexOf('gevBloom') + 1 === + styledOrder.indexOf('gevStyles') && + styledOrder.indexOf('gevStyles') + 1 === styledOrder.indexOf('final'), + styledOrder, + ); + await pickStyle(page, 'normal', 'noir'); + + const off = await setBloom(page, false, 200); + const offFrame = await capture(page); + check( + 'turning bloom off restores the frame', + off.engine.enabled === false && + off.pipeline.bloomShown === false && + diff(normal, offFrame).mean === 0, + { engine: off.engine, diff: diff(normal, offFrame) }, + ); + + // Cesium keeps its scene bloom stage; its engine has no bloom. + await strict.switchTo(page, 'cesium'); + await freeze(page); + await setBloom(page, false, 200); + const cesiumNormal = await capture(page); + const cesium = await setBloom(page, true, 200); + const cesiumStage = await page.evaluate(() => { + const a = window.__godsEyeView; + const stage = a.viewer?.scene?.postProcessStages?.bloom; + return { + setBloom: typeof a.engine.render.setBloom, + enabled: stage?.enabled ?? null, + contrast: stage?.uniforms?.contrast ?? null, + }; + }); + const cesiumGlow = await capture(page); + save('cesium-bloom-200', cesiumGlow); + check( + "Cesium: its scene's bloom stage glows, the engine draws none", + cesium.engine === null && + cesiumStage.setBloom === 'undefined' && + cesiumStage.enabled !== false && + diff(cesiumNormal, cesiumGlow).mean > 1, + { stage: cesiumStage, diff: diff(cesiumNormal, cesiumGlow) }, + ); + await setBloom(page, false, 200); + const cesiumBack = diff(cesiumNormal, await capture(page)); + check( + 'Cesium: bloom off restores its frame', + cesiumBack.mean < 0.5, + cesiumBack, + ); + + check('no page errors', errors.length === 0, errors); + if (outDir) + fs.writeFileSync( + path.join(outDir, 'bloom.json'), + JSON.stringify(report, null, 2), + ); + }, +); diff --git a/scripts/qa-navara-visual-styles.mjs b/scripts/qa-navara-visual-styles.mjs index 30c552ec2..de19400e1 100644 --- a/scripts/qa-navara-visual-styles.mjs +++ b/scripts/qa-navara-visual-styles.mjs @@ -11,192 +11,38 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import sharp from 'sharp'; import { createStrict, localPreview, waitForEngine, } from './qa-strict-harness.mjs'; +import { + STILL_VIEW, + capture, + checker, + diff, + freeze, + histogramShift, + pickStyle, + saveCapture, + stats, + visualPageUrl, +} from './qa/navaraVisual.mjs'; const base = localPreview(process.argv[2]); const outDir = process.argv .find((arg) => arg.startsWith('--out=')) ?.slice('--out='.length); -const VIEW = - 'v=2&lat=30.2672&lon=-97.7431&alt=20000&heading=0&pitch=-70&map=osm&l='; const STYLES = ['retro', 'surveillance', 'thermal', 'anime', 'noir']; const URL_NAMES = { retro: 'crt', surveillance: 'nvg', thermal: 'flir' }; const pageUrl = (engine, extra = '') => - `${base.origin}/?engine=${engine}&welcome=0&look=cinematic#${VIEW}${extra}`; + visualPageUrl(base.origin, engine, extra, STILL_VIEW); const strict = createStrict({ origin: base.origin, layers: [] }); const report = { checks: [] }; -const check = (name, pass, detail) => { - report.checks.push({ name, pass: Boolean(pass), detail }); - console.log( - pass ? 'PASS' : 'FAIL', - name, - detail ? JSON.stringify(detail) : '', - ); - assert.ok(pass, `${name} ${JSON.stringify(detail)}`); -}; +const check = checker(report, assert); -// The engine canvas as drawn, downscaled to 160×100 RGBA in the page, read -// inside the engine's postRender (Cesium keeps no drawing buffer after it). -async function capture(page) { - return page.evaluate(async () => { - const { engine } = window.__godsEyeView; - const canvas = document.createElement('canvas'); - canvas.width = 160; - canvas.height = 100; - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - await new Promise((resolve) => { - const off = engine.frames.postRender.addEventListener(() => { - off(); - ctx.drawImage(engine.host.canvas, 0, 0, 160, 100); - resolve(); - }); - engine.render.request('qa-visual-styles'); - }); - return Array.from(ctx.getImageData(0, 0, 160, 100).data); - }); -} - -function stats(pixels) { - let r = 0, - g = 0, - b = 0, - saturation = 0; - const histogram = new Array(16).fill(0); - const count = pixels.length / 4; - for (let i = 0; i < pixels.length; i += 4) { - const [pr, pg, pb] = [pixels[i], pixels[i + 1], pixels[i + 2]]; - r += pr; - g += pg; - b += pb; - saturation += Math.max(pr, pg, pb) - Math.min(pr, pg, pb); - histogram[ - Math.min(15, Math.floor((0.299 * pr + 0.587 * pg + 0.114 * pb) / 16)) - ]++; - } - // The vignette: the corners' luma over the centre's (160×100 capture). - const luma = (x0, y0) => { - let sum = 0; - for (let y = y0; y < y0 + 10; y++) - for (let x = x0; x < x0 + 16; x++) { - const i = (y * 160 + x) * 4; - sum += - 0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2]; - } - return sum / 160; - }; - const corners = (luma(0, 0) + luma(144, 0) + luma(0, 90) + luma(144, 90)) / 4; - return { - vignette: corners / Math.max(1, luma(72, 45)), - r: r / count, - g: g / count, - b: b / count, - saturation: saturation / count, - histogram: histogram.map((n) => n / count), - }; -} - -function diff(a, b) { - let sum = 0; - let changed = 0; - for (let i = 0; i < a.length; i += 4) { - const d = - Math.abs(a[i] - b[i]) + - Math.abs(a[i + 1] - b[i + 1]) + - Math.abs(a[i + 2] - b[i + 2]); - sum += d / 3; - if (d / 3 > 8) changed++; - } - const count = a.length / 4; - return { mean: sum / count, changedShare: changed / count }; -} - -// Histogram distance (half the L1 of two normalised histograms, 0..1). -const histogramShift = (a, b) => - a.histogram.reduce( - (sum, value, i) => sum + Math.abs(value - b.histogram[i]), - 0, - ) / 2; - -async function freeze(page) { - await page.evaluate(async () => { - const a = window.__godsEyeView; - if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset) - throw new Error('Keyless preview required'); - a.styleManager.orbitController.stop(); - a.engine.camera.cancelFlight(); - a.styleManager._setDetectionMode?.('OFF'); - for (const [id, row] of a.dataManager.layers) - if (row.enabled) await a.dataManager.setEnabled(id, false); - }); - await page.waitForFunction( - () => window.__godsEyeView.engine.surface.tilesLoaded(), - { timeout: 90000, polling: 250 }, - ); - await new Promise((resolve) => setTimeout(resolve, 2500)); -} - -// Picks a style the way the style keys do, then follows the crossfade on -// the engine every frame until VisualEffects has no transition left. -async function pickStyle(page, name, previous) { - return page.evaluate( - async (name, previous) => { - const a = window.__godsEyeView; - const render = a.engine.render; - const drawn = typeof render.setStyle === 'function'; - const intensity = (style) => - !style || style === 'normal' - ? 0 - : drawn - ? render.style(style).intensity - : a.styleManager.stages[style].uniforms.intensity; - const samples = []; - const started = performance.now(); - a.styleManager.setStyle(name, { userInitiated: true }); - while (performance.now() - started < 3000) { - await new Promise((resolve) => requestAnimationFrame(resolve)); - samples.push([intensity(previous), intensity(name)]); - if (a.styleManager.transitions.size === 0) break; - } - return { - ms: performance.now() - started, - pending: a.styleManager.transitions.size, - samples, - active: a.styleManager.activeStyle, - intensities: drawn - ? Object.fromEntries( - render.visualStyles.map((style) => [ - style, - render.style(style).intensity, - ]), - ) - : null, - pipeline: a.engine.pipeline?.() ?? null, - }; - }, - name, - previous, - ); -} - -const save = (name, pixels) => { - if (!outDir) return; - fs.mkdirSync(outDir, { recursive: true }); - void sharp(Buffer.from(pixels), { - raw: { width: 160, height: 100, channels: 4 }, - }) - .png() - .toFile(path.join(outDir, `${name}.png`)); - fs.writeFileSync( - path.join(outDir, `${name}.json`), - JSON.stringify(stats(pixels)), - ); -}; +const save = (name, pixels) => saveCapture(outDir, name, pixels); await strict.run( { args: ['--use-angle=metal', '--enable-gpu'] }, diff --git a/scripts/qa/navaraVisual.mjs b/scripts/qa/navaraVisual.mjs new file mode 100644 index 000000000..6da59e8b9 --- /dev/null +++ b/scripts/qa/navaraVisual.mjs @@ -0,0 +1,186 @@ +// Shared by the Navara visual QA scenarios (qa-navara-visual-styles, +// qa-navara-bloom, qa-navara-sonar, qa-navara-cockpit-clouds): read the +// engine canvas, compare frames, settle a still view, follow a style +// crossfade. + +import fs from 'node:fs'; +import path from 'node:path'; +import sharp from 'sharp'; + +/** A still view over Austin, no layers, OSM imagery. */ +export const STILL_VIEW = + 'v=2&lat=30.2672&lon=-97.7431&alt=20000&heading=0&pitch=-70&map=osm&l='; + +/** The keyless page on `engine` with the still view (and `extra` hash). */ +export const visualPageUrl = (origin, engine, extra = '', view = STILL_VIEW) => + `${origin}/?engine=${engine}&welcome=0&look=cinematic#${view}${extra}`; + +/** A check that logs, records into `report` and asserts. */ +export function checker(report, assert) { + return (name, pass, detail) => { + report.checks.push({ name, pass: Boolean(pass), detail }); + console.log( + pass ? 'PASS' : 'FAIL', + name, + detail ? JSON.stringify(detail) : '', + ); + assert.ok(pass, `${name} ${JSON.stringify(detail)}`); + }; +} + +// The engine canvas as drawn, downscaled to 160×100 RGBA in the page, read +// inside the engine's postRender (Cesium keeps no drawing buffer after it). +export async function capture(page) { + return page.evaluate(async () => { + const { engine } = window.__godsEyeView; + const canvas = document.createElement('canvas'); + canvas.width = 160; + canvas.height = 100; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + await new Promise((resolve) => { + const off = engine.frames.postRender.addEventListener(() => { + off(); + ctx.drawImage(engine.host.canvas, 0, 0, 160, 100); + resolve(); + }); + engine.render.request('qa-visual-styles'); + }); + return Array.from(ctx.getImageData(0, 0, 160, 100).data); + }); +} + +export function stats(pixels) { + let r = 0, + g = 0, + b = 0, + saturation = 0; + const histogram = new Array(16).fill(0); + const count = pixels.length / 4; + for (let i = 0; i < pixels.length; i += 4) { + const [pr, pg, pb] = [pixels[i], pixels[i + 1], pixels[i + 2]]; + r += pr; + g += pg; + b += pb; + saturation += Math.max(pr, pg, pb) - Math.min(pr, pg, pb); + histogram[ + Math.min(15, Math.floor((0.299 * pr + 0.587 * pg + 0.114 * pb) / 16)) + ]++; + } + // The vignette: the corners' luma over the centre's (160×100 capture). + const luma = (x0, y0) => { + let sum = 0; + for (let y = y0; y < y0 + 10; y++) + for (let x = x0; x < x0 + 16; x++) { + const i = (y * 160 + x) * 4; + sum += + 0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2]; + } + return sum / 160; + }; + const corners = (luma(0, 0) + luma(144, 0) + luma(0, 90) + luma(144, 90)) / 4; + return { + vignette: corners / Math.max(1, luma(72, 45)), + r: r / count, + g: g / count, + b: b / count, + saturation: saturation / count, + histogram: histogram.map((n) => n / count), + }; +} + +export function diff(a, b) { + let sum = 0; + let changed = 0; + for (let i = 0; i < a.length; i += 4) { + const d = + Math.abs(a[i] - b[i]) + + Math.abs(a[i + 1] - b[i + 1]) + + Math.abs(a[i + 2] - b[i + 2]); + sum += d / 3; + if (d / 3 > 8) changed++; + } + const count = a.length / 4; + return { mean: sum / count, changedShare: changed / count }; +} + +// Histogram distance (half the L1 of two normalised histograms, 0..1). +export const histogramShift = (a, b) => + a.histogram.reduce( + (sum, value, i) => sum + Math.abs(value - b.histogram[i]), + 0, + ) / 2; + +export async function freeze(page) { + await page.evaluate(async () => { + const a = window.__godsEyeView; + if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset) + throw new Error('Keyless preview required'); + a.styleManager.orbitController.stop(); + a.engine.camera.cancelFlight(); + a.styleManager._setDetectionMode?.('OFF'); + for (const [id, row] of a.dataManager.layers) + if (row.enabled) await a.dataManager.setEnabled(id, false); + }); + await page.waitForFunction( + () => window.__godsEyeView.engine.surface.tilesLoaded(), + { timeout: 90000, polling: 250 }, + ); + await new Promise((resolve) => setTimeout(resolve, 2500)); +} + +// Picks a style the way the style keys do, then follows the crossfade on +// the engine every frame until VisualEffects has no transition left. +export async function pickStyle(page, name, previous) { + return page.evaluate( + async (name, previous) => { + const a = window.__godsEyeView; + const render = a.engine.render; + const drawn = typeof render.setStyle === 'function'; + const intensity = (style) => + !style || style === 'normal' + ? 0 + : drawn + ? render.style(style).intensity + : a.styleManager.stages[style].uniforms.intensity; + const samples = []; + const started = performance.now(); + a.styleManager.setStyle(name, { userInitiated: true }); + while (performance.now() - started < 3000) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + samples.push([intensity(previous), intensity(name)]); + if (a.styleManager.transitions.size === 0) break; + } + return { + ms: performance.now() - started, + pending: a.styleManager.transitions.size, + samples, + active: a.styleManager.activeStyle, + intensities: drawn + ? Object.fromEntries( + render.visualStyles.map((style) => [ + style, + render.style(style).intensity, + ]), + ) + : null, + pipeline: a.engine.pipeline?.() ?? null, + }; + }, + name, + previous, + ); +} + +export const saveCapture = (outDir, name, pixels) => { + if (!outDir) return; + fs.mkdirSync(outDir, { recursive: true }); + void sharp(Buffer.from(pixels), { + raw: { width: 160, height: 100, channels: 4 }, + }) + .png() + .toFile(path.join(outDir, `${name}.png`)); + fs.writeFileSync( + path.join(outDir, `${name}.json`), + JSON.stringify(stats(pixels)), + ); +}; diff --git a/src/globe/contract.js b/src/globe/contract.js index c6a111cb1..d074b453b 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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 }} 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, + }; +} diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index f7642d099..5705a995c 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -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()); +}); diff --git a/src/globe/navara/bloom.js b/src/globe/navara/bloom.js new file mode 100644 index 000000000..d7bb2b62e --- /dev/null +++ b/src/globe/navara/bloom.js @@ -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), + }), + }; +} diff --git a/src/globe/navara/bloomSettings.js b/src/globe/navara/bloomSettings.js new file mode 100644 index 000000000..763797c02 --- /dev/null +++ b/src/globe/navara/bloomSettings.js @@ -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; + }, + }; +} diff --git a/src/globe/navara/bloomSettings.test.mjs b/src/globe/navara/bloomSettings.test.mjs new file mode 100644 index 000000000..75f1f2d08 --- /dev/null +++ b/src/globe/navara/bloomSettings.test.mjs @@ -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); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 350dcb1d6..0c0167a5f 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -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, set: (name: string, state: { intensity: number, uniforms: Record }) => 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: { diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 714bc2d37..9752999e7 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -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", () => { diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 8f0b86b30..76380745a 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -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), diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 041191bc4..e0b6f5c18 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -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 [ diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 6952f1b0f..ac3170f88 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -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 }} [options] + * visualStyles?: Record, 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) { diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index 93b949b10..957a8f929 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -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; diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index b01aa3e7c..ac4426346 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -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();