mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
ac081a008d
commit
bb8c0b83c4
17 files changed
+521
-16
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -170,6 +170,7 @@ export class StyleManager extends ShellFacade {
|
||||
|
||||
this._visualSettings = new VisualSettings({
|
||||
viewer,
|
||||
engine,
|
||||
mapStackController,
|
||||
services: {
|
||||
setScopeTerminusOverride: services.setScopeTerminusOverride,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
+16
-3
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
+23
-4
@@ -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,
|
||||
|
||||
@@ -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}%`;
|
||||
|
||||
Reference in new issue
Block a user