From bb8c0b83c43877a4ccb5dd2f3e926500f48b1340 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 13:30:56 +0400 Subject: [PATCH] feat(globe): Sharpen on Navara, on at 80 % by default The contract gains render.setSharpen({ enabled, amount }) and render.sharpen(). Navara draws it as a custom EffectDesc: Cesium's unsharp mask (3x3 box blur, gamma 2.2 round trip so one amount matches Cesium) right after tone mapping and before SMAA/FXAA, skipping sky and cloud pixels by scene depth. The Cesium engine keeps the state only; Cesium mode still sharpens with its own post stage, unchanged. The fake engine records it and the contract suite checks it. Without a viewer the Sharpen slider and toggle drive the engine with the same slider-to-amount mapping. On Navara the defaults, presets and share links read Cesium's 49 % as 80 %. --- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 1 + src/globe/cesium/render.js | 8 + src/globe/contract.js | 32 ++++ src/globe/contract.test.mjs | 22 +++ src/globe/navara/createNavaraEngine.js | 25 +++- src/globe/navara/createNavaraEngine.test.mjs | 34 +++++ src/globe/navara/index.js | 2 + src/globe/navara/sharpen.js | 147 +++++++++++++++++++ src/globe/testing/contractSuite.mjs | 53 +++++++ src/globe/testing/fakeEngine.js | 14 +- src/ui/applicationShell.js | 1 + src/ui/stateOwners.test.mjs | 56 +++++++ src/ui/visualEffects.js | 19 ++- src/ui/visualEffects.test.mjs | 58 +++++++- src/ui/visualPresets.js | 27 +++- src/ui/visualSettings.js | 37 ++++- 17 files changed, 521 insertions(+), 16 deletions(-) create mode 100644 src/globe/navara/sharpen.js diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 91f724cbc..d35da2367 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1049,6 +1049,7 @@ "src/globe/navara/internals.test.mjs", "src/globe/navara/maps.js", "src/globe/navara/maps.test.mjs", + "src/globe/navara/sharpen.js", "src/globe/navara/surface.js", "src/globe/navara/surface.test.mjs", "src/globe/navara/viewOptions.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 0487148a2..2b6f6e582 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1770,6 +1770,7 @@ "src/globe/navara/internals.js", "src/globe/navara/look.js", "src/globe/navara/maps.js", + "src/globe/navara/sharpen.js", "src/globe/navara/surface.js", "src/globe/navara/viewOptions.js", "src/app/engineChoice.js", diff --git a/src/globe/cesium/render.js b/src/globe/cesium/render.js index 6c15ac522..2f91f6e5f 100644 --- a/src/globe/cesium/render.js +++ b/src/globe/cesium/render.js @@ -8,6 +8,7 @@ import { isRenderGovernorInstalled, releaseContinuousRender, } from '../../renderGovernor.js'; +import { SHARPEN_INITIAL, mergeSharpen } from '../contract.js'; export const RENDER_OWNER = 'globe-engine'; @@ -28,6 +29,7 @@ export function createCesiumRender( } = {}, ) { let held = false; + let sharpen = { ...SHARPEN_INITIAL }; const render = { request(reason = RENDER_OWNER) { requestRender(reason); @@ -39,6 +41,12 @@ export function createCesiumRender( }, // The scene's real mode: another owner's hold keeps it continuous. isContinuous: () => viewer.scene.requestRenderMode === false, + // State only: in Cesium mode the app's own post stage sharpens + // (src/ui/visualEffects.js), and the scene stays as it was. + setSharpen(options) { + sharpen = mergeSharpen(sharpen, options); + }, + sharpen: () => ({ ...sharpen }), }; return { render, diff --git a/src/globe/contract.js b/src/globe/contract.js index a966eafae..8d07e22d9 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -160,6 +160,16 @@ * Owns whatever the engine needs to render every frame or only on request * (Cesium: requestRenderMode and maximumRenderTimeChange = Infinity). * @property {() => boolean} isContinuous + * @property {(options: { enabled?: boolean, amount?: number }) => void} setSharpen + * An unsharp mask on the final image, after tone mapping and before + * anti-aliasing: each pixel moves away from its 3×3 box blur by `amount` + * (the maths of the app's Cesium sharpen stage, whose `amount` uniform the + * Sharpen slider sets in 0.1..2.1). Omitted fields keep their value; a + * non-boolean `enabled` or a negative or non-finite `amount` throws a + * TypeError. Off with amount 1 until set. The Cesium engine only keeps the + * state: in Cesium mode the app's own post stage sharpens. + * @property {() => { enabled: boolean, amount: number }} sharpen + * The sharpening last set (a copy). * * @typedef {object} PickResult * @property {any} id The picked entity or object id; undefined for map tiles. @@ -382,6 +392,8 @@ const SHAPE = { request: isFunction, setContinuous: isFunction, isContinuous: isFunction, + setSharpen: isFunction, + sharpen: isFunction, }, surface: { sampleHeight: isFunction, @@ -448,3 +460,23 @@ export function assertGlobeEngine(engine) { ); return engine; } + +/** The sharpening an engine starts with (GlobeRender.sharpen). */ +export const SHARPEN_INITIAL = Object.freeze({ enabled: false, amount: 1 }); + +/** + * GlobeRender.setSharpen's argument merged onto `current`: a new state, or a + * TypeError for a non-boolean `enabled` or a negative or non-finite `amount`. + */ +export function mergeSharpen(current, { enabled, amount } = {}) { + if (enabled !== undefined && !isBoolean(enabled)) + throw new TypeError(`Sharpen enabled must be a boolean: ${enabled}`); + if (amount !== undefined && !(isNumber(amount) && amount >= 0)) + throw new TypeError( + `Sharpen amount must be a finite number >= 0: ${amount}`, + ); + return { + enabled: enabled ?? current.enabled, + amount: amount ?? current.amount, + }; +} diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index e933a4dff..2ac6108c0 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -5,9 +5,11 @@ import { GLOBE_ENGINE_MEMBERS, INPUT_TYPES, MODIFIERS, + SHARPEN_INITIAL, assertGlobeEngine, createEngineEvent, isEngineEvent, + mergeSharpen, } from './contract.js'; import { createFakeEngine } from './testing/fakeEngine.js'; import { @@ -292,3 +294,23 @@ test('raiseValue raises exactly one argument with the event semantics', () => { assert.deepEqual(seen, [['first', [42]]]); assert.equal(event.numberOfListeners, 1); }); + +test('the contract carries the sharpening and checks its values', () => { + for (const member of ['render.setSharpen', 'render.sharpen']) + assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); + assert.deepEqual(SHARPEN_INITIAL, { enabled: false, amount: 1 }); + const on = mergeSharpen(SHARPEN_INITIAL, { enabled: true }); + assert.deepEqual(on, { enabled: true, amount: 1 }); + assert.deepEqual(mergeSharpen(on, { amount: 0 }), { + enabled: true, + amount: 0, + }); + assert.deepEqual(mergeSharpen(on), on); + for (const bad of [ + { enabled: 1 }, + { amount: -1 }, + { amount: Infinity }, + { amount: null }, + ]) + assert.throws(() => mergeSharpen(on, bad), TypeError); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index db38fbf70..8e4f3434f 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -7,7 +7,11 @@ // destroy() removes everything the engine added; the view stays the caller's. import * as geo from '../../geo/index.js'; -import { createEngineEvent } from '../contract.js'; +import { + SHARPEN_INITIAL, + createEngineEvent, + mergeSharpen, +} from '../contract.js'; import { cesiumFovy } from './cameraMath.js'; import { createCameraModel } from './cameraModel.js'; import { createNavaraCredits } from './credits.js'; @@ -64,13 +68,16 @@ function rayRaisedEllipsoid({ origin, direction }, height) { * container?: HTMLElement, * fetch?: typeof fetch, * clock?: object, + * sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void }, * }} options `hooks` are internals.js; `container` defaults to the - * canvas's parent; `fetch` and `clock` are test seams. + * canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without + * it the engine only keeps the state) and the engine destroys it; `fetch` + * and `clock` are test seams. * @returns {import('../contract.js').GlobeEngine} */ export function createNavaraEngine( view, - { hooks, container, fetch, clock } = {}, + { hooks, container, fetch, clock, sharpen: sharpenPort = null } = {}, ) { if (!view?.camera?.raw) throw new TypeError('createNavaraEngine needs a view'); @@ -177,6 +184,7 @@ export function createNavaraEngine( }, }; + let sharpen = { ...SHARPEN_INITIAL }; let destroyed = false; return { kind: 'navara', @@ -196,6 +204,16 @@ export function createNavaraEngine( view.animation = Boolean(enabled); }, isContinuous: () => view.animation === true, + setSharpen(options) { + const next = mergeSharpen(sharpen, options); + const changed = + next.enabled !== sharpen.enabled || next.amount !== sharpen.amount; + sharpen = next; + if (!changed || destroyed) return; + sharpenPort?.set({ ...next }); + view.forceUpdate(); + }, + sharpen: () => ({ ...sharpen }), }, surface, input: input.input, @@ -210,6 +228,7 @@ export function createNavaraEngine( input.destroy(); credits.destroy(); maps.destroy(); + sharpenPort?.destroy(); }, isDestroyed: () => destroyed, }; diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index e36dc23c2..0f98dad22 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -143,6 +143,40 @@ test('render requests are forceUpdate and continuous mode is Navara animation', assert.equal(view.animation, false); }); +test('setSharpen hands changed states to the sharpen port and draws a frame', () => { + const view = fakeView(); + const shown = []; + let destroyed = 0; + const engine = createNavaraEngine(view, { + hooks, + sharpen: { + set: (state) => shown.push(state), + destroy: () => destroyed++, + }, + }); + assert.deepEqual(engine.render.sharpen(), { enabled: false, amount: 1 }); + engine.render.setSharpen({ amount: 1.7 }); + engine.render.setSharpen({ enabled: true }); + assert.deepEqual(view.calls.at(-1), ['forceUpdate']); + const calls = view.calls.length; + engine.render.setSharpen({ enabled: true, amount: 1.7 }); + assert.equal(view.calls.length, calls, 'an unchanged state draws nothing'); + assert.throws(() => engine.render.setSharpen({ amount: -1 }), TypeError); + assert.deepEqual(shown, [ + { enabled: false, amount: 1.7 }, + { enabled: true, amount: 1.7 }, + ]); + engine.destroy(); + engine.destroy(); + assert.equal(destroyed, 1); +}); + +test('without a sharpen port setSharpen keeps the state only', () => { + const { engine } = setup(); + engine.render.setSharpen({ enabled: true, amount: 0.5 }); + assert.deepEqual(engine.render.sharpen(), { enabled: true, amount: 0.5 }); +}); + test("the camera starts from Navara's drawn camera and pushes poses with setCamera", () => { const { engine, view } = setup(); near(engine.camera.pose().cartographic.height, 1e7, 1e-3, 'height'); diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 5d3abb9a2..d7455c7ce 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -8,6 +8,7 @@ import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { createNavaraEngine } from './createNavaraEngine.js'; import { navaraHooks } from './internals.js'; import { buildNavaraLook } from './look.js'; +import { createNavaraSharpen } from './sharpen.js'; import { NAVARA_CONTEXT_ATTRIBUTES, navaraScenePlan, @@ -91,6 +92,7 @@ export async function createNavaraGlobe({ engine = createNavaraEngine(view, { hooks: navaraHooks(view), container, + sharpen: createNavaraSharpen(view), }); const camera = engine.camera; let ground = Number.NaN; diff --git a/src/globe/navara/sharpen.js b/src/globe/navara/sharpen.js new file mode 100644 index 000000000..034adc686 --- /dev/null +++ b/src/globe/navara/sharpen.js @@ -0,0 +1,147 @@ +// The app's Sharpen control on Navara: an unsharp mask as a Navara custom +// effect (public EffectDesc API), with the maths of the Cesium sharpen stage +// (src/ui/visualPresets.js SHARPEN_SHADER): a 3×3 box blur, then +// center + (center − blur) × amount, clamped to [0, 1]. +// +// Where it sits: right after tone mapping and before SMAA or FXAA (before +// `final` in a look without them), as Cesium runs its custom stages after +// its tone mapping and before FXAA. After tone mapping the colours are the +// display's [0, 1] range, so highlights do not blow up; anti-aliasing after +// it smooths the stair steps sharpening brings out. Cesium sharpens +// gamma-encoded colours (its tone mapping ends with czm_inverseGamma) while +// Navara's buffers stay linear until the final copy, so the mask encodes +// with Cesium's gamma 2.2 and decodes after: the same amount sharpens the +// same. +// +// The sky is left alone: Navara's clouds are dithered (a fine grain at the +// `low` quality the look uses), and the mask turned that grain into visible +// noise. A pixel whose scene depth (the MRT pass's packed depth buffer, as +// the S1 drape reads it) is the far plane is sky or cloud; it keeps its +// colour, and costs one texture read instead of nine. +// +// Its own pass: a convolution reads the pass input, so merged into the tone +// mapping pass it would blur colours not yet tone mapped; Navara 0.2.1 also +// has no public call to merge effects. A disabled pass is skipped: off costs +// nothing. + +import { Effect as NavaraEffect, EffectDesc } from '@navaramap/three'; +import { Effect, EffectAttribute } from 'postprocessing'; +import { RGBADepthPacking, Uniform } from 'three'; + +export const SHARPEN_EFFECT_KEY = 'gevSharpen'; + +const fragmentShader = /* glsl */ ` +uniform float amount; +uniform sampler2D tSceneDepth; +uniform bool uSkipSky; + +vec3 encodeGamma(vec3 color) { + return pow(max(color, vec3(0.0)), vec3(1.0 / 2.2)); +} + +vec3 sampleGamma(vec2 uv) { + return encodeGamma(texture2D(inputBuffer, uv).rgb); +} + +void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { + if (uSkipSky && unpackRGBAToDepth(texture2D(tSceneDepth, uv)) >= 1.0) { + outputColor = inputColor; + return; + } + vec2 texel = texelSize; + vec3 center = encodeGamma(inputColor.rgb); + vec3 blur = ( + sampleGamma(uv + vec2(-texel.x, -texel.y)) + + sampleGamma(uv + vec2( 0.0, -texel.y)) + + sampleGamma(uv + vec2( texel.x, -texel.y)) + + sampleGamma(uv + vec2(-texel.x, 0.0)) + + center + + sampleGamma(uv + vec2( texel.x, 0.0)) + + sampleGamma(uv + vec2(-texel.x, texel.y)) + + sampleGamma(uv + vec2( 0.0, texel.y)) + + sampleGamma(uv + vec2( texel.x, texel.y)) + ) / 9.0; + vec3 sharpened = clamp(center + (center - blur) * amount, 0.0, 1.0); + outputColor = vec4(pow(sharpened, vec3(2.2)), inputColor.a); +} +`; + +class UnsharpMaskEffect extends Effect { + constructor(amount) { + super('GevUnsharpMaskEffect', fragmentShader, { + attributes: EffectAttribute.CONVOLUTION, + uniforms: new Map([ + ['amount', new Uniform(amount)], + ['tSceneDepth', new Uniform(null)], + ['uSkipSky', new Uniform(false)], + ]), + }); + } +} + +/** config.gevSharpen: { amount } (Cesium's `amount` uniform). */ +export class SharpenEffectDesc extends EffectDesc { + static key = SHARPEN_EFFECT_KEY; + static insertAfter = ['toneMapping']; + static insertBefore = ['smaa', 'fxaa', 'final']; + + constructor(view, ctx, config) { + super(view, ctx, config); + this.amount = config?.[SHARPEN_EFFECT_KEY]?.amount ?? 1; + } + + createPass() { + const camera = this.view.camera.raw; + if (!camera) throw new Error('Camera not available for the sharpening'); + this.effect = new UnsharpMaskEffect(this.amount); + return new NavaraEffect(camera, this.effect); + } + + // Each frame: the scene depth for the sky mask, when Navara packs it as + // RGBA (0.2.1 does); otherwise the whole frame is sharpened. + update() { + if (!this.effect) return; + const mrt = this.find('mrt'); + const depth = + mrt?.depthBufferPacking === RGBADepthPacking ? mrt.depthBuffer : null; + const uniforms = this.effect.uniforms; + uniforms.get('tSceneDepth').value = depth ?? null; + uniforms.get('uSkipSky').value = Boolean(depth); + } + + onUpdateConfig(updates) { + super.onUpdateConfig(updates); + const amount = updates?.[SHARPEN_EFFECT_KEY]?.amount; + if (amount === undefined) return; + this.amount = amount; + if (this.effect) this.effect.uniforms.get('amount').value = amount; + } +} + +/** + * The sharpening port createNavaraEngine takes: `set` shows a state + * ({ enabled, amount }, already checked). The pass is added the first time + * the sharpening is on, so a view that never sharpens has none. + * @param {object} view an initialised ThreeView + */ +export function createNavaraSharpen(view) { + view.registerEffect(SHARPEN_EFFECT_KEY, SharpenEffectDesc); + let handle = null; + return { + set({ enabled, amount }) { + if (!handle) { + if (!enabled) return; + handle = view.addEffect({ + [SHARPEN_EFFECT_KEY]: { amount }, + visible: true, + }); + return; + } + handle.update({ [SHARPEN_EFFECT_KEY]: { amount }, visible: enabled }); + }, + destroy() { + handle?.delete(); + handle = null; + }, + }; +} diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 2a2f705fc..398111004 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -379,6 +379,59 @@ export function createContractSuite( } }, ], + [ + 'render.setSharpen: merges partial updates, rejects bad values, draws', + async () => { + const previous = engine.render.sharpen(); + const same = (expected, label) => { + const actual = engine.render.sharpen(); + ensure( + actual.enabled === expected.enabled && + actual.amount === expected.amount, + `${label}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`, + ); + }; + ensure( + typeof previous.enabled === 'boolean' && + Number.isFinite(previous.amount), + `sharpen() ${JSON.stringify(previous)} is not { enabled, amount }`, + ); + try { + engine.render.setSharpen({ enabled: true, amount: 1.7 }); + same({ enabled: true, amount: 1.7 }, 'after enabling'); + engine.render.sharpen().amount = 0; + same({ enabled: true, amount: 1.7 }, 'sharpen() is a copy'); + // A frame with the sharpening on draws without errors. + await settle(2); + engine.render.setSharpen({ amount: 0.5 }); + same({ enabled: true, amount: 0.5 }, 'after an amount alone'); + engine.render.setSharpen({ enabled: false }); + same({ enabled: false, amount: 0.5 }, 'after disabling'); + for (const bad of [ + { enabled: 'yes' }, + { amount: -0.1 }, + { amount: Number.NaN }, + { amount: '1' }, + ]) { + let error = null; + try { + engine.render.setSharpen(bad); + } catch (caught) { + error = caught; + } + ensure( + error instanceof TypeError, + `setSharpen(${JSON.stringify(bad)}) did not throw a TypeError`, + ); + } + same({ enabled: false, amount: 0.5 }, 'after rejected values'); + await settle(1); + } finally { + engine.render.setSharpen(previous); + await settle(1); + } + }, + ], [ 'camera.flyTo: duration 0 lands and completes before flyTo returns', async () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 3ede75e44..395247d67 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -18,7 +18,13 @@ // - zoom distances are Cesium's defaults (1 m to unbounded). import * as geo from '../../geo/index.js'; -import { INPUT_TYPES, MODIFIERS, createEngineEvent } from '../contract.js'; +import { + INPUT_TYPES, + MODIFIERS, + SHARPEN_INITIAL, + createEngineEvent, + mergeSharpen, +} from '../contract.js'; const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; const WGS84 = ellipsoid.WGS84; @@ -250,6 +256,7 @@ export function createFakeEngine(options = {}) { let transform = [...matrix4.IDENTITY]; let percentageChanged = 0.5; let continuous = false; + let sharpen = { ...SHARPEN_INITIAL }; let flight = null; let lookAtTarget = null; let clock = 0; @@ -790,6 +797,11 @@ export function createFakeEngine(options = {}) { continuous = Boolean(enabled); }, isContinuous: () => continuous, + setSharpen(options) { + record('render.setSharpen', [options]); + sharpen = mergeSharpen(sharpen, options); + }, + sharpen: () => ({ ...sharpen }), }, surface: { sampleHeight(position, exclude) { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index c648c6b71..ab613939b 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -170,6 +170,7 @@ export class StyleManager extends ShellFacade { this._visualSettings = new VisualSettings({ viewer, + engine, mapStackController, services: { setScopeTerminusOverride: services.setScopeTerminusOverride, diff --git a/src/ui/stateOwners.test.mjs b/src/ui/stateOwners.test.mjs index b3708ea73..0c9ca8aab 100644 --- a/src/ui/stateOwners.test.mjs +++ b/src/ui/stateOwners.test.mjs @@ -141,3 +141,59 @@ test('visual teardown restores owned fog and aircraft sensor state once', async assert.equal(owner._irBoostActive, false); owner.destroy(); }); + +test('on Navara, presets set the Sharpen slider to 80 % and the engine draws it', async (t) => { + const { VisualSettings } = await import('./visualSettings.js'); + const priorDocument = globalThis.document; + globalThis.document = { + documentElement: { dataset: {} }, + getElementById: () => null, + }; + t.after(() => { + globalThis.document = priorDocument; + }); + const sharpen = []; + const classList = { toggle() {} }; + const element = (value) => ({ value, textContent: '', classList }); + const elements = { + _bloomBtn: element(), + _bloomSlider: element('0'), + _bloomSliderRow: element(), + _bloomSliderValue: element(), + _sharpenBtn: element(), + _sharpenSlider: element('49'), + _sharpenSliderRow: element(), + _sharpenSliderValue: element(), + }; + const owner = new VisualSettings({ + viewer: null, + engine: { + kind: 'navara', + render: { setSharpen: (options) => sharpen.push(options) }, + }, + elements, + operations: { + _syncShareState() {}, + _layoutRightPanels() {}, + _setHudVariant() {}, + _updateHudButtonState() {}, + }, + services: { + governorRequestRender() {}, + holdContinuousRender() {}, + releaseContinuousRender() {}, + }, + readHud: () => ({ getVariant: () => 'tactical', setMode() {} }), + }); + owner._detectionUserOverridden = true; + owner._applyStylePresetDefaults('retro'); + assert.equal(elements._sharpenSlider.value, '80'); + assert.equal(elements._sharpenSliderValue.textContent, '80%'); + // _setSharpenEnabled applies the slider again; the engine skips repeats. + assert.deepEqual(sharpen, [ + { amount: 1.7000000000000002 }, + { enabled: true }, + { amount: 1.7000000000000002 }, + ]); + owner.destroy(); +}); diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index 8a3670f84..b23f9ebbf 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -8,16 +8,20 @@ import { STYLES, SHARPEN_SHADER, TRANSITION_DURATION_MS, + sharpenAmount, } from './visualPresets.js'; /** Own the post-process stages and their animation, without DOM dependencies. */ export class VisualEffects { /** * @param {object} options - Viewer, render ownership callbacks and optional clocks. - * Construction is inert; initStyles/initPostProcess retain startup ordering. + * Without a viewer, `engine` (the globe engine) draws the sharpening + * through render.setSharpen. Construction is inert; initStyles and + * initPostProcess retain startup ordering. */ constructor({ viewer, + engine = null, requestRender, holdRender, releaseRender, @@ -28,6 +32,12 @@ export class VisualEffects { createStage = (options) => new PostProcessStage(options), }) { this.viewer = viewer; + // Cesium sharpens with its own post stage below; an engine without a + // viewer (Navara) sharpens in its render pipeline. + this.engineRender = + !viewer && typeof engine?.render?.setSharpen === 'function' + ? engine.render + : null; this.requestRender = requestRender; this.holdRender = holdRender; this.releaseRender = releaseRender; @@ -53,7 +63,8 @@ export class VisualEffects { } // Post-process stages are Cesium's: without a viewer (the Navara engine) - // there are none, and the style, bloom and sharpen controls change nothing. + // there are none, and the style and bloom controls change nothing (the + // engine sharpens, see engineRender). initStyles() { if (!this.viewer || this.stopped || this.stageEntries.length) return; for (const [name, shader] of Object.entries(STYLES)) { @@ -158,7 +169,8 @@ export class VisualEffects { if (this.stopped) return; this.sharpenIntensity = value; if (this.sharpenStage) - this.sharpenStage.uniforms.amount = 0.1 + value * 2.0; + this.sharpenStage.uniforms.amount = sharpenAmount(value); + this.engineRender?.setSharpen({ amount: sharpenAmount(value) }); this.requestRender('sharpen'); } @@ -166,6 +178,7 @@ export class VisualEffects { if (this.stopped) return; this.sharpenEnabled = !!enabled; if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled; + this.engineRender?.setSharpen({ enabled: this.sharpenEnabled }); this.requestRender('sharpen'); } diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index 76615d0e8..40f6de9dc 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -5,9 +5,13 @@ import { GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET, + NAVARA_SHARPEN_INTENSITY_DEFAULT, + SHARPEN_INTENSITY_DEFAULT, + sharpenAmount, + sharpenIntensityForEngine, } from './visualPresets.js'; -function fixture() { +function fixture(engine = null) { let time = 0; let next = 0; const frames = new Map(); @@ -37,6 +41,7 @@ function fixture() { }; const effects = new VisualEffects({ viewer: { scene: { postProcessStages: pipeline } }, + engine, requestRender: (reason) => requests.push(reason), holdRender: (reason) => holds.add(reason), releaseRender: (reason) => holds.delete(reason), @@ -189,6 +194,57 @@ test('sharpen uses the existing uniform scale and keeps values while disabled', f.effects.destroy(); }); +test('without a viewer the engine sharpens with the same amount scale', () => { + const sharpen = []; + const requests = []; + const engine = { + render: { setSharpen: (options) => sharpen.push(options) }, + }; + const effects = new VisualEffects({ + viewer: null, + engine, + requestRender: (reason) => requests.push(reason), + holdRender() {}, + releaseRender() {}, + }); + effects.initStyles(); + effects.initPostProcess(0.49); + assert.equal(effects.sharpenStage, null); + effects.applySharpenIntensity(0.8); + effects.setSharpenEnabled(true); + effects.setSharpenEnabled(false); + assert.deepEqual(sharpen, [ + { amount: 1.7000000000000002 }, + { enabled: true }, + { enabled: false }, + ]); + assert.equal(sharpen[0].amount, sharpenAmount(0.8)); + assert.deepEqual(requests, ['sharpen', 'sharpen', 'sharpen']); + effects.destroy(); +}); + +test('with a viewer the Cesium stage sharpens and the engine is left alone', () => { + let calls = 0; + const f = fixture({ render: { setSharpen: () => calls++ } }); + f.effects.initPostProcess(0.49); + f.effects.applySharpenIntensity(0.8); + f.effects.setSharpenEnabled(true); + assert.equal(calls, 0); + assert.equal(f.effects.sharpenStage.uniforms.amount, sharpenAmount(0.8)); + f.effects.destroy(); +}); + +test('Navara reads the Cesium sharpen default as 80 %, other values stay', () => { + assert.equal(SHARPEN_INTENSITY_DEFAULT, 49); + assert.equal(NAVARA_SHARPEN_INTENSITY_DEFAULT, 80); + assert.equal(sharpenIntensityForEngine(49, 'navara'), 80); + assert.equal(sharpenIntensityForEngine(49, 'cesium'), 49); + assert.equal(sharpenIntensityForEngine(65, 'navara'), 65); + assert.equal(sharpenIntensityForEngine(0, 'navara'), 0); + for (const preset of Object.values(STYLE_PRESET_DEFAULTS)) + assert.equal(preset.sharpen.intensity, SHARPEN_INTENSITY_DEFAULT); +}); + test('stop revokes pending work immediately but retains stages until final destruction', () => { const f = fixture(); f.effects.initStyles(); diff --git a/src/ui/visualPresets.js b/src/ui/visualPresets.js index fa5c57939..34d948e3b 100644 --- a/src/ui/visualPresets.js +++ b/src/ui/visualPresets.js @@ -40,10 +40,29 @@ export const MILITARY_DETECTION_PRESET = Object.freeze({ densityPct: 75, }); +/** The Sharpen slider's default (%) on Cesium, in every preset. */ +export const SHARPEN_INTENSITY_DEFAULT = 49; +/** The Sharpen slider's default (%) on the Navara engine. */ +export const NAVARA_SHARPEN_INTENSITY_DEFAULT = 80; + +/** + * The sharpen intensity (%) a default, a preset or a share link sets on an + * engine: Navara reads Cesium's default (49, every preset's value) as its own + * default (80); any other value, and everything on Cesium, stays as given. + */ +export function sharpenIntensityForEngine(pct, engineKind) { + return engineKind === 'navara' && pct === SHARPEN_INTENSITY_DEFAULT + ? NAVARA_SHARPEN_INTENSITY_DEFAULT + : pct; +} + +/** The unsharp mask's `amount` for a slider value normalized to 0..1. */ +export const sharpenAmount = (value) => 0.1 + value * 2.0; + /** Baseline post-processing settings applied on first load (before share-link restore). */ export const GLOBAL_POST_DEFAULTS = { bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, + sharpen: { enabled: true, intensity: SHARPEN_INTENSITY_DEFAULT }, hudVariant: 'tactical', hudVisible: true, // Detection is ON for EVERY style on a first run, Normal included (owner @@ -69,7 +88,7 @@ export const GLOBAL_POST_DEFAULTS = { export const STYLE_PRESET_DEFAULTS = { retro: { bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, + sharpen: { enabled: true, intensity: SHARPEN_INTENSITY_DEFAULT }, styleParams: { retro: { pixelation: 1.0, @@ -83,7 +102,7 @@ export const STYLE_PRESET_DEFAULTS = { }, surveillance: { bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, + sharpen: { enabled: true, intensity: SHARPEN_INTENSITY_DEFAULT }, styleParams: { surveillance: { gain: 0.18, @@ -98,7 +117,7 @@ export const STYLE_PRESET_DEFAULTS = { }, thermal: { bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, + sharpen: { enabled: true, intensity: SHARPEN_INTENSITY_DEFAULT }, styleParams: { thermal: { sensitivity: 0.85, diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 117266c43..2b4ec59cd 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1,4 +1,7 @@ -import { STYLE_STATUS_LABELS } from './visualPresets.js'; +import { + STYLE_STATUS_LABELS, + sharpenIntensityForEngine, +} from './visualPresets.js'; import { UiLifetime } from './uiLifetime.js'; import { VisualEffects, @@ -92,7 +95,15 @@ export class VisualSettings { this._setBloomIntensity(intensity, { syncShare: false }); } if (typeof sharpenIntensity === 'number' && this._sharpenSlider) { - const pct = Math.max(0, Math.min(100, Math.round(sharpenIntensity))); + const pct = Math.max( + 0, + Math.min( + 100, + Math.round( + sharpenIntensityForEngine(sharpenIntensity, this._engineKind), + ), + ), + ); this._sharpenSlider.value = String(pct); this._sharpenSliderValue.textContent = `${pct}%`; this._applySharpenIntensity(pct / 100); @@ -162,6 +173,7 @@ export class VisualSettings { constructor({ viewer, + engine = null, services, elements, operations, @@ -189,8 +201,12 @@ export class VisualSettings { readDisplayPortalActive, }); this._lifetime = new UiLifetime(); + // Defaults, presets and share links read Cesium's sharpen default as the + // engine's own (sharpenIntensityForEngine). + this._engineKind = engine?.kind ?? 'cesium'; this._visualEffects = new VisualEffects({ viewer, + engine, requestRender: services.governorRequestRender, holdRender: services.holdContinuousRender, releaseRender: services.releaseContinuousRender, @@ -746,7 +762,12 @@ export class VisualSettings { if (typeof sharpenInput.intensity === 'number' && this._sharpenSlider) { const sharpenPct = Math.max( 0, - Math.min(100, Math.round(sharpenInput.intensity)), + Math.min( + 100, + Math.round( + sharpenIntensityForEngine(sharpenInput.intensity, this._engineKind), + ), + ), ); this._sharpenSlider.value = String(sharpenPct); this._sharpenSliderValue.textContent = `${sharpenPct}%`; @@ -804,7 +825,15 @@ export class VisualSettings { ) { const sharpenPct = Math.max( 0, - Math.min(100, Math.round(defaults.sharpen.intensity)), + Math.min( + 100, + Math.round( + sharpenIntensityForEngine( + defaults.sharpen.intensity, + this._engineKind, + ), + ), + ), ); this._sharpenSlider.value = String(sharpenPct); this._sharpenSliderValue.textContent = `${sharpenPct}%`;