Run the Global Context range pulses and their echoes on Navara, sharing the pulse clock with Cesium

This commit is contained in:
German Khokhlov committed 2026-11-05 00:19:30 +04:00
1 parent 727bb29cae
commit 43b4e9348b
6 files changed
+753 -96

No files matched your search

+1
View File
@@ -3821,6 +3821,7 @@
"src/layers/earthquakes/render/navaraGrid.js",
"src/layers/installations/render/navara.js",
"src/layers/awareness/render/navara.js",
"src/layers/awareness/render/navaraPulses.js",
"src/layers/infrastructure/render/navara.js",
"src/layers/shared/render/navaraStems.js",
"src/layers/shared/render/navaraSceneDepth.js",
+6 -3
View File
@@ -220,9 +220,12 @@ export async function registerNavaraLayerRenderers() {
return (engine) =>
createGhostMapsNavaraRenderer(engine, navaraViews.get(engine).view);
},
'military-awareness': async () =>
(await import('../layers/awareness/render/navara.js'))
.createAwarenessNavaraRenderer,
'military-awareness': async () => {
const { createAwarenessNavaraRenderer } =
await import('../layers/awareness/render/navara.js');
return (engine) =>
createAwarenessNavaraRenderer(engine, navaraViews.get(engine));
},
'military-installations': async () => {
const { createInstallationsNavaraRenderer } =
await import('../layers/installations/render/navara.js');
+266
View File
@@ -0,0 +1,266 @@
// The Global Context range pulses on a globe without a Cesium viewer
// (Navara): the same clock, ring flash, frame requests and echo field as on
// Cesium, drawn through the display's ring and pulse disc.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import { CYBER_SONAR_RENDER_INTERVAL_MS } from '../../cyberSonar.js';
import { getAwarenessEchoField } from '../../awarenessEcho.js';
import { createRendering } from './rendering.js';
import { createState } from './state.js';
import { AWARENESS_RANGE_PULSE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
import { createAwarenessPulseDisc } from './render/navaraPulses.js';
function event() {
const listeners = new Set();
return {
add(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
raise() {
for (const listener of [...listeners]) listener();
},
get size() {
return listeners.size;
},
};
}
function displayRendering(t, { pulses = true, reducedMotion = false } = {}) {
const previous = globalThis.matchMedia;
globalThis.matchMedia = () => ({
matches: reducedMotion,
addEventListener() {},
removeEventListener() {},
});
t.after(() => {
globalThis.matchMedia = previous;
});
const frame = event();
const frameEnd = event();
const rims = [];
const discs = [];
let height = 800_000;
const display = {
container: {},
canvas: { clientWidth: 800, clientHeight: 600 },
camera: {},
cameraHeight: () => height,
onFrame: (run) => frame.add(run),
onFrameEnd: (run) => frameEnd.add(run),
createRim(center) {
const rim = {
center,
alphas: [],
destroyed: false,
move(next) {
rim.center = next;
},
setAlpha(alpha) {
rim.alphas.push(alpha);
},
destroy() {
rim.destroyed = true;
},
};
rims.push(rim);
return rim;
},
...(pulses
? {
createPulses(center) {
const disc = {
center,
draws: [],
destroyed: false,
move(next) {
disc.center = next;
},
draw(state) {
disc.draws.push({
level: state.level,
radii: [...state.radii],
alphas: [...state.alphas],
});
},
destroy() {
disc.destroyed = true;
},
};
discs.push(disc);
return disc;
},
}
: {}),
};
const state = createState({ services: {} });
state.display = display;
const reasons = [];
const rendering = createRendering({
state,
services: {
geometry: {},
render: { governorRequestRender: (reason) => reasons.push(reason) },
},
parts: {},
});
t.after(() => rendering.clearVisual());
let clock = 1_000_000;
t.mock.method(performance, 'now', () => clock);
t.mock.timers.enable({ apis: ['setTimeout'], now: clock });
const draw = () => {
frame.raise();
frameEnd.raise();
};
return {
state,
rendering,
rims,
discs,
frame,
frameEnd,
reasons,
draw,
setHeight(next) {
height = next;
},
advance(ms) {
clock += ms;
t.mock.timers.tick(ms);
},
};
}
const subject = (lon) => ({
layerId: 'flights',
id: 'abc123',
position: geo.cartesian3.fromDegrees(lon, 27, 9000),
});
test('a Navara display draws the pulses, flashes its ring and publishes the echo field', (t) => {
const h = displayRendering(t);
h.rendering.renderVisual(subject(3));
assert.equal(h.rims.length, 1);
assert.equal(h.discs.length, 1);
assert.ok(getAwarenessEchoField(), 'the overlay echoes the fronts');
// A second in: the pulses are rising, the fronts are inside the range.
h.advance(1000);
h.draw();
const last = h.discs[0].draws.at(-1);
assert.ok(last.level > 0);
assert.ok(last.radii.slice(0, 3).every((r) => r > 0 && r <= 1));
assert.ok(last.alphas.some((a) => a > 0));
assert.ok(h.rims[0].alphas.at(-1) < 1, 'the ring dims while they run');
// Each drawn frame asks for the next one a sonar interval later.
const before = h.reasons.filter((r) => r === 'awareness-pulse').length;
h.advance(CYBER_SONAR_RENDER_INTERVAL_MS);
assert.equal(
h.reasons.filter((r) => r === 'awareness-pulse').length,
before + 1,
);
// After the active window they rest: no level, no more frame requests.
h.advance(AWARENESS_RANGE_PULSE.activeMs + AWARENESS_RANGE_PULSE.settleMs);
h.draw();
assert.equal(h.discs[0].draws.at(-1).level, 0);
assert.equal(h.rims[0].alphas.at(-1), 1, 'the ring is steady at rest');
const rested = h.reasons.length;
h.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 3);
assert.equal(h.reasons.length, rested);
// A camera move the user made wakes them again.
h.rendering.wakePulses();
h.advance(1000);
h.draw();
assert.ok(h.discs[0].draws.at(-1).level > 0);
});
test('the Navara disc follows the subject and goes with the ring', (t) => {
const h = displayRendering(t);
h.rendering.renderVisual(subject(3));
h.rendering.renderVisual(subject(3.5));
assert.equal(h.discs.length, 1, 'a move is not a rebuild');
assert.ok(
geo.cartesian3.distance(h.discs[0].center, h.rims[0].center) < 1,
'disc and ring share the centre',
);
h.rendering.clearVisual();
assert.equal(h.discs[0].destroyed, true);
assert.equal(h.rims[0].destroyed, true);
assert.equal(h.frame.size, 0, 'no frame listener left');
assert.equal(h.frameEnd.size, 0);
assert.equal(getAwarenessEchoField(), null);
});
test('below the rim height the pulses rest, so no disc sweeps across the sky', (t) => {
const h = displayRendering(t);
h.setHeight(CONTEXT_RIM_HEIGHT_M - 1);
h.rendering.renderVisual(subject(3));
h.advance(1000);
h.draw();
assert.equal(h.discs[0].draws.at(-1).level, 0);
const rested = h.reasons.length;
h.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 3);
assert.equal(h.reasons.length, rested, 'and ask for no frames');
});
for (const [name, options] of [
['reduced motion', { reducedMotion: true }],
['a display without a disc', { pulses: false }],
])
test(`under ${name} the ring stands alone`, (t) => {
const h = displayRendering(t, options);
h.rendering.renderVisual(subject(3));
assert.equal(h.rims.length, 1);
assert.equal(h.discs.length, 0);
assert.equal(h.frame.size, 0);
assert.equal(getAwarenessEchoField(), null);
});
test('the Navara pulse disc draws only while the pulses run', () => {
const effects = [];
const view = {
registerEffect() {},
addEffect(config) {
const handle = {
config,
visible: config.visible,
deleted: false,
update(next) {
handle.visible = next.visible;
},
delete() {
handle.deleted = true;
},
};
effects.push(handle);
return handle;
},
};
const disc = createAwarenessPulseDisc(
{ render: { request() {} } },
{ view, EffectDesc: class {}, NavaraEffect: class {} },
{
center: geo.cartesian3.fromDegrees(3, 27, 0),
radiusM: 250_000,
color: [0, 0, 1, 0.2],
lineColor: [0, 0, 1, 1],
haloColor: [0, 0, 0, 0.8],
depth: 0.35,
lineWidth: 3,
haloWidth: 1,
},
);
disc.draw({ level: 0, radii: [0.1, 0.4, 0.7], alphas: [0, 0, 0] });
assert.equal(effects.length, 0, 'nothing drawn at rest');
disc.draw({ level: 1, radii: [0.1, 0.4, 0.7], alphas: [0.5, 0.3, 0.2] });
assert.equal(effects.length, 1);
const owner = effects[0].config.gevAwarenessPulses.owner;
assert.deepEqual(owner.radii, [0.1, 0.4, 0.7, 0]);
assert.deepEqual(owner.alphas, [0.5, 0.3, 0.2, 0]);
assert.ok(Math.abs(owner.center.length() - 1) < 1e-9, 'a unit direction');
disc.draw({ level: 0, radii: [0, 0, 0], alphas: [0, 0, 0] });
assert.equal(effects[0].visible, false);
disc.destroy();
assert.equal(effects[0].deleted, true);
});
+77 -8
View File
@@ -1,7 +1,21 @@
import * as geo from '../../../geo/index.js';
import { createEngineLayerView } from '../../shared/render/engineView.js';
import { AWARENESS_RADIUS_M } from '../../../data/militaryAwarenessEngine.js';
import { CONTEXT_RIM_HEIGHT_M } from '../policy.js';
import {
AWARENESS_RANGE_PULSE,
AWARENESS_RANGE_RING_STYLE,
CONTEXT_RIM_HEIGHT_M,
} from '../policy.js';
import { createAwarenessPulseDisc } from './navaraPulses.js';
/** `rgba(r, g, b, a)` as an rgba array in 0..1. */
function rgbaArray(css) {
const [r, g, b, a = 1] = css.match(/[\d.]+/g).map(Number);
return [r / 255, g / 255, b / 255, a];
}
const RING_CORE = rgbaArray(AWARENESS_RANGE_RING_STYLE.color);
const RING_HALO = rgbaArray(AWARENESS_RANGE_RING_STYLE.outlineColor);
const withAlpha = (rgba, alpha) => [rgba[0], rgba[1], rgba[2], rgba[3] * alpha];
// Sample in the subject's east/north plane, then lift each vertex onto
// the ellipsoid. This also works when a ring crosses the date line or a pole.
@@ -33,10 +47,16 @@ function rimPositions(center) {
return positions;
}
export function createAwarenessNavaraRenderer(engine) {
/**
* The awareness layer's view of a Navara globe. With the globe's native view
* (`native`: view, EffectDesc and NavaraEffect) it also draws the range
* pulses; without one, the range ring alone.
*/
export function createAwarenessNavaraRenderer(engine, native = null) {
const binding = createEngineLayerView(engine, {
layerId: 'military-awareness',
});
const style = AWARENESS_RANGE_RING_STYLE;
return {
container: engine.host.container,
canvas: engine.host.canvas,
@@ -44,20 +64,69 @@ export function createAwarenessNavaraRenderer(engine) {
get trackedEntity() {
return engine.camera.following()?.native;
},
cameraHeight: () => engine.camera.height(),
onFrame: (run) => binding.view.on('preRender', run),
onFrameEnd: (run) => engine.frames.postRender.addEventListener(run),
frameContact: (center, radius, duration) =>
engine.camera.flyToSphere({ center, radius }, { duration }),
/**
* The 250 km ring: a core line over a wider dark halo, as the Cesium
* ring's outline material draws it; `setAlpha` flashes it.
*/
createRim(center) {
const line = engine.lines.createLine({
id: 'gev-awareness-rim',
color: [98 / 255, 181 / 255, 1, 0.72],
widthPx: 1,
const halo = engine.lines.createLine({
id: 'gev-awareness-rim-halo',
color: RING_HALO,
widthPx: style.width,
arc: 'geodesic',
});
const move = (position) => line.setPositions(rimPositions(position));
const core = engine.lines.createLine({
id: 'gev-awareness-rim',
color: RING_CORE,
widthPx: Math.max(1, style.width - 2 * style.outlineWidth),
arc: 'geodesic',
});
let alpha = 1;
const move = (position) => {
const positions = rimPositions(position);
halo.setPositions(positions);
core.setPositions(positions);
};
move(center);
return { move, destroy: () => line.destroy() };
return {
move,
setAlpha(next) {
if (next === alpha) return;
alpha = next;
halo.setColor(withAlpha(RING_HALO, alpha));
core.setColor(withAlpha(RING_CORE, alpha));
},
destroy() {
core.destroy();
halo.destroy();
},
};
},
// The pulse disc, when the native view can draw it.
...(native?.view && native.EffectDesc && native.NavaraEffect
? {
createPulses: (center) =>
createAwarenessPulseDisc(engine, native, {
center,
radiusM: AWARENESS_RADIUS_M,
// The band's own opacity, as the Cesium material sets it.
color: [
...RING_CORE.slice(0, 3),
AWARENESS_RANGE_PULSE.gradientOpacity,
],
lineColor: RING_CORE,
haloColor: RING_HALO,
depth: AWARENESS_RANGE_PULSE.gradientDepthFraction,
lineWidth: AWARENESS_RANGE_PULSE.width,
haloWidth: AWARENESS_RANGE_PULSE.outlineWidth,
}),
}
: {}),
destroy: binding.destroy,
};
}
+222
View File
@@ -0,0 +1,222 @@
// The Global Context range pulses on Navara: the pulse disc the Cesium globe
// draws as a translucent ellipse primitive (rendering.js), drawn here over
// visible terrain and 3D tiles by one non-pickable classification pass.
// Each surface pixel's distance from the subject along the Earth sets where
// it lies within the range, and the same pulse lines, halos and inward band
// as Cesium's material are drawn there. Nothing above the surface is touched.
import { Effect, EffectAttribute } from 'postprocessing';
import { Matrix4, Uniform, Vector3, Vector4 } from 'three';
import {
groundSurfaceUniforms,
updateGroundSurface,
} from '../../shared/render/navaraGroundSurface.js';
const KEY = 'gevAwarenessPulses';
const registeredViews = new WeakSet();
const MEAN_EARTH_RADIUS_M = 6_371_000;
const fragmentShader = /* glsl */ `
uniform sampler2D tAllDepth;
uniform sampler2D tSurfaceDepth;
uniform mat4 uInverseProjection;
uniform mat4 uInverseView;
uniform float uLogDepthFC;
uniform vec3 uCenter;
uniform float uRadiusAngle;
uniform vec4 uColor;
uniform vec4 uLineColor;
uniform vec4 uHaloColor;
uniform vec4 uRadii;
uniform vec4 uAlphas;
uniform float uDepth;
uniform float uLineWidth;
uniform float uHaloWidth;
uniform float uPixelRatio;
vec3 toDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 toLinear(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
vec4 over(vec4 top, vec4 under) {
float a = top.a + under.a * (1.0 - top.a);
if (a <= 0.0) return vec4(0.0);
return vec4((top.rgb * top.a + under.rgb * under.a * (1.0 - top.a)) / a, a);
}
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
outputColor=inputColor;
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
vec3 point=ray.xyz*(distance/-ray.z);
vec3 world=(uInverseView*vec4(point,1.0)).xyz;
vec3 up=normalize(world);
// The angle to the subject along the Earth, well conditioned near it too;
// r is the fraction of the range, as the Cesium disc's texture radius.
float r=atan(length(cross(up,uCenter)),dot(up,uCenter))/uRadiusAngle;
// Change in r per CSS pixel, taken before any branch.
float cssPx=max(length(vec2(dFdx(r),dFdy(r))),1e-7)*uPixelRatio;
if(depth>=1.0||r>1.0) return;
float surface=texture2D(tSurfaceDepth,uv).r;
if(depth<surface-2e-7) return;
float band=0.0;
for(int i=0;i<4;i++) {
float inside=uRadii[i]-r;
if(inside>=0.0&&inside<=uDepth) {
float t=1.0-inside/uDepth;
band+=uAlphas[i]*t*t;
}
}
vec4 result=vec4(uColor.rgb,clamp(band*uColor.a,0.0,1.0));
float outerHalf=uLineWidth*0.5;
float coreHalf=max(uLineWidth-uHaloWidth,0.0)*0.5;
for(int i=0;i<4;i++) {
float d=abs(uRadii[i]-r)/cssPx;
float cover=clamp(outerHalf+0.5-d,0.0,1.0);
if(cover<=0.0) continue;
float core=clamp(coreHalf+0.5-d,0.0,1.0);
vec4 pulse=mix(uHaloColor,uLineColor,core);
result=over(vec4(pulse.rgb,pulse.a*cover*uAlphas[i]),result);
}
if(result.a<=0.0) return;
vec3 color=mix(toDisplay(inputColor.rgb),result.rgb,result.a);
outputColor=vec4(toLinear(color),inputColor.a);
}
`;
function pulseEffectClass(EffectDesc, NavaraEffect) {
return class AwarenessPulseEffectDesc extends EffectDesc {
static key = KEY;
static insertAfter = [
'gevEarthquakeGround',
'gevObservedWeather',
'gevNepalEvent',
'gevRecentImagery',
'gevFirmsHeat',
'gevFirmsGround',
'toneMapping',
];
static insertBefore = [
'gevPreSharpenOverlay',
'gevSharpen',
'smaa',
'fxaa',
'gevOverlay',
'final',
];
constructor(view, context, config) {
super(view, context, config);
this.owner = config[KEY].owner;
}
createPass() {
this.effect = new Effect('GevAwarenessPulses', fragmentShader, {
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms(),
['uInverseView', new Uniform(new Matrix4())],
['uCenter', new Uniform(new Vector3(1, 0, 0))],
['uRadiusAngle', new Uniform(1)],
['uColor', new Uniform(new Vector4())],
['uLineColor', new Uniform(new Vector4())],
['uHaloColor', new Uniform(new Vector4())],
['uRadii', new Uniform(new Vector4())],
['uAlphas', new Uniform(new Vector4())],
['uDepth', new Uniform(0.35)],
['uLineWidth', new Uniform(3)],
['uHaloWidth', new Uniform(1)],
['uPixelRatio', new Uniform(1)],
]),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
}
updateFrame() {
const owner = this.owner,
camera = this.view.camera.raw;
if (!this.effect || !camera) return;
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
const u = this.effect.uniforms;
u.get('uInverseView').value.copy(camera.matrixWorld);
u.get('uCenter').value.copy(owner.center);
u.get('uRadiusAngle').value = owner.radiusAngle;
u.get('uColor').value.fromArray(owner.color);
u.get('uLineColor').value.fromArray(owner.lineColor);
u.get('uHaloColor').value.fromArray(owner.haloColor);
u.get('uRadii').value.fromArray(owner.radii);
u.get('uAlphas').value.fromArray(owner.alphas);
u.get('uDepth').value = owner.depth;
u.get('uLineWidth').value = owner.lineWidth;
u.get('uHaloWidth').value = owner.haloWidth;
// Buffer pixels per CSS pixel: the pass can run above the canvas's
// CSS size, and the pulse widths are in CSS pixels.
const cssWidth = owner.cssWidth();
u.get('uPixelRatio').value =
cssWidth > 0
? this.ctx.getInputBuffer().width / cssWidth
: globalThis.devicePixelRatio || 1;
}
onDestroy() {
this.effect?.dispose();
super.onDestroy();
}
};
}
/**
* The pulse disc on one Navara view around `center` (Earth-fixed metres):
* `draw({ radii, alphas, level })` each frame with the fronts as fractions of
* `radiusM` and their opacities, `move(center)`, and `destroy()`. Colors are
* rgba arrays in 0..1: the band, the pulse line and its halo.
*/
export function createAwarenessPulseDisc(
engine,
{ view, EffectDesc, NavaraEffect },
{ center, radiusM, color, lineColor, haloColor, depth, lineWidth, haloWidth },
) {
if (!registeredViews.has(view)) {
view.registerEffect(KEY, pulseEffectClass(EffectDesc, NavaraEffect));
registeredViews.add(view);
}
const owner = {
cssWidth: () => engine.host?.canvas?.clientWidth ?? 0,
center: new Vector3(),
radiusAngle: radiusM / MEAN_EARTH_RADIUS_M,
color,
lineColor,
haloColor,
radii: [0, 0, 0, 0],
alphas: [0, 0, 0, 0],
depth,
lineWidth,
haloWidth,
};
let handle = null,
shown = false,
destroyed = false;
const move = (next) => owner.center.set(next.x, next.y, next.z).normalize();
move(center);
return {
move(next) {
if (!destroyed) move(next);
},
draw({ radii, alphas, level }) {
if (destroyed) return;
for (let i = 0; i < 4; i++) {
owner.radii[i] = radii[i] ?? 0;
owner.alphas[i] = alphas[i] ?? 0;
}
const show = level > 0;
if (show === shown) return;
shown = show;
if (!handle && show)
handle = view.addEffect({ [KEY]: { owner }, visible: true });
else handle?.update({ visible: show });
},
destroy() {
if (destroyed) return;
destroyed = true;
handle?.delete();
handle = null;
engine.render.request('awareness-pulses-destroy');
},
};
}
+181 -85
View File
@@ -662,6 +662,7 @@ export function createRendering({
clearAwarenessEchoField();
if (visual?.rim || (visual?.entities && viewer)) {
visual.rim?.destroy();
visual.disc?.destroy();
for (const entity of visual.entities ?? [])
viewer.entities.remove(entity);
// Idle mode renders only on request; a removed ring must not linger.
@@ -670,6 +671,160 @@ export function createRendering({
layerState.visual = null;
}
/** A fresh pulse window, running from now. */
function newWake() {
return wakeAwarenessPulses(
{ startMs: -Infinity, untilMs: -Infinity },
performance.now(),
);
}
/**
* How far the pulses are on at a time (0 at rest or while `belowRim()`),
* and the ring's opacity: while pulses run it flashes as each one arrives
* and otherwise dims to `restOpacity`; as they settle it returns to full.
*/
function pulseClock(wake, belowRim) {
const levelAt = (nowMs) =>
wake && !belowRim() ? awarenessPulseLevel(nowMs, wake) : 0;
const ringAlpha = (nowMs) => {
const pulsed = Math.max(
AWARENESS_RANGE_RING_STYLE.restOpacity,
awarenessRingFlash(nowMs),
);
return 1 - levelAt(nowMs) * (1 - pulsed);
};
return { levelAt, ringAlpha, belowRim };
}
/**
* Run the pulses on a globe: each frame `draw` gets the level, the ring's
* opacity and the fronts (fractions of the range, opacities). Each drawn
* frame, whoever asked for it, arms one request a Cyber sonar interval
* later, as the Cyber sonar scene does. Requests never land within 80 ms
* of a frame, so they cannot beat the detection lane's 80 ms gate, and
* they stop once the pulses rest. The detection overlay pulses the
* brackets of flights and vessels a front crosses; it reads `center` in
* place, so it follows the subject too. Returns the stop.
*/
function runPulses({
center,
wake,
levelAt,
ringAlpha,
belowRim,
onFrame,
onFrameEnd,
draw,
}) {
const pulse = AWARENESS_RANGE_PULSE;
const count = Math.min(pulse.count, AWARENESS_PULSE_GRADIENT_MAX);
const radii = [0, 0, 0, 0];
const alphas = [0, 0, 0, 0];
const removeFrame = onFrame(() => {
const nowMs = performance.now();
const level = levelAt(nowMs);
for (let index = 0; index < count; index++) {
const state = awarenessPulseState(nowMs, index);
radii[index] = state.radiusM / AWARENESS_RADIUS_M;
alphas[index] = state.alpha * level;
}
draw({ level, alpha: ringAlpha(nowMs), radii, alphas });
});
let timer = null;
const removeFrameEnd = onFrameEnd(() => {
globalThis.clearTimeout(timer);
timer = null;
if (performance.now() < wake.untilMs && !belowRim())
timer = globalThis.setTimeout(() => {
timer = null;
governorRequestRender('awareness-pulse');
}, CYBER_SONAR_RENDER_INTERVAL_MS);
});
setAwarenessEchoField({
center,
radiusM: AWARENESS_RADIUS_M,
pulse,
echo: AWARENESS_RANGE_ECHO,
level: levelAt,
});
return () => {
removeFrame?.();
removeFrameEnd?.();
globalThis.clearTimeout(timer);
timer = null;
};
}
/**
* Rebuild the visual when reduced motion is turned on or off mid-session.
* Returns the listener's removal.
*/
function watchReducedMotion() {
const motionQuery = globalThis.matchMedia?.(REDUCED_MOTION_QUERY);
const onMotionChange = () => {
if (layerState.subject) renderVisual(layerState.subject);
};
// Safari before 14 has only the older addListener / removeListener.
if (motionQuery?.addEventListener)
motionQuery.addEventListener('change', onMotionChange);
else motionQuery?.addListener?.(onMotionChange);
return () => {
if (motionQuery?.removeEventListener)
motionQuery.removeEventListener('change', onMotionChange);
else motionQuery?.removeListener?.(onMotionChange);
};
}
/**
* The visual through the display of an engine without a Cesium viewer:
* its range ring and, unless reduced motion is on or it cannot draw them,
* the pulses and their echoes, timed as on Cesium.
*/
function renderDisplayVisual(key, groundCenter, reducedMotion) {
const display = layerState.display;
const animate = Boolean(
!reducedMotion &&
display.createPulses &&
display.onFrameEnd &&
display.cameraHeight,
);
const wake = animate ? newWake() : null;
const { levelAt, ringAlpha, belowRim } = pulseClock(
wake,
() => display.cameraHeight() < CONTEXT_RIM_HEIGHT_M,
);
const center = geo.cartesian3.clone(groundCenter);
const rim = display.createRim(groundCenter);
const disc = animate ? display.createPulses(groundCenter) : null;
const stopPulses = animate
? runPulses({
center,
wake,
levelAt,
ringAlpha,
belowRim,
onFrame: display.onFrame,
onFrameEnd: display.onFrameEnd,
draw({ level, alpha, radii, alphas }) {
rim.setAlpha?.(alpha);
disc.draw({ level, radii, alphas });
},
})
: null;
layerState.visual = {
key,
rim,
disc,
center,
reducedMotion,
wake,
stopPulses,
removeMotionListener: watchReducedMotion(),
};
governorRequestRender('awareness-visual');
}
function renderVisual(subject) {
if (!layerState.display || !subject?.position) return;
const cartographic = geo.cartographic.fromCartesian(subject.position);
@@ -693,6 +848,7 @@ export function createRendering({
) {
if (layerState.visual.rim) {
layerState.visual.rim.move(groundCenter);
layerState.visual.disc?.move(groundCenter);
geo.cartesian3.clone(groundCenter, layerState.visual.center);
} else {
const positions = awarenessRangeRingPositions(
@@ -724,17 +880,11 @@ export function createRendering({
}
clearVisual();
// Only a Cesium globe has the pulse disc and its echoes; another engine
// draws the plain range ring.
// A Cesium globe draws the ring and the pulse disc as primitives below;
// another engine through its display: a ring, and pulses when it can.
const viewer = layerState.display.viewer;
if (!viewer) {
layerState.visual = {
key,
rim: layerState.display.createRim(groundCenter),
center: geo.cartesian3.clone(groundCenter),
reducedMotion,
};
governorRequestRender('awareness-visual');
renderDisplayVisual(key, groundCenter, reducedMotion);
return;
}
const entities = [];
@@ -749,18 +899,14 @@ export function createRendering({
scene?.postRender?.addEventListener,
);
// Pulses run from now until `wake.untilMs`, then rest.
const wake = animate
? wakeAwarenessPulses(
{ startMs: -Infinity, untilMs: -Infinity },
performance.now(),
)
: null;
const wake = animate ? newWake() : null;
// From a camera below the rim height the disc would sweep across the sky,
// so it is hidden there, and the pulses rest with it: no flash, no
// echoes, no frames.
const belowRim = () => cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M;
const levelAt = (nowMs) =>
wake && !belowRim() ? awarenessPulseLevel(nowMs, wake) : 0;
const { levelAt, ringAlpha, belowRim } = pulseClock(
wake,
() => cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M,
);
// A polyline, not an ellipse outline: WebGL draws ellipse outlines at
// 1 px whatever `outlineWidth` says, and the ring needs a halo.
@@ -772,12 +918,6 @@ export function createRendering({
AWARENESS_RADIUS_M,
CONTEXT_RIM_HEIGHT_M,
);
// While pulses run, the ring flashes as each one arrives and otherwise
// dims to `restOpacity`; as they settle it returns to full and stays.
const ringAlpha = (nowMs) => {
const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs));
return 1 - levelAt(nowMs) * (1 - pulsed);
};
let ringEntity = null;
let ringMaterial = null;
let ringPrimitive = null;
@@ -843,66 +983,26 @@ export function createRendering({
gradient = scene.primitives.add(
createPulseGradient(groundCenter, material),
);
const radii = [0, 0, 0, 0];
const alphas = [0, 0, 0, 0];
const removePreRender = scene.preRender.addEventListener(() => {
const nowMs = performance.now();
const level = levelAt(nowMs);
const alpha = ringAlpha(nowMs);
ringMaterial.uniforms.color.alpha = core.alpha * alpha;
ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha;
// The disc is looked up each frame: a subject move swaps it.
const disc = layerState.visual?.gradient;
if (disc) disc.show = level > 0;
for (let index = 0; index < count; index++) {
const state = awarenessPulseState(nowMs, index);
radii[index] = state.radiusM / AWARENESS_RADIUS_M;
alphas[index] = state.alpha * level;
}
Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii);
Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas);
});
// Each drawn frame, whoever asked for it, arms one request a Cyber
// sonar interval later, as the Cyber sonar scene does. Requests never
// land within 80 ms of a frame, so they cannot beat the detection
// lane's 80 ms gate, and they stop once the pulses rest.
let timer = null;
const removePostRender = scene.postRender.addEventListener(() => {
globalThis.clearTimeout(timer);
timer = null;
if (performance.now() < wake.untilMs && !belowRim())
timer = globalThis.setTimeout(() => {
timer = null;
governorRequestRender('awareness-pulse');
}, CYBER_SONAR_RENDER_INTERVAL_MS);
});
stopPulses = () => {
removePreRender();
removePostRender();
globalThis.clearTimeout(timer);
timer = null;
};
// The detection overlay pulses the brackets of flights and vessels a
// front crosses; it reads the centre in place, so it follows too.
setAwarenessEchoField({
stopPulses = runPulses({
center,
radiusM: AWARENESS_RADIUS_M,
pulse,
echo: AWARENESS_RANGE_ECHO,
level: levelAt,
wake,
levelAt,
ringAlpha,
belowRim,
onFrame: (run) => scene.preRender.addEventListener(run),
onFrameEnd: (run) => scene.postRender.addEventListener(run),
draw({ level, alpha, radii, alphas }) {
ringMaterial.uniforms.color.alpha = core.alpha * alpha;
ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha;
// The disc is looked up each frame: a subject move swaps it.
const disc = layerState.visual?.gradient;
if (disc) disc.show = level > 0;
Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii);
Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas);
},
});
}
// Turning reduced motion on or off mid-session rebuilds the visual.
const motionQuery = globalThis.matchMedia?.(REDUCED_MOTION_QUERY);
const onMotionChange = () => {
if (layerState.subject) renderVisual(layerState.subject);
};
// Safari before 14 has only the older addListener / removeListener.
if (motionQuery?.addEventListener)
motionQuery.addEventListener('change', onMotionChange);
else motionQuery?.addListener?.(onMotionChange);
layerState.visual = {
key,
entities,
@@ -915,11 +1015,7 @@ export function createRendering({
reducedMotion,
wake,
stopPulses,
removeMotionListener: () => {
if (motionQuery?.removeEventListener)
motionQuery.removeEventListener('change', onMotionChange);
else motionQuery?.removeListener?.(onMotionChange);
},
removeMotionListener: watchReducedMotion(),
};
governorRequestRender('awareness-visual');
}