From cb350fa58dc156fab25869c9f5b014b34cbcfff9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 03:18:59 +0400 Subject: [PATCH] Share observed weather clocks and render radar, infrared and lightning on Navara --- scripts/package-boundaries.json | 2 + scripts/qa-weather-engine-switch.mjs | 246 +++++++++++++++++++ src/app/constructCatalog.js | 14 +- src/app/navaraLayers.test.mjs | 3 + src/app/navaraMode.js | 8 + src/layers/weather/index.js | 107 +++++---- src/layers/weather/render/navara.js | 63 +++++ src/layers/weather/render/navaraSurface.js | 259 +++++++++++++++++++++ src/layers/weather/shellRendering.js | 31 +-- 9 files changed, 674 insertions(+), 59 deletions(-) create mode 100644 scripts/qa-weather-engine-switch.mjs create mode 100644 src/layers/weather/render/navara.js create mode 100644 src/layers/weather/render/navaraSurface.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b4bceed86..3fd2b0935 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3672,6 +3672,8 @@ "src/layers/earthquakes/render/navaraGrid.js", "src/layers/installations/render/navara.js", "src/layers/awareness/render/navara.js", + "src/layers/weather/render/navara.js", + "src/layers/weather/render/navaraSurface.js", "src/layers/ghostMaps/render/navara.js", "src/layers/ghostMaps/render/navaraPick.js", "src/layers/shared/render/groundRing.js", diff --git a/scripts/qa-weather-engine-switch.mjs b/scripts/qa-weather-engine-switch.mjs new file mode 100644 index 000000000..ea8dab68c --- /dev/null +++ b/scripts/qa-weather-engine-switch.mjs @@ -0,0 +1,246 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +import assert from 'node:assert/strict'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; + +const base = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(base.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + base.port, + ) +) + throw new Error('Supply an unreserved local keyless preview URL'); +base.search = '?engine=navara&welcome=0&look=cinematic'; +base.hash = + 'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l='; +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + await p.setViewport({ width: 1024, height: 768 }); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message.split('?')[0]); + }); + const times = ['2026-11-02T12:00:00.000Z', '2026-11-02T12:05:00.000Z']; + const ids = ['weather-radar', 'weather-satellite', 'weather-lightning']; + const images = new Map(), + reads = new Map(); + await p.setRequestInterception(true); + p.on('request', async (req) => { + const url = new URL(req.url()); + if (!url.pathname.startsWith('/api/weather/')) return req.continue(); + const product = url.searchParams.get('product'); + if (url.pathname.endsWith('manifest')) { + reads.set(product, (reads.get(product) || 0) + 1); + return req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + schemaVersion: 1, + product, + times, + latest: times.at(-1), + maxLevel: 6, + tileSize: 256, + tilingScheme: 'geographic', + bounds: { west: -130, south: 20, east: -50, north: 60 }, + display: { credit: 'QA weather observations' }, + }), + }); + } + const image = url.pathname.endsWith('image'); + const width = image + ? Number( + ( + url.searchParams.get('size') || + (product === 'clouds' ? '2048' : '4096') + ).split('x')[0], + ) + : 256; + const previous = url.searchParams.get('time') === times[0]; + const color = + product === 'lightning' + ? previous + ? '#ff00ff' + : '#00ffff' + : product === 'radar' + ? '#ff0000' + : '#ffffff'; + const key = width + color; + if (!images.has(key)) + images.set( + key, + sharp({ + create: { + width, + height: image ? width / 2 : width, + channels: 4, + background: color, + }, + }) + .png() + .toBuffer(), + ); + return req.respond({ + status: 200, + contentType: 'image/png', + body: await images.get(key), + }); + }); + p.on('console', (msg) => { + if (msg.type() === 'error' && /Shader|WebGLProgram/.test(msg.text())) + console.log('GPU', msg.text().slice(0, 3000)); + }); + await p.goto(base.href); + const ready = async (kind) => { + await p.waitForFunction( + (k) => + window.__godsEyeView?.engine.kind === k && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 90000 }, + kind, + ); + await p.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + const waitTime = (ids, time) => + p.waitForFunction( + (ids, time) => + ids.every( + (id) => + window.__godsEyeView?.dataManager.layers + .get(id) + .module.getDiagnostics().time === time, + ), + { timeout: 50000 }, + ids, + time, + ); + const sample = async () => { + await new Promise((r) => setTimeout(r, 500)); + const crop = await sharp(await p.screenshot()) + .extract({ left: 535, top: 388, width: 12, height: 12 }) + .removeAlpha() + .raw() + .toBuffer(); + return [0, 1, 2].map( + (i) => + crop.reduce((n, v, k) => n + (k % 3 === i ? v : 0), 0) / + (crop.length / 3), + ); + }; + await p.evaluate(() => { + if (window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Keyless preview required'); + }); + for (const id of [ids[1], ids[0], ids[2]]) { + await p.evaluate( + (id) => + window.__godsEyeView.dataManager.setEnabled(id, true, { + origin: 'user', + }), + id, + ); + await waitTime([id], times[1]); + const latest = await sample(); + if (id === ids[1]) + assert.ok( + Math.min(...latest) > 150, + 'satellite must paint bright clouds', + ); + if (id === ids[0]) + assert.ok( + latest[0] > latest[1] + 50, + 'radar must paint red over satellite', + ); + if (id === ids[2]) + assert.ok( + latest[2] > latest[0] + 50, + 'lightning must paint cyan over radar', + ); + console.log('latest', id, latest); + } + await p.evaluate((ids) => { + window.qaWeather = ids.map((id) => + window.__godsEyeView.dataManager.layers.get(id), + ); + window.qaWeather[0].module.setParams({ step: -1 }); + }, ids); + await waitTime(ids, times[0]); + const polls = [...reads.entries()]; + for (const kind of ['navara', 'cesium', 'navara']) { + if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { + await p.select('#globe-engine', kind); + await ready(kind); + } + await waitTime(ids, times[0]); + const state = await p.evaluate( + (ids) => + ids.map((id, i) => { + const entry = window.__godsEyeView.dataManager.layers.get(id); + const d = entry.module.getDiagnostics(); + return { + same: entry === window.qaWeather[i], + mode: d.clock.mode, + target: d.clock.target, + error: d.error, + time: d.time, + }; + }), + ids, + ); + for (const product of state) + assert.deepEqual(product, { + same: true, + mode: 'history', + target: times[0], + error: null, + time: times[0], + }); + const rgb = await sample(); + console.log(kind, rgb); + assert.ok( + rgb[0] > rgb[1] + 15 && rgb[2] > rgb[1] + 15, + kind + ' lightning must draw magenta above radar', + ); + assert.deepEqual( + [...reads.entries()], + polls, + 'A globe change must not refetch the observation manifest', + ); + console.log( + 'PASS', + kind, + 'common clock, all three frames, visible lightning', + ); + } + // Exercise the raised intersection branch without contacting a tile provider. + await p.evaluate(() => { + const maps = window.__godsEyeView.engine.maps; + const actual = maps.photorealActive.bind(maps); + maps.photorealActive = () => true; + window.qaRestoreSurface = () => { + maps.photorealActive = actual; + }; + window.__godsEyeView.engine.render.request('qa-raised-weather'); + }); + const raised = await sample(); + assert.ok( + raised[0] > raised[1] + 15 && raised[2] > raised[1] + 15, + 'raised weather must remain visible', + ); + await p.evaluate(() => window.qaRestoreSurface()); + assert.deepEqual(errors, []); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 2bbc0f7fb..6c50d4dbb 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -4,7 +4,7 @@ import { createWeatherLayer } from '../layers/weather/index.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js'; import { createWindLayer } from '../layers/wind/index.js'; import { createLayerCatalog } from './catalog.js'; -import { createLayerAvailability } from './layers/renderers.js'; +import { createLayerAvailability, rendererFor } from './layers/renderers.js'; import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; @@ -82,11 +82,15 @@ export function createApplicationCatalog({ sources = sourceComposition.sources; const militaryRegistry = layerSession?.militaryRegistry ?? createMilitaryRegistry(); - const weatherClock = createWeatherClock(); + const weatherClock = layerSession?.weatherClock ?? createWeatherClock(); + if (layerSession && !layerSession.weatherClock) { + layerSession.weatherClock = weatherClock; + layerSession.lifetime.signal.addEventListener('abort', weatherClock.destroy, { once: true }); + } const dispose = () => { signal.removeEventListener('abort', dispose); if (!layerSession) militaryRegistry.dispose(); - weatherClock.destroy(); + if (!layerSession) weatherClock.destroy(); }; signal.addEventListener('abort', dispose, { once: true }); try { @@ -279,12 +283,14 @@ export function createApplicationCatalog({ ...['weather-radar', 'weather-satellite', 'weather-lightning'].map( (id) => [ id, - () => + () => sessionModel(id, () => createWeatherLayer({ feed: sources.weather, id, clock: weatherClock, + createNativeRendering: (engine, options) => rendererFor(id, engine, options), }), + ), ], ), [ diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 31f844812..d61f4c49c 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -62,6 +62,9 @@ test('the Navara mode registers migrated renderers once', async () => { 'ais-live-vessels', 'military-installations', 'military-awareness', + 'weather-radar', + 'weather-satellite', + 'weather-lightning', 'local-firms', ], ); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 6439ce2fa..fcb1da2c7 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -59,6 +59,8 @@ const NAVARA_CREDIT_KEYS = new Set([ 'eccc-aqhi', 'wfigs', 's2-ghostmaps', + 'weather-noaa', + 'weather-lightning', 'firms', 'gbfs', 'overture-military-names', @@ -107,6 +109,12 @@ export async function registerNavaraLayerRenderers() { bikeshare: async () => (await import('../layers/bikeshare/render/navara.js')) .createBikeshareNavaraRenderer, + ...Object.fromEntries(['weather-radar', 'weather-satellite', 'weather-lightning'].map((id) => [ + id, async () => { + const { createWeatherNavaraRenderer } = await import('../layers/weather/render/navara.js'); + return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options); + }, + ])), 's2-ghostmaps': async () => { const { createGhostMapsNavaraRenderer } = await import('../layers/ghostMaps/render/navara.js'); return (engine) => createGhostMapsNavaraRenderer(engine, navaraViews.get(engine).view); diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index e318e3087..e2bfdf16a 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -59,6 +59,7 @@ export function createWeatherLayer({ id = 'weather-radar', cesium = Cesium, createRendering = createWeatherRendering, + createNativeRendering = null, documentRef = globalThis.document, eventTarget = globalThis.window, matchMedia = globalThis.matchMedia?.bind(globalThis), @@ -71,6 +72,8 @@ export function createWeatherLayer({ let product = radar ? 'radar' : lightning ? 'lightning' : 'clouds-regional'; let opacity = 'strong'; let infrared = 100; + let sceneEngine = null; + let lastShown = null; let viewer = null, rendering = null, manifest = null, @@ -104,9 +107,11 @@ export function createWeatherLayer({ const isLatest = () => clock ? clock.getState().mode === 'latest' : followLatest; const getHost = () => - imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' }; + sceneEngine?.kind === 'navara' + ? { kind: sceneEngine.maps.photorealActive() ? 'tileset' : 'globe', collection: sceneEngine } + : imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' }; const notify = () => listener?.(); - const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time); + const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time ?? lastShown); const unsubscribeClock = clock?.subscribe(() => { if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.(); notify(); @@ -159,7 +164,7 @@ export function createWeatherLayer({ : shownTime(); if ( time !== shownTime() || - (satellite && rendering.getDiagnostics().infrared !== infrared) + (satellite && rendering?.getDiagnostics().infrared !== infrared) ) void show(time); else schedule(); @@ -194,7 +199,7 @@ export function createWeatherLayer({ return true; } function registerClock() { - if (!clock || unregisterClock || !rendering || !enabled) return; + if (!clock || unregisterClock || !enabled) return; unregisterClock = clock.register({ id, get maxGapMs() { @@ -227,6 +232,7 @@ export function createWeatherLayer({ if (!enabled || hostHidden || !manifest?.times?.includes(time)) return false; signal?.throwIfAborted(); + if (!rendering) return false; frameRequest?.abort(); const controller = new AbortController(); frameRequest = controller; @@ -244,6 +250,7 @@ export function createWeatherLayer({ return false; if (ok) { noFrame = false; + lastShown = time; rendering.setHidden?.(false); void warmNext(time); } @@ -269,6 +276,14 @@ export function createWeatherLayer({ } } } + function watchCamera() { + const moveEnd = sceneEngine?.camera?.moveEnd ?? viewer?.camera?.moveEnd; + removeCamera ??= moveEnd?.addEventListener(() => { + checkHost(); + notify(); + }); + } + const layer = { id, name: radar @@ -279,24 +294,47 @@ export function createWeatherLayer({ icon: radar ? '◉' : lightning ? 'ϟ' : '☁', source: 'NOAA nowCOAST · OBSERVED', updateInterval: lightning ? 600_000 : 120_000, - init(nextViewer) { - viewer = nextViewer; - rendering = createRendering({ - viewer, - cesium, - getHost, - onChange: notify, - }); - checkHost(false); - eventTarget?.addEventListener?.( - 'gev:map-stack-changed', - onMapStackChanged, - ); - rendering.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8); + init(nextViewer, options = {}) { motion = matchMedia?.('(prefers-reduced-motion: reduce)'); motion?.addEventListener?.('change', onVisibility); documentRef?.addEventListener?.('visibilitychange', onVisibility); - registerClock(); + if (nextViewer || options.engine) layer.attachRenderer(nextViewer, options); + }, + attachRenderer(nextViewer, { engine = null } = {}) { + viewer = nextViewer; + sceneEngine = engine; + const options = { viewer, cesium, getHost, onChange: notify }; + rendering = engine?.kind === 'navara' + ? createNativeRendering(engine, options) + : createRendering(options); + checkHost(false); + eventTarget?.addEventListener?.('gev:map-stack-changed', onMapStackChanged); + rendering.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8); + if (!enabled) return; + watchCamera(); + if (!manifest) return; + const time = clock && !isLatest() ? clock.selectFor(id, clock.getState().target) : + (followLatest ? manifest.latest : lastShown); + if (time) void show(time); + else rendering.setHidden(true); + }, + detachRenderer() { + ++generation; + frameRequest?.abort(); + frameRequest = null; + clearTimeout(timer); + timer = null; + removeCamera?.(); + removeCamera = null; + eventTarget?.removeEventListener?.('gev:map-stack-changed', onMapStackChanged); + rendering?.clear(); + rendering = null; + hostCollection = null; + imageryHost = null; + runNavigation = null; + sceneEngine = null; + viewer = null; + loading = Boolean(request && !request.signal.aborted); }, attachShellServices(services) { imageryHost = @@ -312,12 +350,7 @@ export function createWeatherLayer({ }, enable() { enabled = true; - if (!removeCamera) { - removeCamera = viewer.camera.moveEnd?.addEventListener(() => { - checkHost(); - notify(); - }); - } + watchCamera(); registerClock(); }, disable() { @@ -335,6 +368,7 @@ export function createWeatherLayer({ stop(); rendering?.clear(); manifest = null; + lastShown = null; loading = false; error = null; followLatest = true; @@ -394,7 +428,7 @@ export function createWeatherLayer({ : shownTime(); if ( shownTime() !== time || - (satellite && rendering.getDiagnostics().infrared !== infrared) + (satellite && rendering?.getDiagnostics().infrared !== infrared) ) await show(time, controller.signal); return ( @@ -476,7 +510,7 @@ export function createWeatherLayer({ ? { west: 110, south: -25, east: 0, north: 80 } : manifest.bounds); runNavigation(() => - viewer.camera.flyTo({ + (sceneEngine?.camera ?? viewer.camera).flyTo({ destination: cesium.Rectangle.fromDegrees( b.west, b.south, @@ -577,18 +611,18 @@ export function createWeatherLayer({ const shownProblem = problem ? presentWeatherProblem(problem) : null; const shownHost = hostStatus ? t(hostStatus) : null; const b = manifest?.bounds; - const canvas = viewer?.scene?.canvas; + const canvas = sceneEngine?.host.canvas ?? viewer?.scene?.canvas; const point = canvas && - viewer.camera.pickEllipsoid?.( + (sceneEngine?.camera ?? viewer.camera).pickEllipsoid?.( new cesium.Cartesian2( canvas.clientWidth / 2, canvas.clientHeight / 2, ), - viewer.scene.globe.ellipsoid, + viewer?.scene?.globe?.ellipsoid, ); const camera = point - ? cesium.Cartographic.fromCartesian(point, viewer.scene.globe.ellipsoid) + ? cesium.Cartographic.fromCartesian(point, viewer?.scene?.globe?.ellipsoid) : null; const lon = camera ? cesium.Math.toDegrees(camera.longitude) : 0; const lat = camera ? cesium.Math.toDegrees(camera.latitude) : 0; @@ -873,19 +907,10 @@ export function createWeatherLayer({ }, destroy() { layer.disable(); + layer.detachRenderer(); unsubscribeClock?.(); - removeCamera?.(); - removeCamera = null; motion?.removeEventListener?.('change', onVisibility); documentRef?.removeEventListener?.('visibilitychange', onVisibility); - eventTarget?.removeEventListener?.( - 'gev:map-stack-changed', - onMapStackChanged, - ); - runNavigation = null; - imageryHost = null; - viewer = null; - rendering = null; listener = null; }, }; diff --git a/src/layers/weather/render/navara.js b/src/layers/weather/render/navara.js new file mode 100644 index 000000000..98e51b6f0 --- /dev/null +++ b/src/layers/weather/render/navara.js @@ -0,0 +1,63 @@ +import { createWeatherShell } from '../shellRendering.js'; +import { escapeCreditText, weatherDisplay } from '../display.js'; +import { createWeatherSurface } from './navaraSurface.js'; + +/** Use the same decoded frames, detail windows, infrared mask and prefetch policy as the weather shell. */ +export function createWeatherNavaraRenderer(engine, native, options) { + let shell = null, + alpha = 0.7, + hidden = false, + removeCredit = null, + credit = null; + const updateSnapshot = (snapshot) => { + const next = weatherDisplay(snapshot).credit ?? null; + if (next === credit) return; + removeCredit?.(); + credit = next; + removeCredit = next + ? engine.credits.addStatic(escapeCreditText(next)) + : null; + }; + const select = (product) => { + if (shell?.product === product) return shell; + shell?.clear(); + shell = createWeatherShell({ + ...options, + product, + requestRender: () => engine.render.request('weather'), + viewRectangle: () => engine.camera.viewRectangle(), + moveEnd: engine.camera.moveEnd, + createSurface: (frame) => + createWeatherSurface(engine, native, product, frame), + }); + shell.setAlpha(alpha); + shell.setHidden(hidden); + return shell; + }; + return { + setFrame(snapshot, time, params) { + updateSnapshot(snapshot); + return select(snapshot.product).setFrame(snapshot, time, params); + }, + updateSnapshot, + prefetch: (...args) => shell?.prefetch(...args), + cancelPrefetch: () => shell?.cancelPrefetch(), + rehome: () => shell?.rehome(), + setHidden(value) { + hidden = value; + shell?.setHidden(value); + }, + setAlpha(value) { + alpha = value; + shell?.setAlpha(value); + }, + getDiagnostics: () => + shell?.getDiagnostics() ?? { time: null, loading: false }, + clear() { + shell?.clear(); + shell = null; + removeCredit?.(); + removeCredit = credit = null; + }, + }; +} diff --git a/src/layers/weather/render/navaraSurface.js b/src/layers/weather/render/navaraSurface.js new file mode 100644 index 000000000..790fa32dd --- /dev/null +++ b/src/layers/weather/render/navaraSurface.js @@ -0,0 +1,259 @@ +import { Effect, EffectAttribute } from 'postprocessing'; +import { + CanvasTexture, + DataTexture, + LinearFilter, + Matrix4, + Uniform, +} from 'three'; +import { + groundSurfaceUniforms, + updateGroundSurface, +} from '../../shared/render/navaraGroundSurface.js'; +import { shellLift } from '../shellRendering.js'; + +const KEY = 'gevObservedWeather'; +const owners = new WeakMap(); +const products = ['Satellite', 'Radar', 'Lightning']; +const slotFor = (p) => + p === 'radar' ? 'Radar' : p === 'lightning' ? 'Lightning' : 'Satellite'; +const emptyRect = [0, 0, -1, -1]; +const rectArray = (r) => (r ? [r.west, r.south, r.east, r.north] : emptyRect); + +const shader = /* glsl */ ` +uniform sampler2D tAllDepth,tSurfaceDepth; +uniform mat4 uInverseProjection,uWorld; +uniform float uLogDepthFC; +uniform bool uRaised; +${products + .map( + (p) => `uniform sampler2D t${p},tDetail${p}; +uniform vec4 uBounds${p},uWindow${p}; +uniform vec2 uLook${p};`, + ) + .join('\n')} +vec3 toDisplay(vec3 x) { + return mix(12.92*x,1.055*pow(max(x,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),x)); +} +vec3 toLinear(vec3 x) { + return mix(x/12.92,pow((x+0.055)/1.055,vec3(2.4)),step(vec3(0.04045),x)); +} +vec2 geographic(vec3 point) { + float r=length(point.xy),theta=atan(point.z*6378137.0,r*6356752.314245); + float s=sin(theta),c=cos(theta); + return vec2(atan(point.y,point.x),atan(point.z+42841.311513*s*s*s,r-42697.672707*c*c*c)); +} +vec3 paint(vec3 color,sampler2D image,sampler2D detail,vec4 bounds,vec4 window,vec2 look, + vec3 ray,float sceneRange,bool onSurface) { + if(look.x<=0.0) return color; + vec3 origin=uWorld[3].xyz; + float range=sceneRange; + if(uRaised) { + vec3 radii=vec3(6378137.0,6378137.0,6356752.314245)+look.y; + vec3 o=origin/radii,d=ray/radii; + float a=dot(d,d),b=dot(o,d),c=dot(o,o)-1.0; + float disc=b*b-a*c; + if(disc<0.0) return color; + float root=sqrt(disc); + range=(-b-root)/a; + if(range<=0.0) range=(-b+root)/a; + if(range<=0.0 || range>sceneRange+1.0) return color; + } else if(!onSurface) return color; + vec2 uv=(geographic(origin+range*ray)-bounds.xy)/(bounds.zw-bounds.xy); + if(any(lessThan(uv,vec2(0.0)))||any(greaterThan(uv,vec2(1.0)))) return color; + vec2 delta=window.zw-window.xy; + vec2 duv=(uv-window.xy)/max(delta,vec2(1e-6)); + bool useDetail=all(greaterThan(delta,vec2(0.0)))&&all(greaterThanEqual(duv,vec2(0.0)))&&all(lessThanEqual(duv,vec2(1.0))); + vec4 sampleColor=useDetail ? texture2D(detail,duv) : texture2D(image,uv); + return mix(color,sampleColor.rgb,look.x*sampleColor.a); +} +void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { + outputColor=inputColor; + float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv)); + bool onSurface=depth<1.0 && depth>=texture2D(tSurfaceDepth,uv).r-2e-7; + if(!uRaised && !onSurface) return; + vec3 v=(uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0)).xyz; + vec3 ray=normalize(mat3(uWorld)*v); + float range=depth>=1.0 ? 1.0e12 : (exp2(2.0*depth/uLogDepthFC)-1.0)*length(v)/(-v.z); + vec3 rgb=toDisplay(inputColor.rgb); + ${products.map((p) => `rgb=paint(rgb,t${p},tDetail${p},uBounds${p},uWindow${p},uLook${p},ray,range,onSurface);`).join('\n')} + outputColor=vec4(toLinear(rgb),inputColor.a); +} +`; + +function weatherPass(engine, { view, EffectDesc, NavaraEffect }) { + let shared = owners.get(view); + if (shared) return shared; + const surfaces = new Set(); + let handle = null, + empty = null; + class WeatherEffect extends EffectDesc { + static key = KEY; + static insertAfter = ['toneMapping', 'gevRecentImagery']; + static insertBefore = [ + 'gevEarthquakeGround', + 'gevFirmsGround', + 'gevPreSharpenOverlay', + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevOverlay', + 'final', + ]; + createPass() { + this.effect = new Effect('GevObservedWeather', shader, { + attributes: EffectAttribute.CONVOLUTION, + uniforms: new Map([ + ...groundSurfaceUniforms(), + ['uWorld', new Uniform(new Matrix4())], + ['uRaised', new Uniform(false)], + ...products.flatMap((p) => [ + ['t' + p, new Uniform(empty)], + ['tDetail' + p, new Uniform(empty)], + ['uBounds' + p, new Uniform(emptyRect)], + ['uWindow' + p, new Uniform(emptyRect)], + ['uLook' + p, new Uniform([0, 0])], + ]), + ]), + }); + return new NavaraEffect(this.view.camera.raw, this.effect); + } + update() { + if (!this.effect) return; + const camera = this.view.camera.raw, + u = this.effect.uniforms; + updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); + u.get('uWorld').value.copy(camera.matrixWorld); + u.get('uRaised').value = engine.maps.photorealActive(); + const lift = shellLift(engine.camera.pose().cartographic.height); + for (const frame of surfaces) frame.upload(this.ctx.getRenderer()); + for (const p of products) { + let active = null; + for (const f of surfaces) + if (f.slot === p && f.visible && f.alpha > 0 && f.image) active = f; + u.get('t' + p).value = active?.image ?? empty; + u.get('tDetail' + p).value = active?.detail ?? empty; + u.get('uBounds' + p).value = active?.bounds ?? emptyRect; + u.get('uWindow' + p).value = active?.window ?? emptyRect; + u.get('uLook' + p).value = active + ? [active.alpha, active.height + lift] + : [0, 0]; + } + } + onDestroy() { + this.effect?.dispose(); + super.onDestroy(); + } + } + view.registerEffect(KEY, WeatherEffect); + shared = { + surfaces, + sync() { + if (surfaces.size && !handle) { + empty = new DataTexture(new Uint8Array(4), 1, 1); + empty.needsUpdate = true; + handle = view.addEffect({ [KEY]: {}, visible: true }); + } else if (!surfaces.size && handle) { + handle.delete(); + handle = null; + empty.dispose(); + empty = null; + } + engine.render.request(KEY); + }, + }; + owners.set(view, shared); + return shared; +} + +function imageTexture(canvas) { + const texture = new CanvasTexture(canvas); + texture.minFilter = texture.magFilter = LinearFilter; + texture.generateMipmaps = false; + return texture; +} + +/** Own one weather frame. Upload staging images before replacing the last drawn frame. */ +export function createWeatherSurface( + engine, + options, + product, + { rectangle, height, onSettled }, +) { + const shared = weatherPass(engine, options); + let pending = false, + ready = false; + const frame = { + slot: slotFor(product), + bounds: rectArray(rectangle), + height, + alpha: 0, + visible: true, + image: null, + detail: null, + window: null, + upload(renderer) { + if (!pending) return; + if (frame.image) renderer.initTexture(frame.image); + if (frame.detail) renderer.initTexture(frame.detail); + pending = false; + ready = true; + // Completing a frame can dispose its predecessor. Finish this pass first. + queueMicrotask(() => { + if (shared.surfaces.has(frame)) onSettled(); + }); + }, + }; + shared.surfaces.add(frame); + shared.sync(); + return { + setImage(canvas) { + if (frame.image?.image === canvas) return; + frame.image?.dispose(); + frame.image = imageTexture(canvas); + ready = false; + pending = true; + shared.sync(); + }, + setDetail(canvas, rect) { + if (frame.detail?.image !== canvas) { + frame.detail?.dispose(); + frame.detail = canvas ? imageTexture(canvas) : null; + pending = true; + } + frame.window = rect ? rectArray(rect) : null; + shared.sync(); + }, + setAlpha(value) { + frame.alpha = value; + shared.sync(); + }, + setShow(value) { + if (frame.visible === value) return false; + frame.visible = value; + shared.sync(); + return true; + }, + getDiagnostics() { + const [west, south, east, north] = frame.window ?? []; + return { + height, + ready, + uploaded: ready, + show: frame.visible, + alpha: frame.alpha, + rendering: pending, + detail: { + uploaded: Boolean(frame.detail && !pending), + window: frame.window && { west, south, east, north }, + }, + }; + }, + destroy() { + if (!shared.surfaces.delete(frame)) return; + frame.image?.dispose(); + frame.detail?.dispose(); + shared.sync(); + }, + }; +} diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js index 9def82c43..924637cdc 100644 --- a/src/layers/weather/shellRendering.js +++ b/src/layers/weather/shellRendering.js @@ -405,10 +405,13 @@ export function createWeatherShell({ decodeImage, createCanvas = () => document.createElement('canvas'), cacheBytes = WEATHER_SHELL_CACHE_BYTES, + createSurface = createShellSurface, + requestRender = () => viewer.scene.requestRender(), + viewRectangle = () => viewer.camera?.computeViewRectangle?.(viewer.scene.globe?.ellipsoid), + moveEnd = viewer?.camera?.moveEnd, }) { if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product)) throw new TypeError('Unknown weather product'); - const scene = viewer.scene; const size = fitTexture(cesium, WEATHER_IMAGE_SIZES[product]); const detailSize = fitTexture(cesium, WEATHER_DETAIL_SIZE); const infrared = product === 'clouds' || product === 'clouds-regional'; @@ -524,14 +527,14 @@ export function createWeatherShell({ detail = { box: job.box, key: job.key, image, stale: false }; syncDetail(); evict(); - scene.requestRender(); + requestRender(); } function failDetail(job) { if (detailJob !== job) return; clearTimeout(job.timeout); detailJob = null; if (detail?.stale) dropDetail(); - scene.requestRender(); + requestRender(); } /** Bring the detail to the shown frame and window. The previous detail image * stays until the next is decoded unless the window itself moved, and over at @@ -570,7 +573,7 @@ export function createWeatherShell({ /** Recompute the window from the camera's ground footprint; returns whether it changed. */ function computeView() { const rectangle = detailed - ? viewer.camera?.computeViewRectangle?.(scene.globe?.ellipsoid) + ? viewRectangle() : null; const degrees = cesium.Math.toDegrees; const footprint = rectangle && { @@ -592,7 +595,7 @@ export function createWeatherShell({ function watchCamera() { if (offCamera || !detailed) return; offCamera = - viewer.camera?.moveEnd?.addEventListener(() => { + moveEnd?.addEventListener(() => { if (current && computeView()) refreshDetail(); }) ?? null; } @@ -607,7 +610,7 @@ export function createWeatherShell({ frame.owned = true; const abort = () => { if (incoming === frame) cancelIncoming(); - scene.requestRender(); + requestRender(); }; signal.addEventListener('abort', abort, { once: true }); frame.offAbort = () => signal.removeEventListener('abort', abort); @@ -633,7 +636,7 @@ export function createWeatherShell({ close(frame); lastError = msg('Weather tiles unavailable · previous frame retained'); frame.resolve(false); - scene.requestRender(); + requestRender(); onChange(); } function commit(frame) { @@ -668,7 +671,7 @@ export function createWeatherShell({ if (detail?.stale && detail.key !== wantedDetail()) dropDetail(); refreshDetail(); frame.resolve(true); - scene.requestRender(); + requestRender(); onChange(); } function install(frame, image) { @@ -682,7 +685,7 @@ export function createWeatherShell({ // First frame or a new extent: stage an invisible surface and keep the // previous one until the new geometry and texture are drawable. const { west, south, east, north } = frame.snapshot.bounds; - const next = createShellSurface({ + const next = createSurface({ viewer, cesium, height, @@ -708,7 +711,7 @@ export function createWeatherShell({ computeView(); refreshDetail(); } - if (changed) scene.requestRender(); + if (changed) requestRender(); return changed; }, cancelPrefetch, @@ -805,7 +808,7 @@ export function createWeatherShell({ install(frame, texture); }) .catch(() => fail(frame)); - scene.requestRender(); + requestRender(); onChange(); return frame.result; }, @@ -818,12 +821,12 @@ export function createWeatherShell({ } applyVisibility(); if (!hidden) refreshDetail(); - scene.requestRender(); + requestRender(); }, setAlpha(value) { alpha = value; surface?.setAlpha(alpha); - scene.requestRender(); + requestRender(); }, clear() { cancelPrefetch(); @@ -841,7 +844,7 @@ export function createWeatherShell({ lastError = null; images.clear(); imageBytes = 0; - scene.requestRender(); + requestRender(); }, getDiagnostics() { const drawn = surface?.getDiagnostics();