mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Draw and inspect the same wind forecast when switching globes
This commit is contained in:
1 parent
d7b3d45135
commit
2b4955c817
11 files changed
+555
-30
No files matched your search
@@ -3677,6 +3677,8 @@
|
|||||||
"src/layers/infrastructure/render/navaraStems.js",
|
"src/layers/infrastructure/render/navaraStems.js",
|
||||||
"src/layers/weather/render/navara.js",
|
"src/layers/weather/render/navara.js",
|
||||||
"src/layers/weather/render/navaraSurface.js",
|
"src/layers/weather/render/navaraSurface.js",
|
||||||
|
"src/layers/wind/render/navara.js",
|
||||||
|
"src/layers/wind/render/flowMesh.js",
|
||||||
"src/layers/ghostMaps/render/navara.js",
|
"src/layers/ghostMaps/render/navara.js",
|
||||||
"src/layers/shared/render/navaraGroundPicks.js",
|
"src/layers/shared/render/navaraGroundPicks.js",
|
||||||
"src/layers/cyclones/render/navara.js",
|
"src/layers/cyclones/render/navara.js",
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
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=2000000&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 reads = [];
|
||||||
|
await p.setRequestInterception(true);
|
||||||
|
p.on('request', (req) => {
|
||||||
|
const u = new URL(req.url());
|
||||||
|
if (!u.pathname.startsWith('/api/wind/')) return req.continue();
|
||||||
|
reads.push(u.pathname + u.search);
|
||||||
|
const model = u.searchParams.get('model'),
|
||||||
|
kind = u.searchParams.get('overlay');
|
||||||
|
const count = 36 * 19;
|
||||||
|
if (u.pathname.endsWith('/manifest'))
|
||||||
|
return req.respond({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
overlay: kind ?? 'none',
|
||||||
|
grid: { nx: 36, ny: 19, lo1: 0, la1: 90, dx: 10, dy: 10 },
|
||||||
|
gridUrl: '/api/wind/grid/' + model + '-qa.bin' + u.search,
|
||||||
|
cycle: {
|
||||||
|
validIso: '2026-11-02T12:00:00Z',
|
||||||
|
runIso: '2026-11-02T06:00:00Z',
|
||||||
|
},
|
||||||
|
...(kind
|
||||||
|
? {
|
||||||
|
scalar: {
|
||||||
|
kind,
|
||||||
|
units: kind === 'temperature' ? '°C' : 'hPa',
|
||||||
|
level:
|
||||||
|
kind === 'temperature'
|
||||||
|
? '2 m above ground'
|
||||||
|
: 'mean sea level',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const values = new Float32Array(count * (kind ? 3 : 2));
|
||||||
|
values.fill(8, 0, count);
|
||||||
|
values.fill(6, count, count * 2);
|
||||||
|
if (kind) values.fill(kind === 'temperature' ? 40 : 1020, count * 2);
|
||||||
|
return req.respond({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
body: Buffer.from(values.buffer),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
p.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text())) {
|
||||||
|
errors.push(msg.text());
|
||||||
|
console.log('GPU', msg.text().slice(0, 2500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { dataManager, tileset } = window.__godsEyeView;
|
||||||
|
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||||
|
throw Error('Keyless preview required');
|
||||||
|
const mod = dataManager.layers.get('wind').module;
|
||||||
|
window.qaWind = mod;
|
||||||
|
mod.setParams({ overlay: 'temperature', paused: true, units: 'm/s' });
|
||||||
|
await dataManager.setEnabled('wind', true, { origin: 'user' });
|
||||||
|
});
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.qaWind.getDiagnostics().surface?.ready &&
|
||||||
|
window.qaWind.getDiagnostics().gpu?.ready,
|
||||||
|
);
|
||||||
|
await p.evaluate(() => window.qaWind.setParams({ inspect: true }));
|
||||||
|
const baseline = reads.length;
|
||||||
|
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 p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.qaWind.getDiagnostics().gpu?.ready &&
|
||||||
|
window.qaWind.getRowControls().summary.reading?.speed === 10,
|
||||||
|
{ timeout: 90000 },
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
const check = await p.evaluate(() => {
|
||||||
|
const { dataManager } = window.__godsEyeView;
|
||||||
|
const mod = dataManager.layers.get('wind').module;
|
||||||
|
return {
|
||||||
|
same: mod === window.qaWind,
|
||||||
|
params: mod.getParams(),
|
||||||
|
gpu: mod.getDiagnostics().gpu.pathCount,
|
||||||
|
reading: mod.getRowControls().summary.reading,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(check.same, true);
|
||||||
|
assert.equal(check.params.paused, true);
|
||||||
|
assert.ok(check.gpu > 1000);
|
||||||
|
assert.equal(check.reading.scalarValue, '40.0 °C');
|
||||||
|
assert.equal(check.reading.from, 'SW');
|
||||||
|
const sample = await sharp(await p.screenshot())
|
||||||
|
.extract({ left: 530, top: 390, width: 24, height: 24 })
|
||||||
|
.removeAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer();
|
||||||
|
const [r, g, b] = [0, 1, 2].map((k) => {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = k; i < sample.length; i += 3) sum += sample[i];
|
||||||
|
return sum / (sample.length / 3);
|
||||||
|
});
|
||||||
|
console.log(kind, check.gpu, check.reading.wind, [r, g, b]);
|
||||||
|
assert.ok(
|
||||||
|
r > g * 1.35 && r > b * 1.35,
|
||||||
|
kind + ' temperature field visible',
|
||||||
|
);
|
||||||
|
assert.equal(reads.length, baseline);
|
||||||
|
}
|
||||||
|
await p.evaluate(() => window.qaWind.setParams({ paused: false }));
|
||||||
|
await p.waitForFunction(() => window.qaWind.getDiagnostics().gpu.phaseSeconds > 0.2);
|
||||||
|
await p.evaluate(() => window.qaWind.setParams({ paused: true }));
|
||||||
|
const frozen = await p.evaluate(() => window.qaWind.getDiagnostics().gpu.phaseSeconds);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
assert.equal(await p.evaluate(() => window.qaWind.getDiagnostics().gpu.phaseSeconds), frozen);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'PASS retained forecast grid, pause, temperature field and sampled wind on both engines',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await b.close();
|
||||||
|
}
|
||||||
@@ -279,7 +279,11 @@ export function createApplicationCatalog({
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'wind',
|
'wind',
|
||||||
() => createWindLayer({ feed: sources.wind, clock: weatherClock }),
|
() => sessionModel('wind', () => createWindLayer({
|
||||||
|
feed: sources.wind,
|
||||||
|
clock: weatherClock,
|
||||||
|
createNativeRendering: (engine) => rendererFor('wind', engine),
|
||||||
|
})),
|
||||||
],
|
],
|
||||||
...['weather-radar', 'weather-satellite', 'weather-lightning'].map(
|
...['weather-radar', 'weather-satellite', 'weather-lightning'].map(
|
||||||
(id) => [
|
(id) => [
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'ais-live-vessels',
|
'ais-live-vessels',
|
||||||
'military-installations',
|
'military-installations',
|
||||||
'military-awareness',
|
'military-awareness',
|
||||||
|
'wind',
|
||||||
'weather-radar',
|
'weather-radar',
|
||||||
'weather-satellite',
|
'weather-satellite',
|
||||||
'weather-lightning',
|
'weather-lightning',
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ const NAVARA_CREDIT_KEYS = new Set([
|
|||||||
'eccc-aqhi',
|
'eccc-aqhi',
|
||||||
'wfigs',
|
'wfigs',
|
||||||
's2-ghostmaps',
|
's2-ghostmaps',
|
||||||
|
'wind-gfs',
|
||||||
|
'ecmwf-ifs',
|
||||||
'weather-noaa',
|
'weather-noaa',
|
||||||
'weather-lightning',
|
'weather-lightning',
|
||||||
'weather-cyclones',
|
'weather-cyclones',
|
||||||
@@ -117,6 +119,10 @@ export async function registerNavaraLayerRenderers() {
|
|||||||
return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options);
|
return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options);
|
||||||
},
|
},
|
||||||
])),
|
])),
|
||||||
|
wind: async () => {
|
||||||
|
const { createWindNavaraRenderer } = await import('../layers/wind/render/navara.js');
|
||||||
|
return (engine) => createWindNavaraRenderer(engine, navaraViews.get(engine));
|
||||||
|
},
|
||||||
'weather-cyclones': async () => {
|
'weather-cyclones': async () => {
|
||||||
const {createCyclonesNavaraRenderer} = await import('../layers/cyclones/render/navara.js');
|
const {createCyclonesNavaraRenderer} = await import('../layers/cyclones/render/navara.js');
|
||||||
return (engine,options) => createCyclonesNavaraRenderer(engine,navaraViews.get(engine).view,options);
|
return (engine,options) => createCyclonesNavaraRenderer(engine,navaraViews.get(engine).view,options);
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import { shellLift } from '../shellRendering.js';
|
|||||||
|
|
||||||
const KEY = 'gevObservedWeather';
|
const KEY = 'gevObservedWeather';
|
||||||
const owners = new WeakMap();
|
const owners = new WeakMap();
|
||||||
const products = ['Satellite', 'Radar', 'Lightning'];
|
const products = ['Wind', 'Satellite', 'Radar', 'Lightning'];
|
||||||
const slotFor = (p) =>
|
const slotFor = (p) =>
|
||||||
p === 'radar' ? 'Radar' : p === 'lightning' ? 'Lightning' : 'Satellite';
|
p === 'wind' ? 'Wind' : p === 'radar' ? 'Radar' : p === 'lightning' ? 'Lightning' : 'Satellite';
|
||||||
const emptyRect = [0, 0, -1, -1];
|
const emptyRect = [0, 0, -1, -1];
|
||||||
const rectArray = (r) => (r ? [r.west, r.south, r.east, r.north] : emptyRect);
|
const rectArray = (r) => (r ? [r.west, r.south, r.east, r.north] : emptyRect);
|
||||||
|
|
||||||
|
|||||||
+31
-14
@@ -57,10 +57,12 @@ export function createWindLayer({
|
|||||||
cesium = Cesium,
|
cesium = Cesium,
|
||||||
container,
|
container,
|
||||||
createRendering = createWindRendering,
|
createRendering = createWindRendering,
|
||||||
|
createNativeRendering,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (typeof feed?.getSnapshot !== 'function')
|
if (typeof feed?.getSnapshot !== 'function')
|
||||||
throw new TypeError('Wind requires a snapshot source');
|
throw new TypeError('Wind requires a snapshot source');
|
||||||
let viewer = null;
|
let viewer = null;
|
||||||
|
let engine = null;
|
||||||
let request = null;
|
let request = null;
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
let rendering = null;
|
let rendering = null;
|
||||||
@@ -81,6 +83,10 @@ export function createWindLayer({
|
|||||||
inspectionMarker?.clear();
|
inspectionMarker?.clear();
|
||||||
};
|
};
|
||||||
const sample = (position) => {
|
const sample = (position) => {
|
||||||
|
if (!position && engine && !viewer) {
|
||||||
|
const canvas = engine.host.canvas;
|
||||||
|
position = engine.camera.pickEllipsoid({ x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 });
|
||||||
|
}
|
||||||
reading = inspectWindAtCenter(manifest, viewer, cesium, {
|
reading = inspectWindAtCenter(manifest, viewer, cesium, {
|
||||||
position,
|
position,
|
||||||
units,
|
units,
|
||||||
@@ -110,9 +116,13 @@ export function createWindLayer({
|
|||||||
icon: '🌬',
|
icon: '🌬',
|
||||||
source: 'GFS / ECMWF IFS · FORECAST',
|
source: 'GFS / ECMWF IFS · FORECAST',
|
||||||
updateInterval: 3600_000,
|
updateInterval: 3600_000,
|
||||||
init(nextViewer) {
|
init(nextViewer, options) {
|
||||||
|
layer.attachRenderer(nextViewer, options);
|
||||||
|
},
|
||||||
|
attachRenderer(nextViewer, { engine: nextEngine = null } = {}) {
|
||||||
viewer = nextViewer;
|
viewer = nextViewer;
|
||||||
rendering = createRendering({
|
engine = nextEngine;
|
||||||
|
rendering = engine?.kind === 'navara' ? createNativeRendering(engine) : createRendering({
|
||||||
cesium,
|
cesium,
|
||||||
container: container ?? nextViewer.container,
|
container: container ?? nextViewer.container,
|
||||||
getViewer: () => viewer,
|
getViewer: () => viewer,
|
||||||
@@ -125,12 +135,24 @@ export function createWindLayer({
|
|||||||
});
|
});
|
||||||
rendering.attach();
|
rendering.attach();
|
||||||
rendering.setOptions?.({ overlay, paused });
|
rendering.setOptions?.({ overlay, paused });
|
||||||
const target = container ?? nextViewer.container;
|
const target = container ?? nextViewer?.container ?? engine?.host.container;
|
||||||
inspectionMarker = createWindInspectionMarker({
|
inspectionMarker = createWindInspectionMarker({
|
||||||
container: target,
|
container: target,
|
||||||
viewer,
|
viewer,
|
||||||
cesium,
|
cesium,
|
||||||
|
engine,
|
||||||
});
|
});
|
||||||
|
if (enabled && manifest && !manifest.unavailable) {
|
||||||
|
rendering.setField(manifest);
|
||||||
|
rendering.start();
|
||||||
|
}
|
||||||
|
if (sampledPosition) inspectionMarker.show(sampledPosition);
|
||||||
|
},
|
||||||
|
detachRenderer() {
|
||||||
|
inspectionMarker?.destroy();
|
||||||
|
inspectionMarker = null;
|
||||||
|
rendering?.destroy();
|
||||||
|
rendering = imageryHost = viewer = engine = null;
|
||||||
},
|
},
|
||||||
attachShellServices(services) {
|
attachShellServices(services) {
|
||||||
imageryHost =
|
imageryHost =
|
||||||
@@ -182,12 +204,12 @@ export function createWindLayer({
|
|||||||
error = null;
|
error = null;
|
||||||
if (sampledPosition) sample(sampledPosition);
|
if (sampledPosition) sample(sampledPosition);
|
||||||
if (snapshot.unavailable) {
|
if (snapshot.unavailable) {
|
||||||
rendering.stop();
|
rendering?.stop();
|
||||||
rendering.clear();
|
rendering?.clear();
|
||||||
} else {
|
} else {
|
||||||
rendering.setOptions?.({ overlay, paused });
|
rendering?.setOptions?.({ overlay, paused });
|
||||||
rendering.setField(snapshot);
|
rendering?.setField(snapshot);
|
||||||
rendering.start();
|
rendering?.start();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
@@ -438,13 +460,8 @@ export function createWindLayer({
|
|||||||
destroy() {
|
destroy() {
|
||||||
layer.disable();
|
layer.disable();
|
||||||
unsubscribeClock?.();
|
unsubscribeClock?.();
|
||||||
rendering?.destroy();
|
layer.detachRenderer();
|
||||||
rendering = null;
|
|
||||||
imageryHost = null;
|
|
||||||
viewer = null;
|
|
||||||
rowControlsListener = null;
|
rowControlsListener = null;
|
||||||
inspectionMarker?.destroy();
|
|
||||||
inspectionMarker = null;
|
|
||||||
},
|
},
|
||||||
getStats() {
|
getStats() {
|
||||||
const diagnostic = rendering?.getDiagnostics?.();
|
const diagnostic = rendering?.getDiagnostics?.();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { applicationPinia } from '../../ui/vueRuntime.js';
|
|||||||
export function createWindInspectionMarker({
|
export function createWindInspectionMarker({
|
||||||
container,
|
container,
|
||||||
viewer,
|
viewer,
|
||||||
|
engine,
|
||||||
cesium,
|
cesium,
|
||||||
store = useWindInspectionStore(applicationPinia()),
|
store = useWindInspectionStore(applicationPinia()),
|
||||||
}) {
|
}) {
|
||||||
@@ -33,36 +34,40 @@ export function createWindInspectionMarker({
|
|||||||
show(position) {
|
show(position) {
|
||||||
clear();
|
clear();
|
||||||
const scene = viewer?.scene;
|
const scene = viewer?.scene;
|
||||||
|
const canvas = scene?.canvas ?? engine?.host.canvas;
|
||||||
|
const frames = scene?.postRender ?? engine?.frames.postRender;
|
||||||
if (
|
if (
|
||||||
!position ||
|
!position ||
|
||||||
!container?.ownerDocument ||
|
!container?.ownerDocument ||
|
||||||
!scene?.postRender?.addEventListener ||
|
!frames?.addEventListener ||
|
||||||
!scene.cartesianToCanvasCoordinates
|
(!engine?.projector && !scene?.cartesianToCanvasCoordinates)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
present({ owner, container, visible: false, left: 0, top: 0 });
|
present({ owner, container, visible: false, left: 0, top: 0 });
|
||||||
const occluder = new cesium.EllipsoidalOccluder(
|
const occluder = !engine && new cesium.EllipsoidalOccluder(
|
||||||
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
|
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
|
||||||
viewer.camera.positionWC,
|
viewer.camera.positionWC,
|
||||||
);
|
);
|
||||||
const render = () => {
|
const render = () => {
|
||||||
if (!shown()) return;
|
if (!shown()) return;
|
||||||
occluder.cameraPosition = viewer.camera.positionWC;
|
if (occluder) occluder.cameraPosition = viewer.camera.positionWC;
|
||||||
const point = scene.cartesianToCanvasCoordinates(position);
|
const point = engine
|
||||||
|
? engine.projector.worldToWindow(position, {})
|
||||||
|
: scene.cartesianToCanvasCoordinates(position);
|
||||||
const visible =
|
const visible =
|
||||||
point &&
|
point &&
|
||||||
point.x >= 0 &&
|
point.x >= 0 &&
|
||||||
point.y >= 0 &&
|
point.y >= 0 &&
|
||||||
point.x <= scene.canvas.clientWidth &&
|
point.x <= canvas.clientWidth &&
|
||||||
point.y <= scene.canvas.clientHeight &&
|
point.y <= canvas.clientHeight &&
|
||||||
(scene.mode !== cesium.SceneMode.SCENE3D ||
|
(engine ? engine.projector.horizonVisible(position) :
|
||||||
occluder.isPointVisible(position));
|
scene.mode !== cesium.SceneMode.SCENE3D || occluder.isPointVisible(position));
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
// A hidden marker keeps its last place, as the element did.
|
// A hidden marker keeps its last place, as the element did.
|
||||||
if (marker.visible) present({ visible: false });
|
if (marker.visible) present({ visible: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const canvasRect = scene.canvas.getBoundingClientRect();
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const left = point.x + canvasRect.left - containerRect.left;
|
const left = point.x + canvasRect.left - containerRect.left;
|
||||||
const top = point.y + canvasRect.top - containerRect.top;
|
const top = point.y + canvasRect.top - containerRect.top;
|
||||||
@@ -70,9 +75,10 @@ export function createWindInspectionMarker({
|
|||||||
return;
|
return;
|
||||||
present({ visible: true, left, top });
|
present({ visible: true, left, top });
|
||||||
};
|
};
|
||||||
removeRender = scene.postRender.addEventListener(render);
|
removeRender = frames.addEventListener(render);
|
||||||
render();
|
render();
|
||||||
scene.requestRender?.();
|
if (engine) engine.render.request('wind-inspection');
|
||||||
|
else scene.requestRender?.();
|
||||||
},
|
},
|
||||||
clear,
|
clear,
|
||||||
destroy: clear,
|
destroy: clear,
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
Mesh,
|
||||||
|
ShaderMaterial,
|
||||||
|
InstancedBufferGeometry,
|
||||||
|
InstancedBufferAttribute,
|
||||||
|
Float32BufferAttribute,
|
||||||
|
Vector2,
|
||||||
|
DoubleSide,
|
||||||
|
} from 'three';
|
||||||
|
import { fromDegrees } from '../../../geo/cartesian3.js';
|
||||||
|
import { WIND_DISPLAY_HEIGHT_METERS } from '../streamlines.js';
|
||||||
|
|
||||||
|
const KEY = 'gevWindFlow';
|
||||||
|
const registered = new WeakSet();
|
||||||
|
const vertexShader = /* glsl */ `
|
||||||
|
#include <common>
|
||||||
|
#include <logdepthbuf_pars_vertex>
|
||||||
|
attribute vec3 begin, end;
|
||||||
|
attribute vec3 progress;
|
||||||
|
uniform vec2 viewport;
|
||||||
|
varying vec4 flow;
|
||||||
|
void main() {
|
||||||
|
vec3 world = mix(begin,end,position.x);
|
||||||
|
vec4 a = projectionMatrix * viewMatrix * vec4(begin,1.0);
|
||||||
|
vec4 b = projectionMatrix * viewMatrix * vec4(end,1.0);
|
||||||
|
vec2 delta = (b.xy/max(1.0,b.w)-a.xy/max(1.0,a.w))*viewport;
|
||||||
|
vec2 perp = vec2(-delta.y,delta.x)/max(0.000001,length(delta));
|
||||||
|
gl_Position = mix(a,b,position.x);
|
||||||
|
gl_Position.xy += perp * position.y * 2.4 * gl_Position.w/viewport;
|
||||||
|
flow = vec4(mix(progress.x,progress.y,position.x),progress.z,position.y,
|
||||||
|
dot(normalize(world),normalize(cameraPosition-world)));
|
||||||
|
#include <logdepthbuf_vertex>
|
||||||
|
}`;
|
||||||
|
const fragmentShader = /* glsl */ `
|
||||||
|
#include <common>
|
||||||
|
#include <logdepthbuf_pars_fragment>
|
||||||
|
uniform float seconds,heightFade,ghost;
|
||||||
|
varying vec4 flow;
|
||||||
|
void main() {
|
||||||
|
#include <logdepthbuf_fragment>
|
||||||
|
float behind=fract(seconds*0.16+flow.y-flow.x);
|
||||||
|
float tail=1.0-smoothstep(0.0,0.32,behind), tip=1.0-smoothstep(0.0,0.045,behind);
|
||||||
|
float ends=smoothstep(0.0,0.055,flow.x)*(1.0-smoothstep(0.945,1.0,flow.x));
|
||||||
|
float edge=1.0-smoothstep(0.52,1.0,abs(flow.z));
|
||||||
|
float alpha=(ghost+0.64*tail+0.16*tip)*ends*edge*smoothstep(0.0,0.065,flow.w)*heightFade;
|
||||||
|
if(alpha<0.001) discard;
|
||||||
|
gl_FragColor=vec4(mix(vec3(0.50,0.79,0.90),vec3(0.91,0.99,1.0),tip),alpha);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
export function createWindFlowMesh({ view, MeshDesc }, paths) {
|
||||||
|
if (!registered.has(view)) {
|
||||||
|
view.registerMesh(
|
||||||
|
KEY,
|
||||||
|
class extends MeshDesc {
|
||||||
|
constructor(host, ctx, config) {
|
||||||
|
super(host, ctx, config);
|
||||||
|
this.flow = config[KEY];
|
||||||
|
}
|
||||||
|
getPassKey() {
|
||||||
|
return 'transparent';
|
||||||
|
}
|
||||||
|
createMesh() {
|
||||||
|
return this.flow;
|
||||||
|
}
|
||||||
|
onDestroy() {
|
||||||
|
this.flow.geometry.dispose();
|
||||||
|
this.flow.material.dispose();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
registered.add(view);
|
||||||
|
}
|
||||||
|
const segments = paths.reduce((sum, p) => sum + p.coordinates.length - 1, 0);
|
||||||
|
const begin = new Float32Array(segments * 3),
|
||||||
|
end = new Float32Array(segments * 3),
|
||||||
|
progress = new Float32Array(segments * 3);
|
||||||
|
let i = 0;
|
||||||
|
for (const path of paths) {
|
||||||
|
let previous = null;
|
||||||
|
const n = path.coordinates.length - 1;
|
||||||
|
for (let j = 0; j <= n; j++) {
|
||||||
|
const [lon, lat] = path.coordinates[j];
|
||||||
|
const next = fromDegrees(lon, lat, WIND_DISPLAY_HEIGHT_METERS);
|
||||||
|
if (previous) {
|
||||||
|
begin.set([previous.x, previous.y, previous.z], i);
|
||||||
|
end.set([next.x, next.y, next.z], i);
|
||||||
|
progress.set([(j - 1) / n, j / n, path.phase], i);
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
previous = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const geometry = new InstancedBufferGeometry();
|
||||||
|
geometry.setAttribute(
|
||||||
|
'position',
|
||||||
|
new Float32BufferAttribute([0, -1, 0, 0, 1, 0, 1, -1, 0, 1, 1, 0], 3),
|
||||||
|
);
|
||||||
|
geometry.setIndex([0, 1, 2, 2, 1, 3]);
|
||||||
|
for (const [key, array] of Object.entries({ begin, end, progress }))
|
||||||
|
geometry.setAttribute(key, new InstancedBufferAttribute(array, 3));
|
||||||
|
geometry.instanceCount = segments;
|
||||||
|
const uniforms = {
|
||||||
|
seconds: { value: 0 },
|
||||||
|
heightFade: { value: 0 },
|
||||||
|
ghost: { value: 0.25 },
|
||||||
|
viewport: { value: new Vector2(1, 1) },
|
||||||
|
};
|
||||||
|
const material = new ShaderMaterial({
|
||||||
|
uniforms,
|
||||||
|
vertexShader,
|
||||||
|
fragmentShader,
|
||||||
|
transparent: true,
|
||||||
|
side: DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
toneMapped: false,
|
||||||
|
});
|
||||||
|
material.forceSinglePass = true;
|
||||||
|
const mesh = new Mesh(geometry, material);
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
const holder = view.addMesh({ [KEY]: mesh });
|
||||||
|
return {
|
||||||
|
update({ time, fade, still, width, height }) {
|
||||||
|
mesh.visible = fade > 0;
|
||||||
|
uniforms.seconds.value = time;
|
||||||
|
uniforms.heightFade.value = fade;
|
||||||
|
uniforms.ghost.value = still ? 0.34 : 0.25;
|
||||||
|
uniforms.viewport.value.set(Math.max(1, width), Math.max(1, height));
|
||||||
|
},
|
||||||
|
destroy: () => holder.delete(),
|
||||||
|
segments,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import {
|
||||||
|
bakeWindStreamlines,
|
||||||
|
WIND_NARROW_PATH_LIMIT,
|
||||||
|
WIND_PATH_LIMIT,
|
||||||
|
} from '../streamlines.js';
|
||||||
|
import { createFieldRaster } from '../fields.js';
|
||||||
|
import { fieldUnavailableText, sameWind } from '../rendering.js';
|
||||||
|
import { createWeatherSurface } from '../../weather/render/navaraSurface.js';
|
||||||
|
import { WEATHER_SHELL_HEIGHTS } from '../../weather/shellRendering.js';
|
||||||
|
import { createWindFlowMesh } from './flowMesh.js';
|
||||||
|
|
||||||
|
const world = {
|
||||||
|
west: -Math.PI,
|
||||||
|
south: -Math.PI / 2,
|
||||||
|
east: Math.PI,
|
||||||
|
north: Math.PI / 2,
|
||||||
|
};
|
||||||
|
const clamp = (n) => Math.max(0, Math.min(1, n));
|
||||||
|
export function createWindNavaraRenderer(engine, native) {
|
||||||
|
const canvas = engine.host.canvas,
|
||||||
|
media = matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
let snapshot,
|
||||||
|
field,
|
||||||
|
mesh,
|
||||||
|
surface,
|
||||||
|
removeFrame,
|
||||||
|
lastAlpha,
|
||||||
|
lastTime,
|
||||||
|
seconds = 0,
|
||||||
|
paths = 0,
|
||||||
|
fade = 0;
|
||||||
|
let running = false,
|
||||||
|
paused = false,
|
||||||
|
overlay = 'none',
|
||||||
|
error = null,
|
||||||
|
budget = 0;
|
||||||
|
const wake = () => engine.render.request('wind');
|
||||||
|
const build = () => {
|
||||||
|
mesh?.destroy();
|
||||||
|
mesh = null;
|
||||||
|
budget =
|
||||||
|
canvas.clientWidth < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT;
|
||||||
|
const baked = bakeWindStreamlines(field, { count: budget });
|
||||||
|
paths = baked.length;
|
||||||
|
if (paths) mesh = createWindFlowMesh(native, baked);
|
||||||
|
};
|
||||||
|
const paint = () => {
|
||||||
|
surface?.destroy();
|
||||||
|
surface = null;
|
||||||
|
lastAlpha = null;
|
||||||
|
error = null;
|
||||||
|
if (!snapshot || overlay === 'none') return;
|
||||||
|
const raster = createFieldRaster(snapshot, overlay);
|
||||||
|
if (!raster) {
|
||||||
|
error = fieldUnavailableText(overlay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const texture = document.createElement('canvas');
|
||||||
|
texture.width = raster.width;
|
||||||
|
texture.height = raster.height;
|
||||||
|
texture
|
||||||
|
.getContext('2d')
|
||||||
|
.putImageData(
|
||||||
|
new ImageData(raster.rgba, raster.width, raster.height),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
surface = createWeatherSurface(engine, native, 'wind', {
|
||||||
|
rectangle: world,
|
||||||
|
height: WEATHER_SHELL_HEIGHTS.wind,
|
||||||
|
onSettled: wake,
|
||||||
|
});
|
||||||
|
surface.setImage(texture);
|
||||||
|
wake();
|
||||||
|
};
|
||||||
|
const frame = () => {
|
||||||
|
if (!running || document.hidden) {
|
||||||
|
lastTime = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const h = engine.camera.pose().cartographic.height;
|
||||||
|
if (
|
||||||
|
field &&
|
||||||
|
budget !==
|
||||||
|
(canvas.clientWidth < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT)
|
||||||
|
)
|
||||||
|
build();
|
||||||
|
fade = clamp((h - 15000) / 45000);
|
||||||
|
const still = paused || media.matches;
|
||||||
|
const now = performance.now();
|
||||||
|
if (!still && fade && lastTime != null)
|
||||||
|
seconds += Math.min(0.1, (now - lastTime) / 1000);
|
||||||
|
lastTime = !still && fade ? now : null;
|
||||||
|
mesh?.update({
|
||||||
|
time: seconds % 10000,
|
||||||
|
fade,
|
||||||
|
still,
|
||||||
|
width: canvas.clientWidth,
|
||||||
|
height: canvas.clientHeight,
|
||||||
|
});
|
||||||
|
if (surface) {
|
||||||
|
let alpha =
|
||||||
|
(overlay === 'temperature' ? 1 : 0.85) *
|
||||||
|
clamp(Math.log(Math.max(1, h) / 200000) / Math.log(6));
|
||||||
|
if (engine.maps.photorealActive()) alpha = Math.round(alpha * 10) / 10;
|
||||||
|
if (
|
||||||
|
lastAlpha == null ||
|
||||||
|
Math.abs(alpha - lastAlpha) > 0.005 ||
|
||||||
|
(alpha === 0 && lastAlpha !== 0)
|
||||||
|
) {
|
||||||
|
lastAlpha = alpha;
|
||||||
|
surface.setAlpha(alpha);
|
||||||
|
surface.setShow(alpha > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mesh && !still && fade) wake();
|
||||||
|
};
|
||||||
|
const reset = () => {
|
||||||
|
mesh?.destroy();
|
||||||
|
mesh = null;
|
||||||
|
surface?.destroy();
|
||||||
|
surface = null;
|
||||||
|
field = snapshot = null;
|
||||||
|
paths = 0;
|
||||||
|
error = null;
|
||||||
|
lastAlpha = lastTime = null;
|
||||||
|
};
|
||||||
|
const preferenceChanged = () => {
|
||||||
|
lastTime = null;
|
||||||
|
wake();
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
attach() {
|
||||||
|
removeFrame = engine.frames.preRender.addEventListener(frame);
|
||||||
|
media.addEventListener('change', preferenceChanged);
|
||||||
|
document.addEventListener('visibilitychange', preferenceChanged);
|
||||||
|
},
|
||||||
|
setField(next) {
|
||||||
|
const nextField = { ...next.grid, u: next.u, v: next.v };
|
||||||
|
const changed = !sameWind(snapshot, next, field, nextField);
|
||||||
|
snapshot = next;
|
||||||
|
field = nextField;
|
||||||
|
if (changed) build();
|
||||||
|
paint();
|
||||||
|
wake();
|
||||||
|
},
|
||||||
|
setOptions(next) {
|
||||||
|
paused = next.paused;
|
||||||
|
if (overlay !== next.overlay) {
|
||||||
|
overlay = next.overlay;
|
||||||
|
paint();
|
||||||
|
}
|
||||||
|
wake();
|
||||||
|
},
|
||||||
|
start() {
|
||||||
|
running = true;
|
||||||
|
wake();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
running = false;
|
||||||
|
lastTime = null;
|
||||||
|
},
|
||||||
|
clear: reset,
|
||||||
|
destroy() {
|
||||||
|
running = false;
|
||||||
|
removeFrame?.();
|
||||||
|
media.removeEventListener('change', preferenceChanged);
|
||||||
|
document.removeEventListener('visibilitychange', preferenceChanged);
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
getParticleCount: () => paths,
|
||||||
|
getDiagnostics: () => ({
|
||||||
|
renderMode: 'gpu-streamlines',
|
||||||
|
reducedMotion: media.matches,
|
||||||
|
imageryError: error,
|
||||||
|
gpu: {
|
||||||
|
pathCount: paths,
|
||||||
|
phaseSeconds: seconds,
|
||||||
|
ready: Boolean(mesh),
|
||||||
|
heightFade: fade,
|
||||||
|
vertexCount: (mesh?.segments ?? 0) * 4,
|
||||||
|
},
|
||||||
|
surface: surface?.getDiagnostics() ?? null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ const WIND_FIELD_LOG_RANGE = Math.log(
|
|||||||
// Snapshots own immutable decoded arrays. Compare their contents, not grid URLs
|
// Snapshots own immutable decoded arrays. Compare their contents, not grid URLs
|
||||||
// (which include the scalar overlay) or only cycle IDs (which can be revised).
|
// (which include the scalar overlay) or only cycle IDs (which can be revised).
|
||||||
// This bounded scan runs on acquisition, never in the animation loop.
|
// This bounded scan runs on acquisition, never in the animation loop.
|
||||||
function sameWind(previous, next, previousField, nextField) {
|
export function sameWind(previous, next, previousField, nextField) {
|
||||||
if (!previous || !next || !previousField || !nextField) return false;
|
if (!previous || !next || !previousField || !nextField) return false;
|
||||||
for (const key of ['model', 'level', 'units'])
|
for (const key of ['model', 'level', 'units'])
|
||||||
if (previous[key] !== next[key]) return false;
|
if (previous[key] !== next[key]) return false;
|
||||||
|
|||||||
Reference in new issue
Block a user