mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +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/weather/render/navara.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/shared/render/navaraGroundPicks.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',
|
||||
() => 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(
|
||||
(id) => [
|
||||
|
||||
@@ -62,6 +62,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
'military-awareness',
|
||||
'wind',
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
|
||||
@@ -59,6 +59,8 @@ const NAVARA_CREDIT_KEYS = new Set([
|
||||
'eccc-aqhi',
|
||||
'wfigs',
|
||||
's2-ghostmaps',
|
||||
'wind-gfs',
|
||||
'ecmwf-ifs',
|
||||
'weather-noaa',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
@@ -117,6 +119,10 @@ export async function registerNavaraLayerRenderers() {
|
||||
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 () => {
|
||||
const {createCyclonesNavaraRenderer} = await import('../layers/cyclones/render/navara.js');
|
||||
return (engine,options) => createCyclonesNavaraRenderer(engine,navaraViews.get(engine).view,options);
|
||||
|
||||
@@ -14,9 +14,9 @@ import { shellLift } from '../shellRendering.js';
|
||||
|
||||
const KEY = 'gevObservedWeather';
|
||||
const owners = new WeakMap();
|
||||
const products = ['Satellite', 'Radar', 'Lightning'];
|
||||
const products = ['Wind', 'Satellite', 'Radar', 'Lightning'];
|
||||
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 rectArray = (r) => (r ? [r.west, r.south, r.east, r.north] : emptyRect);
|
||||
|
||||
|
||||
+31
-14
@@ -57,10 +57,12 @@ export function createWindLayer({
|
||||
cesium = Cesium,
|
||||
container,
|
||||
createRendering = createWindRendering,
|
||||
createNativeRendering,
|
||||
} = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Wind requires a snapshot source');
|
||||
let viewer = null;
|
||||
let engine = null;
|
||||
let request = null;
|
||||
let enabled = false;
|
||||
let rendering = null;
|
||||
@@ -81,6 +83,10 @@ export function createWindLayer({
|
||||
inspectionMarker?.clear();
|
||||
};
|
||||
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, {
|
||||
position,
|
||||
units,
|
||||
@@ -110,9 +116,13 @@ export function createWindLayer({
|
||||
icon: '🌬',
|
||||
source: 'GFS / ECMWF IFS · FORECAST',
|
||||
updateInterval: 3600_000,
|
||||
init(nextViewer) {
|
||||
init(nextViewer, options) {
|
||||
layer.attachRenderer(nextViewer, options);
|
||||
},
|
||||
attachRenderer(nextViewer, { engine: nextEngine = null } = {}) {
|
||||
viewer = nextViewer;
|
||||
rendering = createRendering({
|
||||
engine = nextEngine;
|
||||
rendering = engine?.kind === 'navara' ? createNativeRendering(engine) : createRendering({
|
||||
cesium,
|
||||
container: container ?? nextViewer.container,
|
||||
getViewer: () => viewer,
|
||||
@@ -125,12 +135,24 @@ export function createWindLayer({
|
||||
});
|
||||
rendering.attach();
|
||||
rendering.setOptions?.({ overlay, paused });
|
||||
const target = container ?? nextViewer.container;
|
||||
const target = container ?? nextViewer?.container ?? engine?.host.container;
|
||||
inspectionMarker = createWindInspectionMarker({
|
||||
container: target,
|
||||
viewer,
|
||||
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) {
|
||||
imageryHost =
|
||||
@@ -182,12 +204,12 @@ export function createWindLayer({
|
||||
error = null;
|
||||
if (sampledPosition) sample(sampledPosition);
|
||||
if (snapshot.unavailable) {
|
||||
rendering.stop();
|
||||
rendering.clear();
|
||||
rendering?.stop();
|
||||
rendering?.clear();
|
||||
} else {
|
||||
rendering.setOptions?.({ overlay, paused });
|
||||
rendering.setField(snapshot);
|
||||
rendering.start();
|
||||
rendering?.setOptions?.({ overlay, paused });
|
||||
rendering?.setField(snapshot);
|
||||
rendering?.start();
|
||||
}
|
||||
return true;
|
||||
} catch (cause) {
|
||||
@@ -438,13 +460,8 @@ export function createWindLayer({
|
||||
destroy() {
|
||||
layer.disable();
|
||||
unsubscribeClock?.();
|
||||
rendering?.destroy();
|
||||
rendering = null;
|
||||
imageryHost = null;
|
||||
viewer = null;
|
||||
layer.detachRenderer();
|
||||
rowControlsListener = null;
|
||||
inspectionMarker?.destroy();
|
||||
inspectionMarker = null;
|
||||
},
|
||||
getStats() {
|
||||
const diagnostic = rendering?.getDiagnostics?.();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { applicationPinia } from '../../ui/vueRuntime.js';
|
||||
export function createWindInspectionMarker({
|
||||
container,
|
||||
viewer,
|
||||
engine,
|
||||
cesium,
|
||||
store = useWindInspectionStore(applicationPinia()),
|
||||
}) {
|
||||
@@ -33,36 +34,40 @@ export function createWindInspectionMarker({
|
||||
show(position) {
|
||||
clear();
|
||||
const scene = viewer?.scene;
|
||||
const canvas = scene?.canvas ?? engine?.host.canvas;
|
||||
const frames = scene?.postRender ?? engine?.frames.postRender;
|
||||
if (
|
||||
!position ||
|
||||
!container?.ownerDocument ||
|
||||
!scene?.postRender?.addEventListener ||
|
||||
!scene.cartesianToCanvasCoordinates
|
||||
!frames?.addEventListener ||
|
||||
(!engine?.projector && !scene?.cartesianToCanvasCoordinates)
|
||||
)
|
||||
return;
|
||||
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,
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
const render = () => {
|
||||
if (!shown()) return;
|
||||
occluder.cameraPosition = viewer.camera.positionWC;
|
||||
const point = scene.cartesianToCanvasCoordinates(position);
|
||||
if (occluder) occluder.cameraPosition = viewer.camera.positionWC;
|
||||
const point = engine
|
||||
? engine.projector.worldToWindow(position, {})
|
||||
: scene.cartesianToCanvasCoordinates(position);
|
||||
const visible =
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= scene.canvas.clientWidth &&
|
||||
point.y <= scene.canvas.clientHeight &&
|
||||
(scene.mode !== cesium.SceneMode.SCENE3D ||
|
||||
occluder.isPointVisible(position));
|
||||
point.x <= canvas.clientWidth &&
|
||||
point.y <= canvas.clientHeight &&
|
||||
(engine ? engine.projector.horizonVisible(position) :
|
||||
scene.mode !== cesium.SceneMode.SCENE3D || occluder.isPointVisible(position));
|
||||
if (!visible) {
|
||||
// A hidden marker keeps its last place, as the element did.
|
||||
if (marker.visible) present({ visible: false });
|
||||
return;
|
||||
}
|
||||
const canvasRect = scene.canvas.getBoundingClientRect();
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const left = point.x + canvasRect.left - containerRect.left;
|
||||
const top = point.y + canvasRect.top - containerRect.top;
|
||||
@@ -70,9 +75,10 @@ export function createWindInspectionMarker({
|
||||
return;
|
||||
present({ visible: true, left, top });
|
||||
};
|
||||
removeRender = scene.postRender.addEventListener(render);
|
||||
removeRender = frames.addEventListener(render);
|
||||
render();
|
||||
scene.requestRender?.();
|
||||
if (engine) engine.render.request('wind-inspection');
|
||||
else scene.requestRender?.();
|
||||
},
|
||||
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
|
||||
// (which include the scalar overlay) or only cycle IDs (which can be revised).
|
||||
// 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;
|
||||
for (const key of ['model', 'level', 'units'])
|
||||
if (previous[key] !== next[key]) return false;
|
||||
|
||||
Reference in new issue
Block a user