mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Run the Global Context range pulses and their echoes on Navara, sharing the pulse clock with Cesium
This commit is contained in:
1 parent
727bb29cae
commit
43b4e9348b
6 files changed
+753
-96
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user