diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3777cadeb..c244ea378 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index ea52672d8..0fc3ba81a 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.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'); diff --git a/src/layers/awareness/displayPulses.test.mjs b/src/layers/awareness/displayPulses.test.mjs new file mode 100644 index 000000000..125eda7a8 --- /dev/null +++ b/src/layers/awareness/displayPulses.test.mjs @@ -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); +}); diff --git a/src/layers/awareness/render/navara.js b/src/layers/awareness/render/navara.js index 253fc5576..2a77fee87 100644 --- a/src/layers/awareness/render/navara.js +++ b/src/layers/awareness/render/navara.js @@ -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, }; } diff --git a/src/layers/awareness/render/navaraPulses.js b/src/layers/awareness/render/navaraPulses.js new file mode 100644 index 000000000..50d797eb8 --- /dev/null +++ b/src/layers/awareness/render/navaraPulses.js @@ -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=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'); + }, + }; +} diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 5c776ac68..eeeb3b76c 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -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'); }