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 %.
This commit is contained in:
German Khokhlov committed 2026-10-16 13:30:56 +04:00
1 parent ac081a008d
commit bb8c0b83c4
17 files changed
+521 -16

No files matched your search

+1
View File
@@ -1049,6 +1049,7 @@
"src/globe/navara/internals.test.mjs", "src/globe/navara/internals.test.mjs",
"src/globe/navara/maps.js", "src/globe/navara/maps.js",
"src/globe/navara/maps.test.mjs", "src/globe/navara/maps.test.mjs",
"src/globe/navara/sharpen.js",
"src/globe/navara/surface.js", "src/globe/navara/surface.js",
"src/globe/navara/surface.test.mjs", "src/globe/navara/surface.test.mjs",
"src/globe/navara/viewOptions.js", "src/globe/navara/viewOptions.js",
+1
View File
@@ -1770,6 +1770,7 @@
"src/globe/navara/internals.js", "src/globe/navara/internals.js",
"src/globe/navara/look.js", "src/globe/navara/look.js",
"src/globe/navara/maps.js", "src/globe/navara/maps.js",
"src/globe/navara/sharpen.js",
"src/globe/navara/surface.js", "src/globe/navara/surface.js",
"src/globe/navara/viewOptions.js", "src/globe/navara/viewOptions.js",
"src/app/engineChoice.js", "src/app/engineChoice.js",
+8
View File
@@ -8,6 +8,7 @@ import {
isRenderGovernorInstalled, isRenderGovernorInstalled,
releaseContinuousRender, releaseContinuousRender,
} from '../../renderGovernor.js'; } from '../../renderGovernor.js';
import { SHARPEN_INITIAL, mergeSharpen } from '../contract.js';
export const RENDER_OWNER = 'globe-engine'; export const RENDER_OWNER = 'globe-engine';
@@ -28,6 +29,7 @@ export function createCesiumRender(
} = {}, } = {},
) { ) {
let held = false; let held = false;
let sharpen = { ...SHARPEN_INITIAL };
const render = { const render = {
request(reason = RENDER_OWNER) { request(reason = RENDER_OWNER) {
requestRender(reason); requestRender(reason);
@@ -39,6 +41,12 @@ export function createCesiumRender(
}, },
// The scene's real mode: another owner's hold keeps it continuous. // The scene's real mode: another owner's hold keeps it continuous.
isContinuous: () => viewer.scene.requestRenderMode === false, 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 { return {
render, render,
+32
View File
@@ -160,6 +160,16 @@
* Owns whatever the engine needs to render every frame or only on request * Owns whatever the engine needs to render every frame or only on request
* (Cesium: requestRenderMode and maximumRenderTimeChange = Infinity). * (Cesium: requestRenderMode and maximumRenderTimeChange = Infinity).
* @property {() => boolean} isContinuous * @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 * @typedef {object} PickResult
* @property {any} id The picked entity or object id; undefined for map tiles. * @property {any} id The picked entity or object id; undefined for map tiles.
@@ -382,6 +392,8 @@ const SHAPE = {
request: isFunction, request: isFunction,
setContinuous: isFunction, setContinuous: isFunction,
isContinuous: isFunction, isContinuous: isFunction,
setSharpen: isFunction,
sharpen: isFunction,
}, },
surface: { surface: {
sampleHeight: isFunction, sampleHeight: isFunction,
@@ -448,3 +460,23 @@ export function assertGlobeEngine(engine) {
); );
return 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,
};
}
+22
View File
@@ -5,9 +5,11 @@ import {
GLOBE_ENGINE_MEMBERS, GLOBE_ENGINE_MEMBERS,
INPUT_TYPES, INPUT_TYPES,
MODIFIERS, MODIFIERS,
SHARPEN_INITIAL,
assertGlobeEngine, assertGlobeEngine,
createEngineEvent, createEngineEvent,
isEngineEvent, isEngineEvent,
mergeSharpen,
} from './contract.js'; } from './contract.js';
import { createFakeEngine } from './testing/fakeEngine.js'; import { createFakeEngine } from './testing/fakeEngine.js';
import { import {
@@ -292,3 +294,23 @@ test('raiseValue raises exactly one argument with the event semantics', () => {
assert.deepEqual(seen, [['first', [42]]]); assert.deepEqual(seen, [['first', [42]]]);
assert.equal(event.numberOfListeners, 1); 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);
});
+22 -3
View File
@@ -7,7 +7,11 @@
// destroy() removes everything the engine added; the view stays the caller's. // destroy() removes everything the engine added; the view stays the caller's.
import * as geo from '../../geo/index.js'; 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 { cesiumFovy } from './cameraMath.js';
import { createCameraModel } from './cameraModel.js'; import { createCameraModel } from './cameraModel.js';
import { createNavaraCredits } from './credits.js'; import { createNavaraCredits } from './credits.js';
@@ -64,13 +68,16 @@ function rayRaisedEllipsoid({ origin, direction }, height) {
* container?: HTMLElement, * container?: HTMLElement,
* fetch?: typeof fetch, * fetch?: typeof fetch,
* clock?: object, * clock?: object,
* sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void },
* }} options `hooks` are internals.js; `container` defaults to the * }} 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} * @returns {import('../contract.js').GlobeEngine}
*/ */
export function createNavaraEngine( export function createNavaraEngine(
view, view,
{ hooks, container, fetch, clock } = {}, { hooks, container, fetch, clock, sharpen: sharpenPort = null } = {},
) { ) {
if (!view?.camera?.raw) if (!view?.camera?.raw)
throw new TypeError('createNavaraEngine needs a view'); throw new TypeError('createNavaraEngine needs a view');
@@ -177,6 +184,7 @@ export function createNavaraEngine(
}, },
}; };
let sharpen = { ...SHARPEN_INITIAL };
let destroyed = false; let destroyed = false;
return { return {
kind: 'navara', kind: 'navara',
@@ -196,6 +204,16 @@ export function createNavaraEngine(
view.animation = Boolean(enabled); view.animation = Boolean(enabled);
}, },
isContinuous: () => view.animation === true, 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, surface,
input: input.input, input: input.input,
@@ -210,6 +228,7 @@ export function createNavaraEngine(
input.destroy(); input.destroy();
credits.destroy(); credits.destroy();
maps.destroy(); maps.destroy();
sharpenPort?.destroy();
}, },
isDestroyed: () => destroyed, isDestroyed: () => destroyed,
}; };
@@ -143,6 +143,40 @@ test('render requests are forceUpdate and continuous mode is Navara animation',
assert.equal(view.animation, false); 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", () => { test("the camera starts from Navara's drawn camera and pushes poses with setCamera", () => {
const { engine, view } = setup(); const { engine, view } = setup();
near(engine.camera.pose().cartographic.height, 1e7, 1e-3, 'height'); near(engine.camera.pose().cartographic.height, 1e7, 1e-3, 'height');
+2
View File
@@ -8,6 +8,7 @@ import { DefaultPlugin } from '@navaramap/three-default-plugin';
import { createNavaraEngine } from './createNavaraEngine.js'; import { createNavaraEngine } from './createNavaraEngine.js';
import { navaraHooks } from './internals.js'; import { navaraHooks } from './internals.js';
import { buildNavaraLook } from './look.js'; import { buildNavaraLook } from './look.js';
import { createNavaraSharpen } from './sharpen.js';
import { import {
NAVARA_CONTEXT_ATTRIBUTES, NAVARA_CONTEXT_ATTRIBUTES,
navaraScenePlan, navaraScenePlan,
@@ -91,6 +92,7 @@ export async function createNavaraGlobe({
engine = createNavaraEngine(view, { engine = createNavaraEngine(view, {
hooks: navaraHooks(view), hooks: navaraHooks(view),
container, container,
sharpen: createNavaraSharpen(view),
}); });
const camera = engine.camera; const camera = engine.camera;
let ground = Number.NaN; let ground = Number.NaN;
+147
View File
@@ -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;
},
};
}
+53
View File
@@ -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', 'camera.flyTo: duration 0 lands and completes before flyTo returns',
async () => { async () => {
+13 -1
View File
@@ -18,7 +18,13 @@
// - zoom distances are Cesium's defaults (1 m to unbounded). // - zoom distances are Cesium's defaults (1 m to unbounded).
import * as geo from '../../geo/index.js'; 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 { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
const WGS84 = ellipsoid.WGS84; const WGS84 = ellipsoid.WGS84;
@@ -250,6 +256,7 @@ export function createFakeEngine(options = {}) {
let transform = [...matrix4.IDENTITY]; let transform = [...matrix4.IDENTITY];
let percentageChanged = 0.5; let percentageChanged = 0.5;
let continuous = false; let continuous = false;
let sharpen = { ...SHARPEN_INITIAL };
let flight = null; let flight = null;
let lookAtTarget = null; let lookAtTarget = null;
let clock = 0; let clock = 0;
@@ -790,6 +797,11 @@ export function createFakeEngine(options = {}) {
continuous = Boolean(enabled); continuous = Boolean(enabled);
}, },
isContinuous: () => continuous, isContinuous: () => continuous,
setSharpen(options) {
record('render.setSharpen', [options]);
sharpen = mergeSharpen(sharpen, options);
},
sharpen: () => ({ ...sharpen }),
}, },
surface: { surface: {
sampleHeight(position, exclude) { sampleHeight(position, exclude) {
+1
View File
@@ -170,6 +170,7 @@ export class StyleManager extends ShellFacade {
this._visualSettings = new VisualSettings({ this._visualSettings = new VisualSettings({
viewer, viewer,
engine,
mapStackController, mapStackController,
services: { services: {
setScopeTerminusOverride: services.setScopeTerminusOverride, setScopeTerminusOverride: services.setScopeTerminusOverride,
+56
View File
@@ -141,3 +141,59 @@ test('visual teardown restores owned fog and aircraft sensor state once', async
assert.equal(owner._irBoostActive, false); assert.equal(owner._irBoostActive, false);
owner.destroy(); 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();
});
+16 -3
View File
@@ -8,16 +8,20 @@ import {
STYLES, STYLES,
SHARPEN_SHADER, SHARPEN_SHADER,
TRANSITION_DURATION_MS, TRANSITION_DURATION_MS,
sharpenAmount,
} from './visualPresets.js'; } from './visualPresets.js';
/** Own the post-process stages and their animation, without DOM dependencies. */ /** Own the post-process stages and their animation, without DOM dependencies. */
export class VisualEffects { export class VisualEffects {
/** /**
* @param {object} options - Viewer, render ownership callbacks and optional clocks. * @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({ constructor({
viewer, viewer,
engine = null,
requestRender, requestRender,
holdRender, holdRender,
releaseRender, releaseRender,
@@ -28,6 +32,12 @@ export class VisualEffects {
createStage = (options) => new PostProcessStage(options), createStage = (options) => new PostProcessStage(options),
}) { }) {
this.viewer = viewer; 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.requestRender = requestRender;
this.holdRender = holdRender; this.holdRender = holdRender;
this.releaseRender = releaseRender; this.releaseRender = releaseRender;
@@ -53,7 +63,8 @@ export class VisualEffects {
} }
// Post-process stages are Cesium's: without a viewer (the Navara engine) // 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() { initStyles() {
if (!this.viewer || this.stopped || this.stageEntries.length) return; if (!this.viewer || this.stopped || this.stageEntries.length) return;
for (const [name, shader] of Object.entries(STYLES)) { for (const [name, shader] of Object.entries(STYLES)) {
@@ -158,7 +169,8 @@ export class VisualEffects {
if (this.stopped) return; if (this.stopped) return;
this.sharpenIntensity = value; this.sharpenIntensity = value;
if (this.sharpenStage) 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'); this.requestRender('sharpen');
} }
@@ -166,6 +178,7 @@ export class VisualEffects {
if (this.stopped) return; if (this.stopped) return;
this.sharpenEnabled = !!enabled; this.sharpenEnabled = !!enabled;
if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled; if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled;
this.engineRender?.setSharpen({ enabled: this.sharpenEnabled });
this.requestRender('sharpen'); this.requestRender('sharpen');
} }
+57 -1
View File
@@ -5,9 +5,13 @@ import {
GLOBAL_POST_DEFAULTS, GLOBAL_POST_DEFAULTS,
STYLE_PRESET_DEFAULTS, STYLE_PRESET_DEFAULTS,
MILITARY_DETECTION_PRESET, MILITARY_DETECTION_PRESET,
NAVARA_SHARPEN_INTENSITY_DEFAULT,
SHARPEN_INTENSITY_DEFAULT,
sharpenAmount,
sharpenIntensityForEngine,
} from './visualPresets.js'; } from './visualPresets.js';
function fixture() { function fixture(engine = null) {
let time = 0; let time = 0;
let next = 0; let next = 0;
const frames = new Map(); const frames = new Map();
@@ -37,6 +41,7 @@ function fixture() {
}; };
const effects = new VisualEffects({ const effects = new VisualEffects({
viewer: { scene: { postProcessStages: pipeline } }, viewer: { scene: { postProcessStages: pipeline } },
engine,
requestRender: (reason) => requests.push(reason), requestRender: (reason) => requests.push(reason),
holdRender: (reason) => holds.add(reason), holdRender: (reason) => holds.add(reason),
releaseRender: (reason) => holds.delete(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(); 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', () => { test('stop revokes pending work immediately but retains stages until final destruction', () => {
const f = fixture(); const f = fixture();
f.effects.initStyles(); f.effects.initStyles();
+23 -4
View File
@@ -40,10 +40,29 @@ export const MILITARY_DETECTION_PRESET = Object.freeze({
densityPct: 75, 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). */ /** Baseline post-processing settings applied on first load (before share-link restore). */
export const GLOBAL_POST_DEFAULTS = { export const GLOBAL_POST_DEFAULTS = {
bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT },
sharpen: { enabled: true, intensity: 49 }, sharpen: { enabled: true, intensity: SHARPEN_INTENSITY_DEFAULT },
hudVariant: 'tactical', hudVariant: 'tactical',
hudVisible: true, hudVisible: true,
// Detection is ON for EVERY style on a first run, Normal included (owner // 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 = { export const STYLE_PRESET_DEFAULTS = {
retro: { retro: {
bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT },
sharpen: { enabled: true, intensity: 49 }, sharpen: { enabled: true, intensity: SHARPEN_INTENSITY_DEFAULT },
styleParams: { styleParams: {
retro: { retro: {
pixelation: 1.0, pixelation: 1.0,
@@ -83,7 +102,7 @@ export const STYLE_PRESET_DEFAULTS = {
}, },
surveillance: { surveillance: {
bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT },
sharpen: { enabled: true, intensity: 49 }, sharpen: { enabled: true, intensity: SHARPEN_INTENSITY_DEFAULT },
styleParams: { styleParams: {
surveillance: { surveillance: {
gain: 0.18, gain: 0.18,
@@ -98,7 +117,7 @@ export const STYLE_PRESET_DEFAULTS = {
}, },
thermal: { thermal: {
bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT },
sharpen: { enabled: true, intensity: 49 }, sharpen: { enabled: true, intensity: SHARPEN_INTENSITY_DEFAULT },
styleParams: { styleParams: {
thermal: { thermal: {
sensitivity: 0.85, sensitivity: 0.85,
+33 -4
View File
@@ -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 { UiLifetime } from './uiLifetime.js';
import { import {
VisualEffects, VisualEffects,
@@ -92,7 +95,15 @@ export class VisualSettings {
this._setBloomIntensity(intensity, { syncShare: false }); this._setBloomIntensity(intensity, { syncShare: false });
} }
if (typeof sharpenIntensity === 'number' && this._sharpenSlider) { 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._sharpenSlider.value = String(pct);
this._sharpenSliderValue.textContent = `${pct}%`; this._sharpenSliderValue.textContent = `${pct}%`;
this._applySharpenIntensity(pct / 100); this._applySharpenIntensity(pct / 100);
@@ -162,6 +173,7 @@ export class VisualSettings {
constructor({ constructor({
viewer, viewer,
engine = null,
services, services,
elements, elements,
operations, operations,
@@ -189,8 +201,12 @@ export class VisualSettings {
readDisplayPortalActive, readDisplayPortalActive,
}); });
this._lifetime = new UiLifetime(); 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({ this._visualEffects = new VisualEffects({
viewer, viewer,
engine,
requestRender: services.governorRequestRender, requestRender: services.governorRequestRender,
holdRender: services.holdContinuousRender, holdRender: services.holdContinuousRender,
releaseRender: services.releaseContinuousRender, releaseRender: services.releaseContinuousRender,
@@ -746,7 +762,12 @@ export class VisualSettings {
if (typeof sharpenInput.intensity === 'number' && this._sharpenSlider) { if (typeof sharpenInput.intensity === 'number' && this._sharpenSlider) {
const sharpenPct = Math.max( const sharpenPct = Math.max(
0, 0,
Math.min(100, Math.round(sharpenInput.intensity)), Math.min(
100,
Math.round(
sharpenIntensityForEngine(sharpenInput.intensity, this._engineKind),
),
),
); );
this._sharpenSlider.value = String(sharpenPct); this._sharpenSlider.value = String(sharpenPct);
this._sharpenSliderValue.textContent = `${sharpenPct}%`; this._sharpenSliderValue.textContent = `${sharpenPct}%`;
@@ -804,7 +825,15 @@ export class VisualSettings {
) { ) {
const sharpenPct = Math.max( const sharpenPct = Math.max(
0, 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._sharpenSlider.value = String(sharpenPct);
this._sharpenSliderValue.textContent = `${sharpenPct}%`; this._sharpenSliderValue.textContent = `${sharpenPct}%`;