Keep GNSS history across globe changes and share the Navara ground cell pass

This commit is contained in:
German Khokhlov committed 2026-11-03 02:30:52 +04:00
1 parent 6fe5bda028
commit 1930ca247a
20 files changed
+628 -414

No files matched your search

+1
View File
@@ -36,6 +36,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
const RENDERED_LAYER_DIRS = [ const RENDERED_LAYER_DIRS = [
'src/layers/alpr/', 'src/layers/alpr/',
'src/layers/airQuality/', 'src/layers/airQuality/',
'src/layers/gnss/',
'src/layers/radio/', 'src/layers/radio/',
'src/layers/bikeshare/', 'src/layers/bikeshare/',
'src/layers/earthquakes/', 'src/layers/earthquakes/',
+6 -1
View File
@@ -3227,6 +3227,7 @@
"src/layers/perimeters/inciweb.js", "src/layers/perimeters/inciweb.js",
"src/app/layers/gnss.js", "src/app/layers/gnss.js",
"src/layers/gnss/index.js", "src/layers/gnss/index.js",
"src/layers/gnss/render/cesium.js",
"src/layers/gnss/source.js", "src/layers/gnss/source.js",
"src/layers/gnss/records.js", "src/layers/gnss/records.js",
"src/app/layers/ghostMaps.js", "src/app/layers/ghostMaps.js",
@@ -3657,6 +3658,7 @@
"src/layers/earthquakes/render/cesium.js", "src/layers/earthquakes/render/cesium.js",
"src/layers/earthquakes/render/navara.js", "src/layers/earthquakes/render/navara.js",
"src/layers/airQuality/render/navara.js", "src/layers/airQuality/render/navara.js",
"src/layers/gnss/render/navara.js",
"src/layers/earthquakes/render/navaraGeometry.js", "src/layers/earthquakes/render/navaraGeometry.js",
"src/layers/earthquakes/render/navaraGrid.js", "src/layers/earthquakes/render/navaraGrid.js",
"src/layers/installations/render/navara.js", "src/layers/installations/render/navara.js",
@@ -3683,7 +3685,8 @@
"src/layers/alpr/render/navara.js", "src/layers/alpr/render/navara.js",
"src/layers/alpr/render/navaraOverlay.js", "src/layers/alpr/render/navaraOverlay.js",
"src/layers/radio/screenClusters.js", "src/layers/radio/screenClusters.js",
"src/layers/firms/render/navaraGeometry.js", "src/layers/shared/render/navaraCellGeometry.js",
"src/layers/shared/render/navaraGroundCells.js",
"src/layers/firms/render/navaraSprites.js", "src/layers/firms/render/navaraSprites.js",
"src/layers/shared/render/navaraGroundGeometry.js", "src/layers/shared/render/navaraGroundGeometry.js",
"src/layers/shared/render/navaraGroundGrid.js", "src/layers/shared/render/navaraGroundGrid.js",
@@ -4104,6 +4107,7 @@
"src/layers/perimeters/inciweb.js", "src/layers/perimeters/inciweb.js",
"src/app/layers/gnss.js", "src/app/layers/gnss.js",
"src/layers/gnss/index.js", "src/layers/gnss/index.js",
"src/layers/gnss/render/cesium.js",
"src/layers/gnss/source.js", "src/layers/gnss/source.js",
"src/layers/gnss/records.js", "src/layers/gnss/records.js",
"src/app/layers/ghostMaps.js", "src/app/layers/ghostMaps.js",
@@ -5237,6 +5241,7 @@
"exports": ["./layers/gnss"], "exports": ["./layers/gnss"],
"modules": [ "modules": [
"src/layers/gnss/index.js", "src/layers/gnss/index.js",
"src/layers/gnss/render/cesium.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/layers/gnss/source.js", "src/layers/gnss/source.js",
@@ -31,6 +31,10 @@ try {
const fetch = window.fetch.bind(window); const fetch = window.fetch.bind(window);
window.fetch = (input, o) => { window.fetch = (input, o) => {
const url = new URL(input.url ?? input, document.baseURI); const url = new URL(input.url ?? input, document.baseURI);
if (url.pathname === '/api/gnss-integrity') {
return Promise.resolve(Response.json({ageMs: 0, rows: ['a1','a2','a3'].map(hex =>
({hex, lat: 43.65, lon: -79.38, nic: 0, nacp: 0, degraded: true, gpsLost: false}))}));
}
if (url.hostname === 'api.weather.gc.ca') { if (url.hostname === 'api.weather.gc.ca') {
const records = url.pathname.includes('aqhi-stations') const records = url.pathname.includes('aqhi-stations')
? [ ? [
@@ -76,12 +80,12 @@ try {
() => window.__godsEyeView.styleManager.initialRestorePromise, () => window.__godsEyeView.styleManager.initialRestorePromise,
); );
}; };
const capture = async (visible) => { const capture = async (visible, layerId = 'air-quality') => {
await p.evaluate(async (show) => { await p.evaluate(async (show, id) => {
const d = window.__godsEyeView; const d = window.__godsEyeView;
await d.dataManager.setEnabled('air-quality', show, { origin: 'user' }); await d.dataManager.setEnabled(id, show, { origin: 'user' });
d.engine.render.request('qa-air-quality'); d.engine.render.request('qa-air-quality');
}, visible); }, visible, layerId);
await new Promise((r) => setTimeout(r, 3900)); await new Promise((r) => setTimeout(r, 3900));
const shot = await p.screenshot(); const shot = await p.screenshot();
return sharp(shot) return sharp(shot)
@@ -97,6 +101,7 @@ try {
if (d.tileset || window.__GOOGLE_MAPS_API_KEY__) if (d.tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Keyless required'); throw Error('Keyless required');
window.qaAir = d.dataManager.layers.get('air-quality').module; window.qaAir = d.dataManager.layers.get('air-quality').module;
window.qaGnss = d.dataManager.layers.get('gnss-integrity').module;
}); });
for (const kind of ['navara', 'cesium', 'navara']) { for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
@@ -105,10 +110,12 @@ try {
const same = await p.evaluate( const same = await p.evaluate(
() => () =>
window.__godsEyeView.dataManager.layers.get('air-quality').module === window.__godsEyeView.dataManager.layers.get('air-quality').module ===
window.qaAir, window.qaAir &&
window.__godsEyeView.dataManager.layers.get('gnss-integrity').module === window.qaGnss,
); );
assert.ok(same); assert.ok(same);
} }
await p.evaluate(() => window.__godsEyeView.dataManager.setEnabled('gnss-integrity', false));
const off = await capture(false), const off = await capture(false),
on = await capture(true); on = await capture(true);
const values = await p.evaluate(() => ({ const values = await p.evaluate(() => ({
@@ -131,6 +138,14 @@ try {
assert.equal(values.records[0].name, 'QA Toronto'); assert.equal(values.records[0].name, 'QA Toronto');
assert.equal(values.records[0].value, 9); assert.equal(values.records[0].value, 9);
assert.equal(values.card.length, 1); assert.equal(values.card.length, 1);
await capture(false);
const cellOff = await capture(false, 'gnss-integrity');
const cellOn = await capture(true, 'gnss-integrity');
assert.ok(orange(cellOn) > orange(cellOff) + 5, kind + ' must draw GNSS high cell');
const gnss = await p.evaluate(() => window.qaGnss.getStats());
assert.equal(gnss.levels.high, 1);
console.log('PASS', kind, 'GNSS high cell, view anchor', gnss.coverage.latitude, gnss.coverage.longitude);
await p.evaluate(() => window.__godsEyeView.dataManager.setEnabled('air-quality', true));
} }
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
} finally { } finally {
+2 -2
View File
@@ -196,10 +196,10 @@ export function createApplicationCatalog({
[ [
'gnss-integrity', 'gnss-integrity',
() => () =>
createApplicationGnssIntegrity({ sessionModel('gnss-integrity', () => createApplicationGnssIntegrity({
source: sources['gnss-integrity'], source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs, windowMs: sources['gnss-integrity'].windowMs,
}), })),
], ],
[ [
's2-ghostmaps', 's2-ghostmaps',
+8 -1
View File
@@ -1,6 +1,13 @@
import { rendererFor } from './renderers.js';
import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js';
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */ /** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) { export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer(options); return createGnssIntegrityLayer({
createRenderer: ({ viewer, engine, viewAnchor }) => engine
? rendererFor('gnss-integrity', engine, { viewer, viewAnchor })
: createGnssCesiumRenderer({ viewer, viewAnchor }),
...options,
});
} }
+2
View File
@@ -1,3 +1,4 @@
import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js';
import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js'; import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js';
import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js'; import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js';
import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js'; import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js';
@@ -94,6 +95,7 @@ export const layerAvailableOn = (layerId, engineKind) =>
export const CESIUM_LAYER_RENDERERS = Object.freeze({ export const CESIUM_LAYER_RENDERERS = Object.freeze({
earthquakes: createEarthquakesCesiumRenderer, earthquakes: createEarthquakesCesiumRenderer,
'air-quality': (_engine, options) => createAirQualityCesiumRenderer(options), 'air-quality': (_engine, options) => createAirQualityCesiumRenderer(options),
'gnss-integrity': (_engine, options) => createGnssCesiumRenderer(options),
'local-firms': createFirmsCesiumRenderer, 'local-firms': createFirmsCesiumRenderer,
'rocket-launches': createLaunchesCesiumRenderer, 'rocket-launches': createLaunchesCesiumRenderer,
satellites: createSatellitesCesiumRenderer, satellites: createSatellitesCesiumRenderer,
+1
View File
@@ -60,6 +60,7 @@ test('the aircraft, vessels, satellites, launches and earthquakes have extracted
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [ assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
'earthquakes', 'earthquakes',
'air-quality', 'air-quality',
'gnss-integrity',
'local-firms', 'local-firms',
'rocket-launches', 'rocket-launches',
'satellites', 'satellites',
+2
View File
@@ -26,6 +26,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'ais-live-vessels', 'ais-live-vessels',
'earthquakes', 'earthquakes',
'air-quality', 'air-quality',
'gnss-integrity',
'local-firms', 'local-firms',
'military-installations', 'military-installations',
'bikeshare', 'bikeshare',
@@ -46,6 +47,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'local-adsb', 'local-adsb',
'earthquakes', 'earthquakes',
'air-quality', 'air-quality',
'gnss-integrity',
'alpr-cameras', 'alpr-cameras',
'satellites', 'satellites',
'rocket-launches', 'rocket-launches',
+4
View File
@@ -123,6 +123,10 @@ export async function registerNavaraLayerRenderers() {
...navaraViews.get(engine), ...navaraViews.get(engine),
}); });
}, },
'gnss-integrity': async () => {
const { createGnssNavaraRenderer } = await import('../layers/gnss/render/navara.js');
return (engine) => createGnssNavaraRenderer(engine, navaraViews.get(engine));
},
'air-quality': async () => { 'air-quality': async () => {
const { createAirQualityNavaraRenderer } = const { createAirQualityNavaraRenderer } =
await import('../layers/airQuality/render/navara.js'); await import('../layers/airQuality/render/navara.js');
+10 -287
View File
@@ -1,291 +1,26 @@
// FIRMS terrain/tiles classification and actual engine mover glows. // FIRMS terrain/tiles classification and actual engine mover glows.
import { Effect, EffectAttribute } from 'postprocessing';
import { Matrix4, Uniform } from 'three';
import { createEngineLayerView } from '../../shared/render/engineView.js'; import { createEngineLayerView } from '../../shared/render/engineView.js';
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
import { LOD_LEVELS } from '../policy.js'; import { LOD_LEVELS } from '../policy.js';
import { createFirmsSpriteBank } from './navaraSprites.js'; import { createFirmsSpriteBank } from './navaraSprites.js';
import { rectangleFrame } from './navaraGeometry.js'; import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js';
import {
viewFrame,
groundClipBounds,
} from '../../shared/render/navaraGroundGeometry.js';
import {
createGroundGrid,
GRID_COLS,
GRID_ROWS,
} from '../../shared/render/navaraGroundGrid.js';
import {
createGroundTexture,
createGroundStorage,
groundSurfaceUniforms,
updateGroundSurface,
} from '../../shared/render/navaraGroundSurface.js';
const KEY = 'gevFirmsGround';
const registeredViews = new WeakSet();
const fragmentShader = /* glsl */ `
uniform sampler2D tAllDepth;
uniform sampler2D tSurfaceDepth;
uniform sampler2D tCells;
uniform sampler2D tGrid;
uniform sampler2D tCandidates;
uniform vec2 uCellSize;
uniform vec2 uCandidateSize;
uniform mat4 uInverseProjection;
uniform mat4 uInverseView;
uniform float uLogDepthFC;
vec4 atSlot(sampler2D image,vec2 size,float slot) {
return texture2D(image,(vec2(mod(slot,size.x),floor(slot/size.x))+0.5)/size);
}
vec3 toDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 toLinear(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
vec3 pixelPlane(float distance,vec3 normal,vec3 dx,vec3 dy) {
return vec3(distance,dot(normal,dx),dot(normal,dy));
}
float rectangleCoverage(vec3 west,vec3 east,vec3 south,vec3 north) {
vec3 edges[4];
edges[0]=west;edges[1]=east;edges[2]=south;edges[3]=north;
float coverage=1.0;
int partial=0;
for(int i=0;i<4;i++) {
float width=abs(edges[i].y)+abs(edges[i].z);
coverage=min(coverage,clamp(edges[i].x/max(width,0.001)+0.5,0.0,1.0));
if(coverage<=0.0) return 0.0;
if(edges[i].x<=width*0.5) partial++;
}
if(partial<2) return coverage;
// Intersect the SAME pixel square with all sides. Minimum soft-edge
// coverage creates long miter halos at acute/polar rectangle corners.
vec2 polygon[8],clipped[8];
polygon[0]=vec2(-0.5,-0.5);polygon[1]=vec2(0.5,-0.5);
polygon[2]=vec2(0.5,0.5);polygon[3]=vec2(-0.5,0.5);
int count=4;
for(int side=0;side<4;side++) {
int nextCount=0;
vec2 previous=polygon[count-1];
float before=edges[side].x+dot(edges[side].yz,previous);
for(int i=0;i<8;i++) {
if(i>=count) break;
vec2 current=polygon[i];
float after=edges[side].x+dot(edges[side].yz,current);
if((before>0.0&&after<0.0)||(before<0.0&&after>0.0))
clipped[nextCount++]=mix(previous,current,before/(before-after));
if(after>=0.0) clipped[nextCount++]=current;
previous=current;before=after;
}
count=nextCount;
if(count<3) return 0.0;
for(int i=0;i<8;i++) { if(i>=count) break;polygon[i]=clipped[i]; }
}
float area=0.0;
vec2 previous=polygon[count-1];
for(int i=0;i<8;i++) {
if(i>=count) break;
area+=previous.x*polygon[i].y-previous.y*polygon[i].x;
previous=polygon[i];
}
return clamp(abs(area)*0.5,0.0,1.0);
}
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
outputColor=inputColor;
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
vec3 point=ray.xyz*(distance/-ray.z);
// Evaluate derivatives before divergent candidate branches, as in quakes.
vec3 world=(uInverseView*vec4(point,1.0)).xyz;
vec3 dx=(uInverseView*vec4(dFdx(point),0.0)).xyz;
vec3 dy=(uInverseView*vec4(dFdy(point),0.0)).xyz;
// A quad crossing the sky has no surface derivative. Keep exact halfspaces
// there; otherwise its far-plane lane spreads cell AA along the globe limb.
float onSurface=1.0-step(1.0,depth);
float derivativeMask=1.0-step(0.5,abs(dFdx(onSurface))+abs(dFdy(onSurface)));
dx*=derivativeMask;
dy*=derivativeMask;
if(depth>=1.0) return;
vec2 tile=clamp(floor(uv*vec2(${GRID_COLS}.0,${GRID_ROWS}.0)),vec2(0.0),vec2(${GRID_COLS - 1}.0,${GRID_ROWS - 1}.0));
vec2 range=texture2D(tGrid,(tile+0.5)/vec2(${GRID_COLS}.0,${GRID_ROWS}.0)).xy;
if(range.y<0.5) return;
float surface=texture2D(tSurfaceDepth,uv).r;
if(depth<surface-2e-7) return;
float rho=length(world.xy);
vec2 radial=world.xy/max(rho,0.001);
vec3 color=toDisplay(inputColor.rgb);
for(int k=0;k<int(range.y);k++) {
float id=atSlot(tCandidates,uCandidateSize,range.x+float(k)).x;
vec4 longitude=atSlot(tCells,uCellSize,id*4.0);
vec3 south=atSlot(tCells,uCellSize,id*4.0+1.0).xyz;
vec3 north=atSlot(tCells,uCellSize,id*4.0+2.0).xyz;
vec3 westNormal=vec3(-longitude.x,longitude.y,0.0);
vec3 eastNormal=vec3(longitude.z,-longitude.w,0.0);
float coverage=rectangleCoverage(
pixelPlane(dot(westNormal,world),westNormal,dx,dy),
pixelPlane(dot(eastNormal,world),eastNormal,dx,dy),
pixelPlane(world.z*south.y-rho*south.x+south.z,vec3(-south.x*radial,south.y),dx,dy),
pixelPlane(rho*north.x-world.z*north.y-north.z,vec3(north.x*radial,-north.y),dx,dy));
if(coverage<=0.0) continue;
vec4 fill=atSlot(tCells,uCellSize,id*4.0+3.0);
color=mix(color,fill.rgb,fill.a*coverage);
}
outputColor=vec4(toLinear(color),inputColor.a);
}
`;
function buildState(cells) {
const frames = cells.map(rectangleFrame),
packed = createGroundStorage(cells.length * 4);
const grid = createGroundGrid(cells.length),
gridData = new Float32Array(GRID_COLS * GRID_ROWS * 4);
const gridTexture = createGroundTexture(gridData, GRID_COLS, GRID_ROWS);
let candidates = createGroundStorage(1);
const scratch = new Float64Array(12),
bounds = new Float64Array(4);
const lastView = new Float64Array(16).fill(NaN),
lastProjection = new Float64Array(16).fill(NaN);
let lastWidth = 0,
lastHeight = 0;
frames.forEach((frame, index) => {
const offset = index * 16,
color = cells[index].color;
packed.data.set(frame.bounds.longitude, offset);
packed.data.set(frame.bounds.south, offset + 4);
packed.data.set(frame.bounds.north, offset + 8);
packed.data.set(
[color.red, color.green, color.blue, color.alpha],
offset + 12,
);
});
return {
count: cells.length,
packed,
gridTexture,
get candidates() {
return candidates;
},
update(camera, context) {
const input = context.getInputBuffer(),
view = camera.matrixWorldInverse.elements,
projection = camera.projectionMatrix.elements;
let changed = input.width !== lastWidth || input.height !== lastHeight;
for (let i = 0; i < 16 && !changed; i++)
changed =
view[i] !== lastView[i] || projection[i] !== lastProjection[i];
if (!changed) return;
lastView.set(view);
lastProjection.set(projection);
lastWidth = input.width;
lastHeight = input.height;
frames.forEach((frame, index) => {
viewFrame(frame, camera.matrixWorldInverse, scratch);
groundClipBounds(frame, scratch, camera.projectionMatrix, bounds);
if (bounds[0] <= 1) {
bounds[0] -= 2 / Math.max(1, input.width);
bounds[2] += 2 / Math.max(1, input.width);
bounds[1] -= 2 / Math.max(1, input.height);
bounds[3] += 2 / Math.max(1, input.height);
}
grid.bounds.set(bounds, index * 4);
});
const total = grid.pack();
if (candidates.data.length / 4 < total) {
candidates.texture.dispose();
candidates = createGroundStorage(
Math.max(total, candidates.data.length / 2),
);
}
for (let i = 0; i < total; i++) candidates.data[i * 4] = grid.indices[i];
for (let i = 0; i < grid.counts.length; i++) {
gridData[i * 4] = grid.offsets[i];
gridData[i * 4 + 1] = grid.counts[i];
}
gridTexture.needsUpdate = true;
candidates.texture.needsUpdate = true;
},
dispose() {
packed.texture.dispose();
gridTexture.dispose();
candidates.texture.dispose();
},
};
}
function groundEffectClass(EffectDesc, NavaraEffect) {
return class FirmsGroundEffectDesc extends EffectDesc {
static key = KEY;
static insertAfter = [
'gevEarthquakeGround',
'gevRecentImagery',
'toneMapping',
];
static insertBefore = [
'gevPreSharpenOverlay',
'gevSharpen',
'smaa',
'fxaa',
'gevOverlay',
'final',
];
constructor(view, context, config) {
super(view, context, config);
this.owner = config[KEY].owner;
}
createPass() {
this.effect = new Effect('GevFirmsGround', fragmentShader, {
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms(),
['tCells', new Uniform(null)],
['tGrid', new Uniform(null)],
['tCandidates', new Uniform(null)],
['uCellSize', new Uniform([1, 1])],
['uCandidateSize', new Uniform([1, 1])],
['uInverseView', new Uniform(new Matrix4())],
]),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
}
update() {
const state = this.owner.state,
camera = this.view.camera.raw;
if (!this.effect || !state || !camera) return;
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
state.update(camera, this.ctx);
const u = this.effect.uniforms;
u.get('tCells').value = state.packed.texture;
u.get('uCellSize').value[0] = state.packed.width;
u.get('uCellSize').value[1] = state.packed.height;
u.get('tGrid').value = state.gridTexture;
u.get('tCandidates').value = state.candidates.texture;
u.get('uCandidateSize').value[0] = state.candidates.width;
u.get('uCandidateSize').value[1] = state.candidates.height;
u.get('uInverseView').value.copy(camera.matrixWorld);
}
onDestroy() {
this.effect?.dispose();
super.onDestroy();
}
};
}
export function createFirmsNavaraRenderer( export function createFirmsNavaraRenderer(
engine, engine,
{ view: nativeView, EffectDesc, NavaraEffect, id = 'local-firms' } = {}, { view: nativeView, EffectDesc, NavaraEffect, id = 'local-firms' } = {},
) { ) {
if (!engine?.movers || !nativeView?.camera || !EffectDesc || !NavaraEffect) if (!engine?.movers || !nativeView?.camera || !EffectDesc || !NavaraEffect)
throw new TypeError('FIRMS requires a Navara view'); throw new TypeError('FIRMS requires a Navara view');
if (!registeredViews.has(nativeView)) { const display = createGroundCellDisplay(
nativeView.registerEffect(KEY, groundEffectClass(EffectDesc, NavaraEffect)); engine,
registeredViews.add(nativeView); { view: nativeView, EffectDesc, NavaraEffect },
} 1,
);
let destroyed = false, let destroyed = false,
visible = false, visible = false,
cells = [], cells = [],
queued = false, queued = false,
handle = null,
sprites = null; sprites = null;
const owner = { state: buildState([]) }, const dataSource = { show: false },
dataSource = { show: false },
handlers = new Set(); handlers = new Set();
const adapter = createEngineLayerView(engine, { const adapter = createEngineLayerView(engine, {
layerId: id, layerId: id,
@@ -293,24 +28,13 @@ export function createFirmsNavaraRenderer(
}); });
adapter.view.camera.moveEnd = engine.camera.moveEnd; adapter.view.camera.moveEnd = engine.camera.moveEnd;
adapter.view.scene.preRender = engine.frames.preRender; adapter.view.scene.preRender = engine.frames.preRender;
const sync = () => {
const show = visible && owner.state.count > 0;
if (show && !handle)
handle = nativeView.addEffect({ [KEY]: { owner }, visible: true });
else handle?.update({ visible: show });
};
const scheduleCells = () => { const scheduleCells = () => {
if (queued || destroyed) return; if (queued || destroyed) return;
queued = true; queued = true;
queueMicrotask(() => { queueMicrotask(() => {
queued = false; queued = false;
if (destroyed) return; if (destroyed) return;
const next = buildState(cells), display.replace(cells);
previous = owner.state;
owner.state = next;
sync();
previous.dispose();
if (visible) engine.render.request('firms-cells');
}); });
}; };
const removeSprites = () => { const removeSprites = () => {
@@ -366,7 +90,7 @@ export function createFirmsNavaraRenderer(
visible = next; visible = next;
dataSource.show = next; dataSource.show = next;
sprites?.setVisible(next); sprites?.setVisible(next);
sync(); display.setVisible(visible);
engine.render.request('firms-visibility'); engine.render.request('firms-visibility');
}, },
clearCells() { clearCells() {
@@ -406,8 +130,7 @@ export function createFirmsNavaraRenderer(
cells = []; cells = [];
for (const handler of [...handlers]) handler.destroy(); for (const handler of [...handlers]) handler.destroy();
removeSprites(); removeSprites();
handle?.delete(); display.destroy();
owner.state.dispose();
adapter.destroy(); adapter.destroy();
engine.render.request('firms-destroy'); engine.render.request('firms-destroy');
}, },
+27
View File
@@ -1,3 +1,4 @@
import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createEngineEvent } from '../../../globe/contract.js'; import { createEngineEvent } from '../../../globe/contract.js';
@@ -130,6 +131,32 @@ test('ground changes are atomic, lazy and dispose replaced and destroyed GPU tex
second.destroy(); second.destroy();
assert.equal(h.registered.length, 1); assert.equal(h.registered.length, 1);
}); });
test('GNSS and FIRMS share one ground pass without hiding or destroying one another', async () => {
const h = harness();
const gnss = createGroundCellDisplay(h.engine, h.options, 0);
const fire = h.renderer;
try {
fire.setVisible(true);
fire.addCell(cell);
await settle();
gnss.replace([cell, cell]);
gnss.setVisible(true);
const owner = h.effects[0].config.gevFirmsGround.owner;
assert.equal(h.effects.length, 1);
assert.equal(owner.state.count, 3);
gnss.setVisible(false);
assert.equal(owner.state.count, 1);
gnss.setVisible(true);
fire.destroy();
assert.equal(owner.state.count, 2);
assert.equal(h.effects[0].deleted, 0);
} finally {
fire.destroy();
gnss.destroy();
}
assert.equal(h.effects[0].deleted, 1);
});
test('FIRMS selection uses actual engine event picks and releases its dispatcher subscriptions', () => { test('FIRMS selection uses actual engine event picks and releases its dispatcher subscriptions', () => {
const h = harness(), const h = harness(),
r = h.renderer; r = h.renderer;
@@ -6,7 +6,7 @@ import {
rectangleBoundaries, rectangleBoundaries,
rectangleDistances, rectangleDistances,
rectangleFrame, rectangleFrame,
} from './navaraGeometry.js'; } from '../../shared/render/navaraCellGeometry.js';
const cases = []; const cases = [];
for (const lat of [-90, -80, -45, 0, 45, 80, 88]) for (const lat of [-90, -80, -45, 0, 45, 80, 88])
for (const west of [-180, -123, 0, 178]) for (const west of [-180, -123, 0, 178])
+52 -115
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import { createGnssCesiumRenderer } from './render/cesium.js';
export { defaultGnssViewAnchor } from './render/cesium.js';
import { formatNumber, msg, t } from '../../i18n/index.js'; import { formatNumber, msg, t } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js'; import { translateFeedError } from '../../ui/statusText.js';
import { import {
@@ -13,17 +14,6 @@ import {
export * from './records.js'; export * from './records.js';
export { createAdsbGnssSource } from './source.js'; export { createAdsbGnssSource } from './source.js';
/**
* GroundPrimitive allowPicking:false suppresses ids but still submits stencil
* commands in Cesium's pick pass, erasing picks of overlapping ground geometry.
* This fill participates only in rendering, so sibling polygons remain pickable.
*/
class GnssGroundFill extends Cesium.GroundPrimitive {
update(frameState) {
if (!frameState.passes.pick) super.update(frameState);
}
}
/** Low stays visible over land so coverage reads apart from no data. */ /** Low stays visible over land so coverage reads apart from no data. */
const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 }); const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 });
const LEVEL_LABELS = Object.freeze({ const LEVEL_LABELS = Object.freeze({
@@ -32,34 +22,6 @@ const LEVEL_LABELS = Object.freeze({
high: msg('Low-accuracy score > 10%'), high: msg('Low-accuracy score > 10%'),
}); });
/** Degrees at the centre of the current view, or null before the camera settles. */
export function defaultGnssViewAnchor(viewer) {
const canvas = viewer?.scene?.canvas;
const ellipsoid = viewer?.scene?.globe?.ellipsoid;
// At globe scale the visible rectangle can be the whole world, whose
// midpoint is unrelated to the place under the screen centre.
const centre =
canvas?.clientWidth > 0 &&
canvas.clientHeight > 0 &&
viewer.camera?.pickEllipsoid?.(
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
ellipsoid,
);
const rectangle =
!centre && viewer?.camera?.computeViewRectangle?.(ellipsoid);
const carto = centre
? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre)
: rectangle
? Cesium.Rectangle.center(rectangle)
: viewer?.camera?.positionCartographic;
if (!carto) return null;
const latitude = Cesium.Math.toDegrees(carto.latitude);
const longitude = Cesium.Math.toDegrees(carto.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude)
? { latitude, longitude }
: null;
}
/** /**
* Own one GNSS navigation-integrity display: rolling integrity observations * Own one GNSS navigation-integrity display: rolling integrity observations
* around the views the user visits, binned into cells coloured by the * around the views the user visits, binned into cells coloured by the
@@ -67,7 +29,8 @@ export function defaultGnssViewAnchor(viewer) {
*/ */
export function createGnssIntegrityLayer({ export function createGnssIntegrityLayer({
source, source,
viewAnchor = defaultGnssViewAnchor, viewAnchor,
createRenderer = createGnssCesiumRenderer,
now = () => Date.now(), now = () => Date.now(),
monotonicNow = () => performance.now(), monotonicNow = () => performance.now(),
windowMs: requestedWindowMs, windowMs: requestedWindowMs,
@@ -80,8 +43,9 @@ export function createGnssIntegrityLayer({
Number.isFinite(requestedWindowMs) && requestedWindowMs > 0 Number.isFinite(requestedWindowMs) && requestedWindowMs > 0
? requestedWindowMs ? requestedWindowMs
: GNSS_WINDOW_MS; : GNSS_WINDOW_MS;
let _viewer = null; let initialized = false;
let _primitives = null; let renderer = null;
let lastAnchor = null;
let _request = null; let _request = null;
let _enabled = false; let _enabled = false;
let _cells = []; let _cells = [];
@@ -109,9 +73,9 @@ export function createGnssIntegrityLayer({
: null; : null;
function scheduleCameraRefresh() { function scheduleCameraRefresh() {
if (!_enabled || !_primitives) return; if (!_enabled || !renderer) return;
if (now() < _retryAt) return; if (now() < _retryAt) return;
const key = queryKey(viewAnchor(_viewer)); const key = queryKey(renderer?.anchor());
if (!key || key === _lastQueryKey) return; if (!key || key === _lastQueryKey) return;
clearTimeout(_cameraTimer); clearTimeout(_cameraTimer);
// Debounce drags and bound moving/follow cameras to at most 24 requests // Debounce drags and bound moving/follow cameras to at most 24 requests
@@ -120,7 +84,7 @@ export function createGnssIntegrityLayer({
async () => { async () => {
_cameraTimer = null; _cameraTimer = null;
if (!_enabled || _backgroundRefresh || _request) return; if (!_enabled || _backgroundRefresh || _request) return;
const key = queryKey(viewAnchor(_viewer)); const key = queryKey(renderer?.anchor());
if (!key || key === _lastQueryKey) return; if (!key || key === _lastQueryKey) return;
await refreshInBackground(); await refreshInBackground();
}, },
@@ -174,45 +138,8 @@ export function createGnssIntegrityLayer({
_cells.map(({ id, level }) => [id, level]), _cells.map(({ id, level }) => [id, level]),
); );
if (signature === _signature) return; if (signature === _signature) return;
renderer?.replace(_cells, FILL_ALPHA);
_signature = signature; _signature = signature;
_primitives.removeAll();
if (_cells.length) {
_primitives.add(
new GnssGroundFill({
geometryInstances: _cells.map(
(cell) =>
new Cesium.GeometryInstance({
id: `${layer.id}:${cell.id}`,
geometry: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(
cell.west,
cell.south,
cell.east,
cell.north,
),
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.fromCssColorString(
GNSS_LEVEL_COLORS[cell.level],
).withAlpha(FILL_ALPHA[cell.level]),
),
},
}),
),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
}),
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
// Retain the compact rectangle descriptors for inspection, not Entity visualizers.
releaseGeometryInstances: false,
}),
);
}
_viewer?.scene?.requestRender?.();
} }
const layer = { const layer = {
@@ -228,29 +155,45 @@ export function createGnssIntegrityLayer({
_manager = manager; _manager = manager;
}, },
init(viewer) { init(viewer, { engine = null } = {}) {
if (_viewer) if (initialized)
throw new Error('GNSS integrity layer is already initialized'); throw new Error('GNSS integrity layer is already initialized');
_viewer = viewer; initialized = true;
_primitives = new Cesium.PrimitiveCollection(); try {
_primitives.id = layer.id; if (viewer || engine) this.attachRenderer(viewer, { engine });
_primitives.show = false; } catch (error) {
viewer.scene.groundPrimitives.add(_primitives); this.destroy();
_removeCameraEnd = viewer.camera?.moveEnd?.addEventListener( throw error;
scheduleCameraRefresh, }
); },
_removeCameraChanged = viewer.camera?.changed?.addEventListener(
scheduleCameraRefresh, attachRenderer(viewer, { engine = null } = {}) {
); if (!initialized || renderer)
throw new Error('Invalid GNSS scene attachment');
renderer = createRenderer({ viewer, engine, viewAnchor });
_removeCameraEnd = renderer.camera?.moveEnd?.addEventListener(scheduleCameraRefresh);
_removeCameraChanged = renderer.camera?.changed?.addEventListener(scheduleCameraRefresh);
_signature = null;
if (_enabled) expire();
renderer.setVisible(_enabled);
},
detachRenderer() {
clearTimeout(_cameraTimer);
_cameraTimer = null;
_removeCameraEnd?.();
_removeCameraChanged?.();
_removeCameraEnd = null;
_removeCameraChanged = null;
renderer?.destroy();
renderer = null;
_signature = null;
}, },
enable() { enable() {
_enabled = true; _enabled = true;
// Cells left from before a long disable must not reappear past the window.
if (_primitives) {
expire(); expire();
_primitives.show = true; renderer?.setVisible(true);
}
}, },
disable() { disable() {
@@ -263,11 +206,11 @@ export function createGnssIntegrityLayer({
_request?.abort(); _request?.abort();
_request = null; _request = null;
_enabled = false; _enabled = false;
if (_primitives) _primitives.show = false; renderer?.setVisible(false);
}, },
async update(_viewerArg, { signal } = {}) { async update(_viewerArg, { signal } = {}) {
if (!_enabled || !_primitives || signal?.aborted) return false; if (!_enabled || signal?.aborted) return false;
if (now() < _retryAt) { if (now() < _retryAt) {
expire(); expire();
scheduleRetry(); scheduleRetry();
@@ -276,7 +219,8 @@ export function createGnssIntegrityLayer({
_retryAt = 0; _retryAt = 0;
clearTimeout(_retryTimer); clearTimeout(_retryTimer);
_retryTimer = null; _retryTimer = null;
const anchor = viewAnchor(_viewer); const anchor = renderer ? renderer.anchor() : lastAnchor;
lastAnchor = anchor;
// No anchor yet (camera still settling) is not a failure: returning // No anchor yet (camera still settling) is not a failure: returning
// false would make the manager reject the enable. The next interval // false would make the manager reject the enable. The next interval
// retries; cells already drawn still age out of the window. // retries; cells already drawn still age out of the window.
@@ -352,20 +296,13 @@ export function createGnssIntegrityLayer({
} }
}, },
destroy(viewer = _viewer) { destroy() {
layer.disable(); layer.disable();
_removeCameraEnd?.(); initialized = false;
_removeCameraChanged?.(); layer.detachRenderer();
_removeCameraEnd = null;
_removeCameraChanged = null;
_observations.clear(); _observations.clear();
_cells = []; _cells = [];
_signature = null; lastAnchor = null;
if (_primitives) {
viewer.scene.groundPrimitives.remove(_primitives);
_primitives = null;
}
_viewer = null;
_lastUpdate = null; _lastUpdate = null;
_lastError = null; _lastError = null;
_stale = false; _stale = false;
+21
View File
@@ -698,3 +698,24 @@ test('view anchor falls back to rectangle, then nadir, then no camera', () => {
assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10); assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10); assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10);
}); });
test('an in-flight regional response supplies the next renderer without a new request', async () => {
let resolve, calls = 0;
const h = harness(() => { calls++; return new Promise(done => { resolve = done; }); });
try {
h.layer.enable();
const pending = h.layer.update();
h.layer.detachRenderer();
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
resolve({ rows: threeHealthy() });
assert.equal(await pending, true);
assert.equal(h.sources.length, 0);
assert.equal(h.layer.getStats().levels.low, 1);
h.layer.attachRenderer(h.viewer);
assert.equal(h.sources[0].length, 1);
assert.equal(calls, 1);
assert.equal(h.viewer.camera.changed.numberOfListeners, 1);
} finally {
h.layer.destroy();
}
});
+102
View File
@@ -0,0 +1,102 @@
import * as Cesium from 'cesium';
import { GNSS_LEVEL_COLORS } from '../records.js';
/**
* GroundPrimitive allowPicking:false suppresses ids but still submits stencil
* commands in Cesium's pick pass, erasing picks of overlapping ground geometry.
* This fill participates only in rendering, so sibling polygons remain pickable.
*/
class GnssGroundFill extends Cesium.GroundPrimitive {
update(frameState) {
if (!frameState.passes.pick) super.update(frameState);
}
}
/** Degrees at the centre of the current view, or null before the camera settles. */
export function defaultGnssViewAnchor(viewer) {
const canvas = viewer?.scene?.canvas;
const ellipsoid = viewer?.scene?.globe?.ellipsoid;
// At globe scale the visible rectangle can be the whole world, whose
// midpoint is unrelated to the place under the screen centre.
const centre =
canvas?.clientWidth > 0 &&
canvas.clientHeight > 0 &&
viewer.camera?.pickEllipsoid?.(
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
ellipsoid,
);
const rectangle =
!centre && viewer?.camera?.computeViewRectangle?.(ellipsoid);
const carto = centre
? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre)
: rectangle
? Cesium.Rectangle.center(rectangle)
: viewer?.camera?.positionCartographic;
if (!carto) return null;
const latitude = Cesium.Math.toDegrees(carto.latitude);
const longitude = Cesium.Math.toDegrees(carto.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude)
? { latitude, longitude }
: null;
}
export function createGnssCesiumRenderer({
viewer,
viewAnchor = defaultGnssViewAnchor,
}) {
const primitives = new Cesium.PrimitiveCollection();
primitives.id = 'gnss-integrity';
primitives.show = false;
viewer.scene.groundPrimitives.add(primitives);
return {
camera: viewer.camera,
anchor: () => viewAnchor(viewer),
setVisible(show) {
primitives.show = show;
},
replace(cells, alpha) {
primitives.removeAll();
if (cells.length) {
primitives.add(
new GnssGroundFill({
geometryInstances: cells.map(
(cell) =>
new Cesium.GeometryInstance({
id: `gnss-integrity:${cell.id}`,
geometry: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(
cell.west,
cell.south,
cell.east,
cell.north,
),
vertexFormat:
Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.fromCssColorString(
GNSS_LEVEL_COLORS[cell.level],
).withAlpha(alpha[cell.level]),
),
},
}),
),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
}),
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
// Retain the compact rectangle descriptors for inspection, not Entity visualizers.
releaseGeometryInstances: false,
}),
);
}
viewer?.scene?.requestRender?.();
},
destroy() {
viewer.scene.groundPrimitives.remove(primitives);
},
};
}
+47
View File
@@ -0,0 +1,47 @@
import * as geo from '../../../geo/index.js';
import { GNSS_LEVEL_COLORS } from '../records.js';
import { rgbaFromCss } from '../../shared/color.js';
import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js';
export function createGnssNavaraRenderer(engine, options) {
const cells = createGroundCellDisplay(engine, options, 0);
const { camera } = engine;
function anchor() {
const { width, height } = engine.host.size();
const centre =
width && height && camera.pickEllipsoid({ x: width / 2, y: height / 2 });
let position = centre && geo.cartographic.fromCartesian(centre);
if (!position) {
const rect = camera.viewRectangle();
if (rect) {
const east =
rect.east < rect.west ? rect.east + 2 * Math.PI : rect.east;
const longitude = (rect.west + east) / 2;
position = {
latitude: (rect.south + rect.north) / 2,
longitude: longitude > Math.PI ? longitude - 2 * Math.PI : longitude,
};
} else position = camera.pose().cartographic;
}
return {
latitude: geo.math.toDegrees(position.latitude),
longitude: geo.math.toDegrees(position.longitude),
};
}
return {
camera,
anchor,
setVisible: cells.setVisible,
destroy: cells.destroy,
replace(rows, alpha) {
cells.replace(
rows.map((row) => ({
...row,
color: rgbaFromCss(GNSS_LEVEL_COLORS[row.level]).withAlpha(
alpha[row.level],
),
})),
);
},
};
}
@@ -0,0 +1,320 @@
// Classify visible terrain and 3D tiles. Multiple cell layers share one non-pickable pass.
import { Effect, EffectAttribute } from 'postprocessing';
import { Matrix4, Uniform } from 'three';
import { rectangleFrame } from './navaraCellGeometry.js';
import {
viewFrame,
groundClipBounds,
} from './navaraGroundGeometry.js';
import {
createGroundGrid,
GRID_COLS,
GRID_ROWS,
} from './navaraGroundGrid.js';
import {
createGroundTexture,
createGroundStorage,
groundSurfaceUniforms,
updateGroundSurface,
} from './navaraGroundSurface.js';
const KEY = 'gevFirmsGround';
const registeredViews = new WeakSet();
const fragmentShader = /* glsl */ `
uniform sampler2D tAllDepth;
uniform sampler2D tSurfaceDepth;
uniform sampler2D tCells;
uniform sampler2D tGrid;
uniform sampler2D tCandidates;
uniform vec2 uCellSize;
uniform vec2 uCandidateSize;
uniform mat4 uInverseProjection;
uniform mat4 uInverseView;
uniform float uLogDepthFC;
vec4 atSlot(sampler2D image,vec2 size,float slot) {
return texture2D(image,(vec2(mod(slot,size.x),floor(slot/size.x))+0.5)/size);
}
vec3 toDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 toLinear(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
vec3 pixelPlane(float distance,vec3 normal,vec3 dx,vec3 dy) {
return vec3(distance,dot(normal,dx),dot(normal,dy));
}
float rectangleCoverage(vec3 west,vec3 east,vec3 south,vec3 north) {
vec3 edges[4];
edges[0]=west;edges[1]=east;edges[2]=south;edges[3]=north;
float coverage=1.0;
int partial=0;
for(int i=0;i<4;i++) {
float width=abs(edges[i].y)+abs(edges[i].z);
coverage=min(coverage,clamp(edges[i].x/max(width,0.001)+0.5,0.0,1.0));
if(coverage<=0.0) return 0.0;
if(edges[i].x<=width*0.5) partial++;
}
if(partial<2) return coverage;
// Intersect the SAME pixel square with all sides. Minimum soft-edge
// coverage creates long miter halos at acute/polar rectangle corners.
vec2 polygon[8],clipped[8];
polygon[0]=vec2(-0.5,-0.5);polygon[1]=vec2(0.5,-0.5);
polygon[2]=vec2(0.5,0.5);polygon[3]=vec2(-0.5,0.5);
int count=4;
for(int side=0;side<4;side++) {
int nextCount=0;
vec2 previous=polygon[count-1];
float before=edges[side].x+dot(edges[side].yz,previous);
for(int i=0;i<8;i++) {
if(i>=count) break;
vec2 current=polygon[i];
float after=edges[side].x+dot(edges[side].yz,current);
if((before>0.0&&after<0.0)||(before<0.0&&after>0.0))
clipped[nextCount++]=mix(previous,current,before/(before-after));
if(after>=0.0) clipped[nextCount++]=current;
previous=current;before=after;
}
count=nextCount;
if(count<3) return 0.0;
for(int i=0;i<8;i++) { if(i>=count) break;polygon[i]=clipped[i]; }
}
float area=0.0;
vec2 previous=polygon[count-1];
for(int i=0;i<8;i++) {
if(i>=count) break;
area+=previous.x*polygon[i].y-previous.y*polygon[i].x;
previous=polygon[i];
}
return clamp(abs(area)*0.5,0.0,1.0);
}
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
outputColor=inputColor;
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
vec3 point=ray.xyz*(distance/-ray.z);
// Evaluate derivatives before divergent candidate branches, as in quakes.
vec3 world=(uInverseView*vec4(point,1.0)).xyz;
vec3 dx=(uInverseView*vec4(dFdx(point),0.0)).xyz;
vec3 dy=(uInverseView*vec4(dFdy(point),0.0)).xyz;
// A quad crossing the sky has no surface derivative. Keep exact halfspaces
// there; otherwise its far-plane lane spreads cell AA along the globe limb.
float onSurface=1.0-step(1.0,depth);
float derivativeMask=1.0-step(0.5,abs(dFdx(onSurface))+abs(dFdy(onSurface)));
dx*=derivativeMask;
dy*=derivativeMask;
if(depth>=1.0) return;
vec2 tile=clamp(floor(uv*vec2(${GRID_COLS}.0,${GRID_ROWS}.0)),vec2(0.0),vec2(${GRID_COLS - 1}.0,${GRID_ROWS - 1}.0));
vec2 range=texture2D(tGrid,(tile+0.5)/vec2(${GRID_COLS}.0,${GRID_ROWS}.0)).xy;
if(range.y<0.5) return;
float surface=texture2D(tSurfaceDepth,uv).r;
if(depth<surface-2e-7) return;
float rho=length(world.xy);
vec2 radial=world.xy/max(rho,0.001);
vec3 color=toDisplay(inputColor.rgb);
for(int k=0;k<int(range.y);k++) {
float id=atSlot(tCandidates,uCandidateSize,range.x+float(k)).x;
vec4 longitude=atSlot(tCells,uCellSize,id*4.0);
vec3 south=atSlot(tCells,uCellSize,id*4.0+1.0).xyz;
vec3 north=atSlot(tCells,uCellSize,id*4.0+2.0).xyz;
vec3 westNormal=vec3(-longitude.x,longitude.y,0.0);
vec3 eastNormal=vec3(longitude.z,-longitude.w,0.0);
float coverage=rectangleCoverage(
pixelPlane(dot(westNormal,world),westNormal,dx,dy),
pixelPlane(dot(eastNormal,world),eastNormal,dx,dy),
pixelPlane(world.z*south.y-rho*south.x+south.z,vec3(-south.x*radial,south.y),dx,dy),
pixelPlane(rho*north.x-world.z*north.y-north.z,vec3(north.x*radial,-north.y),dx,dy));
if(coverage<=0.0) continue;
vec4 fill=atSlot(tCells,uCellSize,id*4.0+3.0);
color=mix(color,fill.rgb,fill.a*coverage);
}
outputColor=vec4(toLinear(color),inputColor.a);
}
`;
function buildState(cells) {
const frames = cells.map(rectangleFrame),
packed = createGroundStorage(cells.length * 4);
const grid = createGroundGrid(cells.length),
gridData = new Float32Array(GRID_COLS * GRID_ROWS * 4);
const gridTexture = createGroundTexture(gridData, GRID_COLS, GRID_ROWS);
let candidates = createGroundStorage(1);
const scratch = new Float64Array(12),
bounds = new Float64Array(4);
const lastView = new Float64Array(16).fill(NaN),
lastProjection = new Float64Array(16).fill(NaN);
let lastWidth = 0,
lastHeight = 0;
frames.forEach((frame, index) => {
const offset = index * 16,
color = cells[index].color;
packed.data.set(frame.bounds.longitude, offset);
packed.data.set(frame.bounds.south, offset + 4);
packed.data.set(frame.bounds.north, offset + 8);
packed.data.set(
[color.red, color.green, color.blue, color.alpha],
offset + 12,
);
});
return {
count: cells.length,
packed,
gridTexture,
get candidates() {
return candidates;
},
update(camera, context) {
const input = context.getInputBuffer(),
view = camera.matrixWorldInverse.elements,
projection = camera.projectionMatrix.elements;
let changed = input.width !== lastWidth || input.height !== lastHeight;
for (let i = 0; i < 16 && !changed; i++)
changed =
view[i] !== lastView[i] || projection[i] !== lastProjection[i];
if (!changed) return;
lastView.set(view);
lastProjection.set(projection);
lastWidth = input.width;
lastHeight = input.height;
frames.forEach((frame, index) => {
viewFrame(frame, camera.matrixWorldInverse, scratch);
groundClipBounds(frame, scratch, camera.projectionMatrix, bounds);
if (bounds[0] <= 1) {
bounds[0] -= 2 / Math.max(1, input.width);
bounds[2] += 2 / Math.max(1, input.width);
bounds[1] -= 2 / Math.max(1, input.height);
bounds[3] += 2 / Math.max(1, input.height);
}
grid.bounds.set(bounds, index * 4);
});
const total = grid.pack();
if (candidates.data.length / 4 < total) {
candidates.texture.dispose();
candidates = createGroundStorage(
Math.max(total, candidates.data.length / 2),
);
}
for (let i = 0; i < total; i++) candidates.data[i * 4] = grid.indices[i];
for (let i = 0; i < grid.counts.length; i++) {
gridData[i * 4] = grid.offsets[i];
gridData[i * 4 + 1] = grid.counts[i];
}
gridTexture.needsUpdate = true;
candidates.texture.needsUpdate = true;
},
dispose() {
packed.texture.dispose();
gridTexture.dispose();
candidates.texture.dispose();
},
};
}
function groundEffectClass(EffectDesc, NavaraEffect) {
return class FirmsGroundEffectDesc extends EffectDesc {
static key = KEY;
static insertAfter = [
'gevEarthquakeGround',
'gevRecentImagery',
'toneMapping',
];
static insertBefore = [
'gevPreSharpenOverlay',
'gevSharpen',
'smaa',
'fxaa',
'gevOverlay',
'final',
];
constructor(view, context, config) {
super(view, context, config);
this.owner = config[KEY].owner;
}
createPass() {
this.effect = new Effect('GevFirmsGround', fragmentShader, {
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms(),
['tCells', new Uniform(null)],
['tGrid', new Uniform(null)],
['tCandidates', new Uniform(null)],
['uCellSize', new Uniform([1, 1])],
['uCandidateSize', new Uniform([1, 1])],
['uInverseView', new Uniform(new Matrix4())],
]),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
}
update() {
const state = this.owner.state,
camera = this.view.camera.raw;
if (!this.effect || !state || !camera) return;
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
state.update(camera, this.ctx);
const u = this.effect.uniforms;
u.get('tCells').value = state.packed.texture;
u.get('uCellSize').value[0] = state.packed.width;
u.get('uCellSize').value[1] = state.packed.height;
u.get('tGrid').value = state.gridTexture;
u.get('tCandidates').value = state.candidates.texture;
u.get('uCandidateSize').value[0] = state.candidates.width;
u.get('uCandidateSize').value[1] = state.candidates.height;
u.get('uInverseView').value.copy(camera.matrixWorld);
}
onDestroy() {
this.effect?.dispose();
super.onDestroy();
}
};
}
// The SDK allows one active effect per key. Compose enabled datasets in
// catalog order so both can be drawn without replacing one another.
const views = new WeakMap();
export function createGroundCellDisplay(engine, { view, EffectDesc, NavaraEffect }, order) {
if (!registeredViews.has(view)) {
view.registerEffect(KEY, groundEffectClass(EffectDesc, NavaraEffect));
registeredViews.add(view);
}
let pass = views.get(view);
if (!pass) {
pass = { inputs: new Set(), owner: { state: buildState([]) }, handle: null };
views.set(view, pass);
}
const input = { cells: [], visible: false, order };
pass.inputs.add(input);
function sync() {
const cells = [...pass.inputs].filter(item => item.visible)
.sort((a,b) => a.order - b.order).flatMap(item => item.cells);
const previous = pass.owner.state;
pass.owner.state = buildState(cells);
try {
if (!pass.handle && cells.length) {
pass.handle = view.addEffect({ [KEY]: { owner: pass.owner }, visible: true });
} else {
pass.handle?.update({ visible: cells.length > 0 });
}
} finally {
previous.dispose();
}
engine.render.request('ground-cells');
}
return {
replace(cells) {
input.cells = cells;
if (input.visible) sync();
},
setVisible(show) {
if (input.visible === show) return;
input.visible = show;
sync();
},
destroy() {
if (!pass.inputs.delete(input)) return;
if (pass.inputs.size) {
if (input.visible) sync();
} else {
pass.handle?.delete();
pass.owner.state.dispose();
views.delete(view);
engine.render.request('ground-cells-destroy');
}
},
};
}
@@ -8,7 +8,7 @@ import fs from 'node:fs/promises';
const declarations = [ const declarations = [
['gevRecentImagery', '../../recentImagery/render/navaraGround.js'], ['gevRecentImagery', '../../recentImagery/render/navaraGround.js'],
['gevEarthquakeGround', '../../earthquakes/render/navara.js'], ['gevEarthquakeGround', '../../earthquakes/render/navara.js'],
['gevFirmsGround', '../../firms/render/navara.js'], ['gevFirmsGround', './navaraGroundCells.js'],
['gevPreSharpenOverlay', '../../../globe/navara/overlay.js'], ['gevPreSharpenOverlay', '../../../globe/navara/overlay.js'],
]; ];
const specs = await Promise.all( const specs = await Promise.all(
+1 -1
View File
@@ -499,7 +499,7 @@ export function createGroundFloor({ terrain, signal }) {
return h != null ? h : null; return h != null ? h : null;
} }
/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */ /** Discard measurements from the outgoing scene while keeping the DEM cache. */
function clearMeshFloorCells() { function clearMeshFloorCells() {
_meshCells.clear(); _meshCells.clear();
} }