mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Draw FIRMS detections from a GPU icon set
This commit is contained in:
1 parent
00d1bee127
commit
f008120b4c
11 files changed
+318
-322
No files matched your search
@@ -1559,7 +1559,13 @@
|
|||||||
"src/layers/firms/queries.js",
|
"src/layers/firms/queries.js",
|
||||||
"src/layers/firms/rendering.js",
|
"src/layers/firms/rendering.js",
|
||||||
"src/data/entityRecords.js",
|
"src/data/entityRecords.js",
|
||||||
|
"src/data/iconSetPrimitive.js",
|
||||||
|
"src/data/iconSet.js",
|
||||||
|
"src/data/iconSegment.js",
|
||||||
|
"src/data/iconAtlas.js",
|
||||||
|
"src/data/aircraftRecession.js",
|
||||||
"src/data/billboardMirror.js",
|
"src/data/billboardMirror.js",
|
||||||
|
"src/data/motionModel.js",
|
||||||
"src/layers/firms/selection.js",
|
"src/layers/firms/selection.js",
|
||||||
"src/layers/firms/source.js",
|
"src/layers/firms/source.js",
|
||||||
"src/layers/firms/state.js",
|
"src/layers/firms/state.js",
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const helpers = createFirmsHelpers({
|
|||||||
});
|
});
|
||||||
export const mapAnalystRecord = helpers.mapAnalystRecord;
|
export const mapAnalystRecord = helpers.mapAnalystRecord;
|
||||||
export const fireCullPosition = helpers.fireCullPosition;
|
export const fireCullPosition = helpers.fireCullPosition;
|
||||||
export const applyHorizonCull = helpers.applyHorizonCull;
|
|
||||||
export const buildSelectedFireCard = helpers.buildSelectedFireCard;
|
export const buildSelectedFireCard = helpers.buildSelectedFireCard;
|
||||||
export const buildFireCard = helpers.buildFireCard;
|
export const buildFireCard = helpers.buildFireCard;
|
||||||
export const buildCellCard = helpers.buildCellCard;
|
export const buildCellCard = helpers.buildCellCard;
|
||||||
|
|||||||
+166
-130
@@ -1,11 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import { createFirmsHeatmapLayer, fireCullPosition } from './firmsHeatmap.js';
|
||||||
applyHorizonCull,
|
|
||||||
createFirmsHeatmapLayer,
|
|
||||||
fireCullPosition,
|
|
||||||
} from './firmsHeatmap.js';
|
|
||||||
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
|
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
|
||||||
import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID } from './firmsLabels.js';
|
import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID } from './firmsLabels.js';
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +11,8 @@ import {
|
|||||||
} from './groundFloor.js';
|
} from './groundFloor.js';
|
||||||
import { unregisterSpriteCollection } from './spriteOrder.js';
|
import { unregisterSpriteCollection } from './spriteOrder.js';
|
||||||
import { isOverlayPointVisible, normalizeOverlayEntry } from '../overlays/worldOverlay.js';
|
import { isOverlayPointVisible, normalizeOverlayEntry } from '../overlays/worldOverlay.js';
|
||||||
import { mirrorBillboard } from './billboardMirror.js';
|
import { createIconSet, ICON_CULL_LIFT } from './iconSet.js';
|
||||||
|
import { writeHoldSegment } from './iconSegment.js';
|
||||||
|
|
||||||
const AUSTIN = { lon: -97.7, lat: 30.2 };
|
const AUSTIN = { lon: -97.7, lat: 30.2 };
|
||||||
/** Antipode of Austin — as far behind the limb as a point on Earth can be. */
|
/** Antipode of Austin — as far behind the limb as a point on Earth can be. */
|
||||||
@@ -29,24 +26,26 @@ const REPRO_HEIGHT_M = 1_500_000;
|
|||||||
* at −22 m but visible at +5/+12 m. Measured against Cesium's own occluder.
|
* at −22 m but visible at +5/+12 m. Measured against Cesium's own occluder.
|
||||||
*/
|
*/
|
||||||
const LIMB_DLON = 41.87;
|
const LIMB_DLON = 41.87;
|
||||||
|
/** Farther east, where 11 m reads hidden and 12 m visible from the same camera. */
|
||||||
|
const LIFT_BAND_DLON = 41.9452;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Billboard stub that records every write to `show` so the tests can assert
|
* Whether the icon set's GPU pass draws `row` from `occluder`'s camera: the
|
||||||
* the pass does not dirty the collection's vertex buffer needlessly.
|
* shader's horizon test on the row's cull point (its CPU twin).
|
||||||
*/
|
*/
|
||||||
function billboard(position, show = true) {
|
function gpuShows(set, row, occluder) {
|
||||||
return mirrorBillboard({
|
const position = set.position(row, 0, new Cesium.Cartesian3());
|
||||||
position,
|
return set.shown(row) && occluder.isPointVisible(set.cullPoint(row, position));
|
||||||
_show: show,
|
|
||||||
writes: 0,
|
|
||||||
get show() { return this._show; },
|
|
||||||
set show(value) { this._show = value; this.writes += 1; },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cesium BillboardCollection-shaped stub. */
|
/** A fire-shaped row (shown, CULL_LIFT) held at `position`. */
|
||||||
function collection(items) {
|
function fireRow(set, id, position) {
|
||||||
return { length: items.length, get: (i) => items[i] };
|
const row = set.add(id, { layer: 'firms', id });
|
||||||
|
set.setFlag(row, ICON_CULL_LIFT, true);
|
||||||
|
set.setShown(row, true);
|
||||||
|
writeHoldSegment(set.segments, row, position, 0);
|
||||||
|
set.segmentWritten(row);
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Horizon occluder for a camera directly above Austin at `heightM`. */
|
/** Horizon occluder for a camera directly above Austin at `heightM`. */
|
||||||
@@ -58,92 +57,71 @@ function occluderOverAustin(heightM) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// The pure cull predicate
|
// The GPU horizon cull a fire row gets
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
test('applyHorizonCull: far-side fires are hidden, near-side fires stay visible', () => {
|
test('icon cull: far-side fires are hidden, near-side fires stay visible', () => {
|
||||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||||
const near = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0));
|
const set = createIconSet();
|
||||||
const nearby = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + 6, AUSTIN.lat + 4, 0));
|
const at = (p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, 0);
|
||||||
const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0));
|
const near = fireRow(set, 'near', at(AUSTIN));
|
||||||
const otherSide = billboard(Cesium.Cartesian3.fromDegrees(TOKYO.lon, TOKYO.lat, 0));
|
const nearby = fireRow(set, 'nearby', at({ lon: AUSTIN.lon + 6, lat: AUSTIN.lat + 4 }));
|
||||||
|
const far = fireRow(set, 'far', at(ANTIPODE));
|
||||||
|
const otherSide = fireRow(set, 'tokyo', at(TOKYO));
|
||||||
|
|
||||||
const visible = applyHorizonCull(collection([near, nearby, far, otherSide]), occluder);
|
assert.equal(gpuShows(set, near, occluder), true, 'the fire under the camera renders');
|
||||||
|
assert.equal(gpuShows(set, nearby, occluder), true, 'a fire well inside the horizon renders');
|
||||||
assert.equal(near.show, true, 'the fire under the camera renders');
|
assert.equal(gpuShows(set, far, occluder), false, 'the antipodal fire must not shine through the planet');
|
||||||
assert.equal(nearby.show, true, 'a fire well inside the horizon renders');
|
assert.equal(gpuShows(set, otherSide, occluder), false, 'Tokyo is beyond the limb from Austin at 1.5 Mm');
|
||||||
assert.equal(far.show, false, 'the antipodal fire must not shine through the planet');
|
|
||||||
assert.equal(otherSide.show, false, 'Tokyo is beyond the limb from Austin at 1.5 Mm');
|
|
||||||
assert.equal(visible, 2, 'returns the surviving count');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('applyHorizonCull: raising the camera reveals fires the low camera hid', () => {
|
test('icon cull: raising the camera reveals fires the low camera hid', () => {
|
||||||
const paris = () => billboard(Cesium.Cartesian3.fromDegrees(2.35, 48.86, 0));
|
const set = createIconSet();
|
||||||
|
const paris = fireRow(set, 'paris', Cesium.Cartesian3.fromDegrees(2.35, 48.86, 0));
|
||||||
const low = paris();
|
assert.equal(gpuShows(set, paris, occluderOverAustin(REPRO_HEIGHT_M)), false);
|
||||||
applyHorizonCull(collection([low]), occluderOverAustin(REPRO_HEIGHT_M));
|
assert.equal(gpuShows(set, paris, occluderOverAustin(35_000_000)), true, 'in view from geostationary height');
|
||||||
assert.equal(low.show, false, 'Paris is over the horizon from a 1.5 Mm Austin camera');
|
|
||||||
|
|
||||||
const high = paris();
|
|
||||||
applyHorizonCull(collection([high]), occluderOverAustin(35_000_000));
|
|
||||||
assert.equal(high.show, true, 'from geostationary height the same fire is in view');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('applyHorizonCull: a hidden fire is restored when it swings back into view', () => {
|
test('icon cull: a sub-ellipsoid anchor is judged lifted, a far-side one still culls', () => {
|
||||||
const revealed = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), false);
|
|
||||||
applyHorizonCull(collection([revealed]), occluderOverAustin(REPRO_HEIGHT_M));
|
|
||||||
assert.equal(revealed.show, true, 'the cull un-hides as well as hides');
|
|
||||||
assert.equal(revealed.writes, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('applyHorizonCull: only flipped billboards are written (vertex-buffer churn)', () => {
|
|
||||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||||
const stays = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), true);
|
const set = createIconSet();
|
||||||
const flips = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0), true);
|
const sunken = fireRow(set, 'sunken', Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22));
|
||||||
|
assert.equal(gpuShows(set, sunken, occluder), true, 'the lifted cull point keeps the near-limb fire on screen');
|
||||||
|
set.setFlag(sunken, ICON_CULL_LIFT, false);
|
||||||
|
assert.equal(gpuShows(set, sunken, occluder), false, 'baseline: the raw anchor false-hides at the limb');
|
||||||
|
|
||||||
applyHorizonCull(collection([stays, flips]), occluder);
|
const far = fireRow(set, 'far', Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22));
|
||||||
applyHorizonCull(collection([stays, flips]), occluder);
|
assert.equal(gpuShows(set, far, occluder), false, '12 m of lift must not defeat the actual horizon cull');
|
||||||
|
|
||||||
assert.equal(stays.writes, 0, 'an already-visible billboard is never re-assigned');
|
|
||||||
assert.equal(flips.writes, 1, 'a hidden billboard is written once, not once per pass');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('applyHorizonCull: missing collection/occluder/holes are no-ops, not throws', () => {
|
test('fireCullPosition: a card is judged where its sprite is', () => {
|
||||||
assert.equal(applyHorizonCull(null, occluderOverAustin(REPRO_HEIGHT_M)), 0);
|
|
||||||
assert.equal(applyHorizonCull({ length: 3 }, occluderOverAustin(REPRO_HEIGHT_M)), 0, 'non-collection shape');
|
|
||||||
const item = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0));
|
|
||||||
assert.equal(applyHorizonCull(collection([item]), null), 0, 'no occluder → leave the layer alone');
|
|
||||||
assert.equal(applyHorizonCull(collection([item]), {}), 0, 'occluder without isPointVisible');
|
|
||||||
assert.equal(item.writes, 0, 'a skipped pass must not touch show');
|
|
||||||
assert.equal(
|
|
||||||
applyHorizonCull(collection([undefined, item]), occluderOverAustin(REPRO_HEIGHT_M)),
|
|
||||||
1,
|
|
||||||
'sparse slots are skipped, surviving neighbours still counted',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sub-ellipsoid anchors: datum-correct render point, lifted CULL point
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
test('applyHorizonCull: an index-aligned cull position overrides the render anchor', () => {
|
|
||||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||||
const sunken = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22));
|
const set = createIconSet();
|
||||||
const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12);
|
setMeshFloorPreferred(true);
|
||||||
|
try {
|
||||||
applyHorizonCull(collection([sunken]), occluder);
|
// Sunk, an 11 m anchor (floor 6 + lift), and well above: each card must
|
||||||
assert.equal(sunken.show, false, 'baseline: the raw sub-ellipsoid anchor false-hides at the limb');
|
// agree with the GPU test on its own row, at limb points where they can.
|
||||||
|
for (const [i, floor] of [-27, 6, 900].entries()) {
|
||||||
applyHorizonCull(collection([sunken]), occluder, [lifted]);
|
_clearMeshFloorCellsForTest(); // a reported cell latches its first floor
|
||||||
assert.equal(sunken.show, true, 'the lifted cull point keeps the near-limb fire on screen');
|
for (const dLon of [LIMB_DLON, LIFT_BAND_DLON]) {
|
||||||
});
|
const lat = AUSTIN.lat;
|
||||||
|
const lon = AUSTIN.lon + dLon;
|
||||||
test('applyHorizonCull: a lifted cull point does NOT resurrect a genuinely far-side fire', () => {
|
reportMeshFloorCell(lat, lon, floor);
|
||||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
const fire = { index: i, lat, lon, frp: 10, position: null };
|
||||||
const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22));
|
const cull = fireCullPosition(fire);
|
||||||
const lifted = Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12);
|
assert.equal(fire.positionHeight, floor + FIRE_ANCHOR_LIFT_M);
|
||||||
applyHorizonCull(collection([far]), occluder, [lifted]);
|
const row = fireRow(set, `${floor}:${dLon}`, fire.position);
|
||||||
assert.equal(far.show, false, '12 m of lift must not defeat the actual horizon cull');
|
assert.equal(
|
||||||
|
occluder.isPointVisible(cull),
|
||||||
|
gpuShows(set, row, occluder),
|
||||||
|
`floor ${floor} m at +${dLon}°`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_clearMeshFloorCellsForTest();
|
||||||
|
setMeshFloorPreferred(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fireCullPosition: negative-geoid anchor renders at the datum but culls lifted', () => {
|
test('fireCullPosition: negative-geoid anchor renders at the datum but culls lifted', () => {
|
||||||
@@ -305,17 +283,21 @@ class MockEvent {
|
|||||||
emit() { for (const listener of [...this.listeners]) listener(); }
|
emit() { for (const listener of [...this.listeners]) listener(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Canvas stub good enough for the pre-baked glow sprites. */
|
/**
|
||||||
|
* Canvas stub good enough for the pre-baked glow sprites. Its data URL names
|
||||||
|
* the gradient's colours, so each colour stop gets its own atlas slot.
|
||||||
|
*/
|
||||||
function canvasStub() {
|
function canvasStub() {
|
||||||
|
const stops = [];
|
||||||
return {
|
return {
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
getContext: () => ({
|
getContext: () => ({
|
||||||
createRadialGradient: () => ({ addColorStop() {} }),
|
createRadialGradient: () => ({ addColorStop: (offset, color) => stops.push(color) }),
|
||||||
fillStyle: null,
|
fillStyle: null,
|
||||||
fillRect() {},
|
fillRect() {},
|
||||||
}),
|
}),
|
||||||
toDataURL: () => 'data:image/png;base64,iVBORw0KGgo=',
|
toDataURL: () => `data:image/png;base64,iVBORw0KGgo=#${stops.join('|')}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,9 +318,8 @@ function rawFire(lat, lon, frp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boots the REAL layer against a headless viewer stub with a REAL
|
* Boots the REAL layer against a headless viewer stub; what the GPU would draw
|
||||||
* Cesium.BillboardCollection, so the production hooks (not just the pure
|
* is read off the layer's real icon set (`gpuShows`) from the stub camera.
|
||||||
* predicate) are what the assertions exercise.
|
|
||||||
*/
|
*/
|
||||||
function createHarness(rawFires) {
|
function createHarness(rawFires) {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -384,7 +365,13 @@ function createHarness(rawFires) {
|
|||||||
preRender,
|
preRender,
|
||||||
primitives: {
|
primitives: {
|
||||||
add: (value) => { primitives.push(value); return value; },
|
add: (value) => { primitives.push(value); return value; },
|
||||||
remove: () => {},
|
remove: (value) => {
|
||||||
|
const at = primitives.indexOf(value);
|
||||||
|
if (at < 0) return false;
|
||||||
|
primitives.splice(at, 1);
|
||||||
|
value.destroy();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
contains: () => false,
|
contains: () => false,
|
||||||
},
|
},
|
||||||
frameState: { mode: Cesium.SceneMode.SCENE3D, mapProjection: new Cesium.GeographicProjection() },
|
frameState: { mode: Cesium.SceneMode.SCENE3D, mapProjection: new Cesium.GeographicProjection() },
|
||||||
@@ -408,15 +395,19 @@ function createHarness(rawFires) {
|
|||||||
viewer,
|
viewer,
|
||||||
preRender,
|
preRender,
|
||||||
moveEnd,
|
moveEnd,
|
||||||
/** The real BillboardCollection the layer created. */
|
/** The icon set the layer draws its detections from. */
|
||||||
billboards: () => primitives[0],
|
icons: () => primitives[0].set,
|
||||||
|
/** Primitives currently in the stub scene. */
|
||||||
|
primitives,
|
||||||
/** Latest entry list published to the overlay host. */
|
/** Latest entry list published to the overlay host. */
|
||||||
latestEntries: () => entryCalls[entryCalls.length - 1] || [],
|
latestEntries: () => entryCalls[entryCalls.length - 1] || [],
|
||||||
/** `show` flags keyed by billboard pick id. */
|
/** Whether the GPU draws each row from the current camera, by pick id. */
|
||||||
showById() {
|
showById() {
|
||||||
const bb = primitives[0];
|
const set = primitives[0].set;
|
||||||
|
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, viewer.camera.positionWC);
|
||||||
const out = new Map();
|
const out = new Map();
|
||||||
for (let i = 0; i < bb.length; i += 1) out.set(bb.get(i).id, bb.get(i).show);
|
for (let row = 0; row < set.count; row += 1)
|
||||||
|
out.set(set.idAt(row), set.show && gpuShows(set, row, occluder));
|
||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
moveCameraTo(lon, lat, height = REPRO_HEIGHT_M) {
|
moveCameraTo(lon, lat, height = REPRO_HEIGHT_M) {
|
||||||
@@ -434,7 +425,7 @@ function createHarness(rawFires) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('layer: a detection rebuild culls its own freshly-added sprites', async () => {
|
test('layer: far-side detections are culled from the first frame', async () => {
|
||||||
const harness = createHarness([
|
const harness = createHarness([
|
||||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||||
rawFire(ANTIPODE.lat, ANTIPODE.lon, 900),
|
rawFire(ANTIPODE.lat, ANTIPODE.lon, 900),
|
||||||
@@ -454,7 +445,7 @@ test('layer: a detection rebuild culls its own freshly-added sprites', async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('layer: camera moveEnd re-culls without a rebuild', async () => {
|
test('layer: the GPU cull follows the camera with no CPU show pass', async () => {
|
||||||
const harness = createHarness([
|
const harness = createHarness([
|
||||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||||
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||||
@@ -467,6 +458,9 @@ test('layer: camera moveEnd re-culls without a rebuild', async () => {
|
|||||||
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
||||||
harness.moveEnd.emit();
|
harness.moveEnd.emit();
|
||||||
|
|
||||||
|
const set = harness.icons();
|
||||||
|
for (let row = 0; row < set.count; row += 1)
|
||||||
|
assert.equal(set.shown(row), true, `${set.idAt(row)} keeps its row shown; the GPU decides`);
|
||||||
const show = harness.showById();
|
const show = harness.showById();
|
||||||
assert.equal(show.get('firms-1'), true, 'Tokyo becomes visible once the camera is over it');
|
assert.equal(show.get('firms-1'), true, 'Tokyo becomes visible once the camera is over it');
|
||||||
assert.equal(show.get('firms-0'), false, 'Austin drops behind the limb');
|
assert.equal(show.get('firms-0'), false, 'Austin drops behind the limb');
|
||||||
@@ -475,32 +469,33 @@ test('layer: camera moveEnd re-culls without a rebuild', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('layer: the throttled preRender watcher re-culls on a camera move', async () => {
|
test('layer: a refresh keeps rows and restyles one whose index now holds another fire', async () => {
|
||||||
const harness = createHarness([
|
const fires = [rawFire(AUSTIN.lat, AUSTIN.lon, 400), rawFire(TOKYO.lat, TOKYO.lon, 800)];
|
||||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
const harness = createHarness(fires);
|
||||||
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
|
||||||
]);
|
|
||||||
try {
|
try {
|
||||||
harness.layer.init(harness.viewer);
|
harness.layer.init(harness.viewer);
|
||||||
await harness.layer.enable(harness.viewer);
|
await harness.layer.enable(harness.viewer);
|
||||||
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
const set = harness.icons();
|
||||||
// The watcher is throttled to LOD_CHECK_MS (650 ms) against
|
const austin = set.row('firms-0');
|
||||||
// performance.now(), whose value at this point depends on process
|
const handle = set.handleAt(austin);
|
||||||
// start-up — so emit, wait out the full throttle window, and emit again.
|
const before = set.width(austin);
|
||||||
// Whichever tick passes the gate, one of them must run the pass.
|
const beforeImage = set.imageFar(austin);
|
||||||
harness.preRender.emit();
|
|
||||||
await new Promise((resolve) => { setTimeout(resolve, 700); });
|
|
||||||
harness.preRender.emit();
|
|
||||||
|
|
||||||
const show = harness.showById();
|
fires[0] = rawFire(AUSTIN.lat + 0.5, AUSTIN.lon, 1);
|
||||||
assert.equal(show.get('firms-1'), true, 'Tokyo revealed by the preRender pass');
|
await harness.layer.update();
|
||||||
assert.equal(show.get('firms-0'), false, 'Austin culled by the preRender pass');
|
|
||||||
|
const row = set.row('firms-0');
|
||||||
|
assert.equal(set.handleAt(row), handle, 'the row (and its pick id) survives the rebuild');
|
||||||
|
assert.ok(set.width(row) < before, `restyled for the weaker fire: ${set.width(row)} < ${before}`);
|
||||||
|
assert.notEqual(set.imageFar(row), beforeImage, 'a red glow becomes a yellow one');
|
||||||
|
const carto = Cesium.Cartographic.fromCartesian(set.position(row, 0, new Cesium.Cartesian3()));
|
||||||
|
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - (AUSTIN.lat + 0.5)) < 1e-6, 'moved to the new fire');
|
||||||
} finally {
|
} finally {
|
||||||
harness.cleanup();
|
harness.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('layer: re-enabling after the camera moved does not expose stale far-side sprites', async () => {
|
test('layer: re-enabling after the camera moved shows the icons, culled from the new camera', async () => {
|
||||||
const harness = createHarness([
|
const harness = createHarness([
|
||||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||||
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||||
@@ -517,10 +512,10 @@ test('layer: re-enabling after the camera moved does not expose stale far-side s
|
|||||||
await harness.layer.enable(harness.viewer);
|
await harness.layer.enable(harness.viewer);
|
||||||
|
|
||||||
// Assert with NO camera event fired and NO refetch (fires are already
|
// Assert with NO camera event fired and NO refetch (fires are already
|
||||||
// loaded, so enable() does not reload): the enable path itself must have
|
// loaded, so enable() does not reload): the GPU culls from the current
|
||||||
// re-culled before the collection went visible.
|
// camera on the first frame.
|
||||||
const show = harness.showById();
|
const show = harness.showById();
|
||||||
assert.equal(harness.billboards().show, true, 'the collection is visible again');
|
assert.equal(harness.icons().show, true, 'the icon set is visible again');
|
||||||
assert.equal(show.get('firms-1'), true, 'the Tokyo fire is revealed at enable time');
|
assert.equal(show.get('firms-1'), true, 'the Tokyo fire is revealed at enable time');
|
||||||
assert.equal(show.get('firms-0'), false, 'the Austin fire is NOT left shining through the planet');
|
assert.equal(show.get('firms-0'), false, 'the Austin fire is NOT left shining through the planet');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -528,6 +523,47 @@ test('layer: re-enabling after the camera moved does not expose stale far-side s
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('layer: a refresh drops rows whose fires left, and an empty one drops them all', async () => {
|
||||||
|
const fires = [
|
||||||
|
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||||
|
rawFire(AUSTIN.lat + 1, AUSTIN.lon, 300),
|
||||||
|
rawFire(AUSTIN.lat + 2, AUSTIN.lon, 200),
|
||||||
|
];
|
||||||
|
const harness = createHarness(fires);
|
||||||
|
try {
|
||||||
|
harness.layer.init(harness.viewer);
|
||||||
|
await harness.layer.enable(harness.viewer);
|
||||||
|
const set = harness.icons();
|
||||||
|
assert.equal(set.count, 3);
|
||||||
|
|
||||||
|
// Index 0 still holds a fire; 1 and 2 are gone, so their rows swap out.
|
||||||
|
fires.splice(1, 2);
|
||||||
|
await harness.layer.update();
|
||||||
|
assert.deepEqual(
|
||||||
|
Array.from({ length: set.count }, (_, row) => set.idAt(row)),
|
||||||
|
['firms-0'],
|
||||||
|
);
|
||||||
|
assert.equal(set.metaAt(set.row('firms-0')).fire.lat, AUSTIN.lat, 'the kept row holds its own fire');
|
||||||
|
|
||||||
|
fires.length = 0;
|
||||||
|
await harness.layer.update();
|
||||||
|
assert.equal(set.count, 0, 'an empty refresh leaves no sprite behind');
|
||||||
|
assert.equal(harness.latestEntries().length, 0, 'nor any card');
|
||||||
|
} finally {
|
||||||
|
harness.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('layer: destroy takes the icons out of the scene', async () => {
|
||||||
|
const harness = createHarness([rawFire(AUSTIN.lat, AUSTIN.lon, 120)]);
|
||||||
|
harness.layer.init(harness.viewer);
|
||||||
|
await harness.layer.enable(harness.viewer);
|
||||||
|
const primitive = harness.primitives[0];
|
||||||
|
harness.cleanup();
|
||||||
|
assert.equal(harness.primitives.includes(primitive), false);
|
||||||
|
assert.equal(primitive.isDestroyed(), true);
|
||||||
|
});
|
||||||
|
|
||||||
test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its datum', async () => {
|
test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its datum', async () => {
|
||||||
const lat = AUSTIN.lat;
|
const lat = AUSTIN.lat;
|
||||||
const lon = AUSTIN.lon + LIMB_DLON;
|
const lon = AUSTIN.lon + LIMB_DLON;
|
||||||
@@ -543,14 +579,14 @@ test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its dat
|
|||||||
harness.layer.init(harness.viewer);
|
harness.layer.init(harness.viewer);
|
||||||
await harness.layer.enable(harness.viewer);
|
await harness.layer.enable(harness.viewer);
|
||||||
|
|
||||||
const bb = harness.billboards();
|
const set = harness.icons();
|
||||||
const sunken = bb.get(1);
|
const sunken = set.row('firms-1');
|
||||||
const carto = Cesium.Cartographic.fromCartesian(sunken.position);
|
const carto = Cesium.Cartographic.fromCartesian(set.position(sunken, 0, new Cesium.Cartesian3()));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
Math.abs(carto.height - (-27 + FIRE_ANCHOR_LIFT_M)) < 0.5,
|
Math.abs(carto.height - (-27 + FIRE_ANCHOR_LIFT_M)) < 0.5,
|
||||||
`the sprite still RENDERS at the datum-correct anchor, got ${carto.height}`,
|
`the sprite still RENDERS at the datum-correct anchor, got ${carto.height}`,
|
||||||
);
|
);
|
||||||
assert.equal(sunken.show, true, 'the near-limb fire stays on screen (lifted cull anchor)');
|
assert.equal(harness.showById().get('firms-1'), true, 'the near-limb fire stays on screen (lifted cull anchor)');
|
||||||
assert.equal(harness.showById().get('firms-0'), true, 'the camera-nadir fire is unaffected');
|
assert.equal(harness.showById().get('firms-0'), true, 'the camera-nadir fire is unaffected');
|
||||||
} finally {
|
} finally {
|
||||||
harness.cleanup();
|
harness.cleanup();
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r
|
|||||||
assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/);
|
assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
createFirmsRendering.toString(),
|
createFirmsRendering.toString(),
|
||||||
/registerSpriteCollection\('firms', layerState\._billboards\);\s*restoreSpriteOrder\(layerState\._viewer\);/,
|
/registerSpriteCollection\('firms', layerState\._iconPrimitive\);\s*restoreSpriteOrder\(layerState\._viewer\);/,
|
||||||
'lazy FIRMS registration must restore order immediately',
|
'lazy FIRMS registration must restore order immediately',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -28,17 +28,9 @@ export function createLifecycle({
|
|||||||
layerState._viewer = viewer;
|
layerState._viewer = viewer;
|
||||||
if (!layerState._dataSource) this.init(viewer);
|
if (!layerState._dataSource) this.init(viewer);
|
||||||
if (layerState._dataSource) layerState._dataSource.show = true;
|
if (layerState._dataSource) layerState._dataSource.show = true;
|
||||||
if (layerState._billboards) {
|
// The GPU culls the far side each frame, so retained rows are right
|
||||||
// The camera can have moved anywhere while the layer was off: moveEnd
|
// for wherever the camera went while the layer was off.
|
||||||
// was not being listened to, and the preRender watcher is inert while
|
if (layerState._icons) layerState._icons.show = true;
|
||||||
// disabled AND while the (untimed) refetch below is in flight. So the
|
|
||||||
// retained per-sprite show flags describe the OLD viewpoint. Re-cull
|
|
||||||
// BEFORE the collection becomes visible — otherwise re-enabling at a
|
|
||||||
// new location flashes far-side fires through the planet (and keeps
|
|
||||||
// near-side ones hidden) until the fetch resolves.
|
|
||||||
components.rendering.refreshHorizonCulling();
|
|
||||||
layerState._billboards.show = true;
|
|
||||||
}
|
|
||||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
|
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
|
||||||
components.viewport.installLodWatcher();
|
components.viewport.installLodWatcher();
|
||||||
components.viewport.installMoveEndWatcher();
|
components.viewport.installMoveEndWatcher();
|
||||||
@@ -63,7 +55,7 @@ export function createLifecycle({
|
|||||||
layerState._enabled = false;
|
layerState._enabled = false;
|
||||||
components.selection.clearFireSelection();
|
components.selection.clearFireSelection();
|
||||||
if (layerState._dataSource) layerState._dataSource.show = false;
|
if (layerState._dataSource) layerState._dataSource.show = false;
|
||||||
if (layerState._billboards) layerState._billboards.show = false;
|
if (layerState._icons) layerState._icons.show = false;
|
||||||
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
|
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
|
||||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
|
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
|
||||||
clearSelectedEntityContextForLayer(id);
|
clearSelectedEntityContextForLayer(id);
|
||||||
@@ -110,7 +102,6 @@ export function createLifecycle({
|
|||||||
layerState._labelLodDistance = 0;
|
layerState._labelLodDistance = 0;
|
||||||
layerState._pickIndexById.clear();
|
layerState._pickIndexById.clear();
|
||||||
layerState._fireByCardId.clear();
|
layerState._fireByCardId.clear();
|
||||||
layerState._cullPositions.length = 0;
|
|
||||||
layerState._camSnapValid = false;
|
layerState._camSnapValid = false;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+18
-58
@@ -9,11 +9,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
VIEW_PADDING,
|
VIEW_PADDING,
|
||||||
DETECTION_COLOR_STOPS,
|
DETECTION_COLOR_STOPS,
|
||||||
CULL_LIFT_THRESHOLD_M,
|
GLOW_SPRITE_PX,
|
||||||
CULL_LIFT_M,
|
|
||||||
LABEL_MIN_SEP_PX,
|
LABEL_MIN_SEP_PX,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
|
import { ICON_CULL_HEIGHT_M } from '../../data/iconSet.js';
|
||||||
|
|
||||||
export function createModel({
|
export function createModel({
|
||||||
layerState,
|
layerState,
|
||||||
@@ -161,18 +160,17 @@ export function createModel({
|
|||||||
/**
|
/**
|
||||||
* Build (and cache) a radial-glow sprite: hot near-white center fading
|
* Build (and cache) a radial-glow sprite: hot near-white center fading
|
||||||
* through the stop color to a transparent edge. The glow lives in the
|
* through the stop color to a transparent edge. The glow lives in the
|
||||||
* sprite itself because global bloom defaults OFF.
|
* sprite itself because global bloom defaults OFF. One raster per color
|
||||||
|
* (GLOW_SPRITE_PX); each fire draws it at its own size.
|
||||||
* @param {{name: string, color: Cesium.Color}} stop - Color stop.
|
* @param {{name: string, color: Cesium.Color}} stop - Color stop.
|
||||||
* @param {number} corePx - Bucketed core size in pixels.
|
|
||||||
* @returns {string} PNG data URL.
|
* @returns {string} PNG data URL.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function glowSprite(stop, corePx) {
|
function glowSprite(stop) {
|
||||||
const key = `${stop.name}:${corePx}`;
|
const cached = layerState.glowSpriteCache.get(stop.name);
|
||||||
const cached = layerState.glowSpriteCache.get(key);
|
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const dimension = corePx * 2;
|
const dimension = GLOW_SPRITE_PX;
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = dimension;
|
canvas.width = dimension;
|
||||||
canvas.height = dimension;
|
canvas.height = dimension;
|
||||||
@@ -200,7 +198,7 @@ export function createModel({
|
|||||||
context.fillRect(0, 0, dimension, dimension);
|
context.fillRect(0, 0, dimension, dimension);
|
||||||
|
|
||||||
const dataUrl = canvas.toDataURL('image/png');
|
const dataUrl = canvas.toDataURL('image/png');
|
||||||
layerState.glowSpriteCache.set(key, dataUrl);
|
layerState.glowSpriteCache.set(stop.name, dataUrl);
|
||||||
return dataUrl;
|
return dataUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,21 +229,25 @@ export function createModel({
|
|||||||
/**
|
/**
|
||||||
* Occlusion-test anchor for one detection. Returns the render position
|
* Occlusion-test anchor for one detection. Returns the render position
|
||||||
* outright for anchors comfortably above the ellipsoid (the common case) and
|
* outright for anchors comfortably above the ellipsoid (the common case) and
|
||||||
* a lazily-cached lifted point otherwise — see CULL_LIFT_THRESHOLD_M. This
|
* a lazily-cached lifted point otherwise, the point the icon set's GPU
|
||||||
* NEVER feeds rendering: the datum-correct anchor from {@link firePosition}
|
* horizon test judges a fire row at (ICON_CULL_LIFT), so a card shows exactly
|
||||||
* is what the sprite and the card are drawn at.
|
* when its sprite does. In negative-geoid coastal regions the floored anchor
|
||||||
|
* lands tens of metres BELOW the WGS84 ellipsoid, which the occluder judges
|
||||||
|
* beyond the horizon while true-surface neighbours stay visible. This NEVER
|
||||||
|
* feeds rendering: the datum-correct anchor from {@link firePosition} is what
|
||||||
|
* the sprite and the card are drawn at.
|
||||||
* @param {Object} fire - Detection record.
|
* @param {Object} fire - Detection record.
|
||||||
* @returns {Cesium.Cartesian3}
|
* @returns {Cesium.Cartesian3}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function fireCullPosition(fire) {
|
function fireCullPosition(fire) {
|
||||||
const position = firePosition(fire);
|
const position = firePosition(fire);
|
||||||
if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position;
|
if (fire.positionHeight >= ICON_CULL_HEIGHT_M) return position;
|
||||||
if (!fire.cullPosition) {
|
if (!fire.cullPosition) {
|
||||||
fire.cullPosition = Cesium.Cartesian3.fromDegrees(
|
fire.cullPosition = Cesium.Cartesian3.fromDegrees(
|
||||||
fire.lon,
|
fire.lon,
|
||||||
fire.lat,
|
fire.lat,
|
||||||
CULL_LIFT_M,
|
ICON_CULL_HEIGHT_M,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return fire.cullPosition;
|
return fire.cullPosition;
|
||||||
@@ -261,48 +263,7 @@ export function createModel({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function cellCullPosition(lon, lat) {
|
function cellCullPosition(lon, lat) {
|
||||||
return Cesium.Cartesian3.fromDegrees(lon, lat, CULL_LIFT_M);
|
return Cesium.Cartesian3.fromDegrees(lon, lat, ICON_CULL_HEIGHT_M);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Horizon-cull a billboard collection against an ellipsoid occluder.
|
|
||||||
*
|
|
||||||
* The Cesium globe is hidden in this app (Google 3D Tiles render the planet),
|
|
||||||
* so nothing writes far-side depth — and these sprites are additionally
|
|
||||||
* always-on-top (`disableDepthTestDistance: INFINITY`, so a detection is never
|
|
||||||
* swallowed by terrain it sits on). Without an explicit occluder pass, fires on
|
|
||||||
* the opposite side of the Earth shine through the planet at tilted mid-altitude
|
|
||||||
* views. Same pattern as the flights/CCTV layers' `EllipsoidalOccluder` passes.
|
|
||||||
*
|
|
||||||
* Pure and allocation-free: reads the billboard's mirrored position and show,
|
|
||||||
* and writes `show` only when it actually flips (assigning `show` dirties the
|
|
||||||
* collection's vertex buffer). Accepts any `{length, get(i)}` shape so it is unit-testable without
|
|
||||||
* a WebGL scene.
|
|
||||||
*
|
|
||||||
* @param {?{length: number, get: function(number): (Object|undefined)}} billboards
|
|
||||||
* Billboard collection (or collection-shaped stub).
|
|
||||||
* @param {?{isPointVisible: function(Object): boolean}} occluder Horizon occluder.
|
|
||||||
* @param {?Array<Cesium.Cartesian3>} [cullPositions=null] Index-aligned lifted
|
|
||||||
* occlusion-test anchors (see {@link fireCullPosition}); falls back to the
|
|
||||||
* billboard's own render position wherever an entry is absent.
|
|
||||||
* @returns {number} Count of billboards left visible (0 when nothing to test).
|
|
||||||
*/
|
|
||||||
|
|
||||||
function applyHorizonCull(billboards, occluder, cullPositions = null) {
|
|
||||||
if (!billboards || typeof billboards.get !== 'function') return 0;
|
|
||||||
if (typeof occluder?.isPointVisible !== 'function') return 0;
|
|
||||||
const total = Number(billboards.length) || 0;
|
|
||||||
let visibleCount = 0;
|
|
||||||
for (let i = 0; i < total; i += 1) {
|
|
||||||
const billboard = billboards.get(i);
|
|
||||||
if (!billboard) continue;
|
|
||||||
const pushed = billboardState(billboard);
|
|
||||||
const point = (cullPositions && cullPositions[i]) || pushed.position;
|
|
||||||
const visible = occluder.isPointVisible(point) === true;
|
|
||||||
if (pushed.show !== visible) setBillboard(billboard, 'show', visible);
|
|
||||||
if (visible) visibleCount += 1;
|
|
||||||
}
|
|
||||||
return visibleCount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -589,7 +550,6 @@ export function createModel({
|
|||||||
firePosition,
|
firePosition,
|
||||||
fireCullPosition,
|
fireCullPosition,
|
||||||
cellCullPosition,
|
cellCullPosition,
|
||||||
applyHorizonCull,
|
|
||||||
screenSeparated,
|
screenSeparated,
|
||||||
buildSelectedFireCard,
|
buildSelectedFireCard,
|
||||||
buildFireCard,
|
buildFireCard,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const REFRESH_INTERVAL_MS = 600_000;
|
|||||||
/**
|
/**
|
||||||
* LOD bands keyed by camera height. `cells` bands render aggregated
|
* LOD bands keyed by camera height. `cells` bands render aggregated
|
||||||
* ground-clamped heat rectangles; `detections` bands (< ~750km plus the
|
* ground-clamped heat rectangles; `detections` bands (< ~750km plus the
|
||||||
* local band) render individual fire detections as glow-sprite billboards.
|
* local band) render individual fire detections as glow-sprite icons.
|
||||||
* Labels in every band go through the shared screen-space greedy declutter
|
* Labels in every band go through the shared screen-space greedy declutter
|
||||||
* (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs.
|
* (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs.
|
||||||
*/
|
*/
|
||||||
@@ -73,20 +73,12 @@ export const LABEL_MIN_SEP_PX = 150;
|
|||||||
export const LABEL_VIEW_MARGIN_PX = 16;
|
export const LABEL_VIEW_MARGIN_PX = 16;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anchors below this height get a separate LIFTED point for occlusion tests
|
* Glow sprites are rasterised once per colour at the largest core size
|
||||||
* (never for rendering). Mirrors the flights layer's `cullPosition` idiom
|
* (frpPixelSize tops out at 28 px, a 56 px sprite) and drawn smaller per fire:
|
||||||
* (`renderAltitudeM < 10` → test at 12 m): `fireAnchorHeight` is ground floor
|
* the gradient stops are relative, so the glow keeps its shape at any size.
|
||||||
* + lift, and in negative-geoid coastal regions that lands a few tens of
|
|
||||||
* metres BELOW the WGS84 ellipsoid — EllipsoidalOccluder then judges such a
|
|
||||||
* point "beyond the horizon" while its true-surface neighbours stay visible,
|
|
||||||
* so near-limb fires would blink out for a datum reason.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const CULL_LIFT_THRESHOLD_M = 10;
|
export const GLOW_SPRITE_PX = 56;
|
||||||
|
|
||||||
/** Height of the lifted occlusion-test point (flights uses the same 12 m). */
|
|
||||||
|
|
||||||
export const CULL_LIFT_M = 12;
|
|
||||||
|
|
||||||
/** Color stops shared by cell heat fills and detection glow sprites. */
|
/** Color stops shared by cell heat fills and detection glow sprites. */
|
||||||
|
|
||||||
|
|||||||
+107
-73
@@ -1,8 +1,11 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
|
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
|
||||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||||
import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js';
|
import { LOD_LEVELS, CONTEXT_TOP_N, GLOW_SPRITE_PX } from './policy.js';
|
||||||
import { addBillboard } from '../../data/billboardMirror.js';
|
import { createIconSet, ICON_CULL_LIFT } from '../../data/iconSet.js';
|
||||||
|
import { writeHoldSegment } from '../../data/iconSegment.js';
|
||||||
|
import { IconSetPrimitive } from '../../data/iconSetPrimitive.js';
|
||||||
|
import { iconAtlas } from '../../data/iconAtlas.js';
|
||||||
|
|
||||||
export function createRendering({
|
export function createRendering({
|
||||||
layerState,
|
layerState,
|
||||||
@@ -20,18 +23,18 @@ export function createRendering({
|
|||||||
* when neither the (hysteresis-damped) LOD band nor the padded view
|
* when neither the (hysteresis-damped) LOD band nor the padded view
|
||||||
* rectangle changed meaningfully.
|
* rectangle changed meaningfully.
|
||||||
* @param {boolean} force - Rebuild even if LOD/view appear unchanged.
|
* @param {boolean} force - Rebuild even if LOD/view appear unchanged.
|
||||||
* @returns {boolean} True when a rebuild actually ran. Callers use this to
|
* @returns {boolean} True when a rebuild actually ran.
|
||||||
* avoid a second horizon walk in the same tick: a rebuild already culls
|
|
||||||
* its freshly-added sprites, an early-out does not.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function renderCurrentLod(force = false) {
|
function renderCurrentLod(force = false) {
|
||||||
if (
|
if (!layerState._dataSource || !layerState._viewer) return false;
|
||||||
!layerState._dataSource ||
|
if (!layerState._fires.length) {
|
||||||
!layerState._fires.length ||
|
// A refresh that came back empty takes down what the last one drew, so
|
||||||
!layerState._viewer
|
// no sprite or card is left on screen (and clickable) for a fire that is
|
||||||
)
|
// gone.
|
||||||
|
if (force) clearRendered();
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
const lodIndex = components.viewport.selectLodIndex(
|
const lodIndex = components.viewport.selectLodIndex(
|
||||||
components.viewport.cameraHeight(),
|
components.viewport.cameraHeight(),
|
||||||
);
|
);
|
||||||
@@ -60,6 +63,16 @@ export function createRendering({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearRendered() {
|
||||||
|
layerState._dataSource.entities.removeAll();
|
||||||
|
layerState._icons?.clear();
|
||||||
|
layerState._pickIndexById.clear();
|
||||||
|
layerState._cellCount = 0;
|
||||||
|
layerState._labelCandidates = [];
|
||||||
|
components.cards.rebuildAmbientLabels();
|
||||||
|
components.selection.refreshContextRegistrations([]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bin all detections into grid cells, then viewport-clip BEFORE the
|
* Bin all detections into grid cells, then viewport-clip BEFORE the
|
||||||
* top-N cap so fires in view never lose their slot to higher-scoring
|
* top-N cap so fires in view never lose their slot to higher-scoring
|
||||||
@@ -132,9 +145,8 @@ export function createRendering({
|
|||||||
function renderCells(cells, lod, bounds) {
|
function renderCells(cells, lod, bounds) {
|
||||||
ensureDetectionCollections();
|
ensureDetectionCollections();
|
||||||
layerState._dataSource.entities.removeAll();
|
layerState._dataSource.entities.removeAll();
|
||||||
if (layerState._billboards) layerState._billboards.removeAll();
|
layerState._icons?.clear();
|
||||||
layerState._pickIndexById.clear();
|
layerState._pickIndexById.clear();
|
||||||
layerState._cullPositions.length = 0;
|
|
||||||
layerState._cellCount = cells.length;
|
layerState._cellCount = cells.length;
|
||||||
const maxScore = Math.max(1, ...cells.map(components.model.heatScore));
|
const maxScore = Math.max(1, ...cells.map(components.model.heatScore));
|
||||||
|
|
||||||
@@ -189,7 +201,7 @@ export function createRendering({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Render individual detections (local/close bands) as pre-baked
|
* Render individual detections (local/close bands) as pre-baked
|
||||||
* radial-glow sprite billboards sized by FRP, capped per viewport by
|
* radial-glow sprite icons sized by FRP, capped per viewport by
|
||||||
* highest FRP, depth test disabled. The 3D Tiles mesh is never sampled
|
* highest FRP, depth test disabled. The 3D Tiles mesh is never sampled
|
||||||
* per point; instead, the CLOSE band (where terrain parallax is actually
|
* per point; instead, the CLOSE band (where terrain parallax is actually
|
||||||
* visible) batch-warms the shared cached DEM floor for its rendered
|
* visible) batch-warms the shared cached DEM floor for its rendered
|
||||||
@@ -205,10 +217,9 @@ export function createRendering({
|
|||||||
function renderDetections(lod, bounds) {
|
function renderDetections(lod, bounds) {
|
||||||
ensureDetectionCollections();
|
ensureDetectionCollections();
|
||||||
layerState._dataSource.entities.removeAll();
|
layerState._dataSource.entities.removeAll();
|
||||||
if (!layerState._billboards) return;
|
const set = layerState._icons;
|
||||||
layerState._billboards.removeAll();
|
if (!set) return;
|
||||||
layerState._pickIndexById.clear();
|
layerState._pickIndexById.clear();
|
||||||
layerState._cullPositions.length = 0;
|
|
||||||
|
|
||||||
let candidates;
|
let candidates;
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
@@ -231,41 +242,28 @@ export function createRendering({
|
|||||||
layerState._labelCandidates = [];
|
layerState._labelCandidates = [];
|
||||||
layerState._labelLodDistance = lod.labelDistance;
|
layerState._labelLodDistance = lod.labelDistance;
|
||||||
|
|
||||||
|
// Fires keep their rows across rebuilds: a pan that keeps a fire in view
|
||||||
|
// rewrites nothing for it, and only rows that left the view go.
|
||||||
|
const keep = new Set();
|
||||||
for (const fire of candidates) {
|
for (const fire of candidates) {
|
||||||
const coreSize = components.model.frpPixelSize(fire.frp);
|
|
||||||
const position = components.model.firePosition(fire);
|
const position = components.model.firePosition(fire);
|
||||||
const cullPosition = components.model.fireCullPosition(fire);
|
|
||||||
const pickId = `firms-${fire.index}`;
|
const pickId = `firms-${fire.index}`;
|
||||||
layerState._pickIndexById.set(pickId, fire);
|
layerState._pickIndexById.set(pickId, fire);
|
||||||
layerState._cullPositions.push(cullPosition);
|
keep.add(pickId);
|
||||||
const corePx = components.model.sizeBucket(coreSize);
|
placeFireIcon(set, pickId, fire, position);
|
||||||
addBillboard(layerState._billboards, {
|
|
||||||
id: pickId,
|
|
||||||
position,
|
|
||||||
image: components.model.glowSprite(
|
|
||||||
components.model.detectionColorStop(fire),
|
|
||||||
corePx,
|
|
||||||
),
|
|
||||||
// The glow sprite's own size (see glowSprite), stated for Core's mirror.
|
|
||||||
width: corePx * 2,
|
|
||||||
height: corePx * 2,
|
|
||||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
|
||||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
||||||
});
|
|
||||||
// FRP-sorted walk: candidate order doubles as label priority.
|
// FRP-sorted walk: candidate order doubles as label priority.
|
||||||
layerState._labelCandidates.push({
|
layerState._labelCandidates.push({
|
||||||
position,
|
position,
|
||||||
cullPosition,
|
cullPosition: components.model.fireCullPosition(fire),
|
||||||
fire,
|
fire,
|
||||||
cell: null,
|
cell: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (let row = set.count - 1; row >= 0; row -= 1) {
|
||||||
|
const pickId = set.idAt(row);
|
||||||
|
if (!keep.has(pickId)) set.remove(pickId);
|
||||||
|
}
|
||||||
|
|
||||||
// Freshly-added sprites default to show=true; cull the far side now so a
|
|
||||||
// rebuild never flashes through-the-planet detections while the camera
|
|
||||||
// sits still (the moveEnd/preRender hooks only fire on camera motion).
|
|
||||||
refreshHorizonCulling();
|
|
||||||
components.cards.rebuildAmbientLabels();
|
components.cards.rebuildAmbientLabels();
|
||||||
components.selection.refreshContextRegistrations(
|
components.selection.refreshContextRegistrations(
|
||||||
candidates.slice(0, CONTEXT_TOP_N),
|
candidates.slice(0, CONTEXT_TOP_N),
|
||||||
@@ -286,30 +284,83 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fire's glow row: drawn depth-test-free at its anchor (a detection is
|
||||||
|
* never swallowed by terrain it sits on), with the far side of the globe
|
||||||
|
* culled on the GPU each frame. The Cesium globe is hidden here (Google 3D
|
||||||
|
* Tiles render the planet), so nothing writes far-side depth, and the cull
|
||||||
|
* is what keeps fires from shining through the planet. The anchor moves
|
||||||
|
* once, when its floor warms.
|
||||||
|
*/
|
||||||
|
function placeFireIcon(set, pickId, fire, position) {
|
||||||
|
let row = set.row(pickId);
|
||||||
|
if (row < 0) {
|
||||||
|
row = set.add(pickId, {
|
||||||
|
layer: 'firms',
|
||||||
|
id: pickId,
|
||||||
|
fire: null,
|
||||||
|
position: null,
|
||||||
|
});
|
||||||
|
set.setFlag(row, ICON_CULL_LIFT, true);
|
||||||
|
set.setShown(row, true);
|
||||||
|
}
|
||||||
|
const meta = set.metaAt(row);
|
||||||
|
// A refresh reuses indices, so a kept row can hold a different detection.
|
||||||
|
if (meta.fire !== fire) {
|
||||||
|
meta.fire = fire;
|
||||||
|
const slot = iconAtlas.slot(
|
||||||
|
components.model.glowSprite(components.model.detectionColorStop(fire)),
|
||||||
|
GLOW_SPRITE_PX,
|
||||||
|
);
|
||||||
|
const corePx = components.model.sizeBucket(
|
||||||
|
components.model.frpPixelSize(fire.frp),
|
||||||
|
);
|
||||||
|
set.setImage(row, slot, slot);
|
||||||
|
set.setSize(row, corePx * 2, corePx * 2);
|
||||||
|
}
|
||||||
|
if (meta.position === position) return;
|
||||||
|
meta.position = position;
|
||||||
|
writeHoldSegment(set.segments, row, position, 0);
|
||||||
|
set.segmentWritten(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The scene the icon primitive was added to. */
|
||||||
|
let iconScene = null;
|
||||||
|
|
||||||
function ensureDetectionCollections() {
|
function ensureDetectionCollections() {
|
||||||
if (!layerState._viewer || layerState._billboards) return;
|
if (!layerState._viewer || layerState._icons) return;
|
||||||
layerState._billboards = new Cesium.BillboardCollection({
|
const epoch = Cesium.JulianDate.now();
|
||||||
scene: layerState._viewer.scene,
|
layerState._icons = createIconSet({ epoch });
|
||||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
layerState._iconPrimitive = new IconSetPrimitive({
|
||||||
|
set: layerState._icons,
|
||||||
|
renderTime: () => epoch,
|
||||||
|
requestRender: () => layerState._viewer?.scene?.requestRender?.(),
|
||||||
});
|
});
|
||||||
layerState._billboards.show = layerState._enabled;
|
layerState._icons.show = layerState._enabled;
|
||||||
layerState._viewer.scene.primitives.add(layerState._billboards);
|
iconScene = layerState._viewer.scene;
|
||||||
registerSpriteCollection('firms', layerState._billboards);
|
iconScene.primitives.add(layerState._iconPrimitive);
|
||||||
|
registerSpriteCollection('firms', layerState._iconPrimitive);
|
||||||
restoreSpriteOrder(layerState._viewer);
|
restoreSpriteOrder(layerState._viewer);
|
||||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled);
|
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDetectionCollections(viewer) {
|
/**
|
||||||
const scene = viewer?.scene || layerState._viewer?.scene;
|
* Take the icons out of the scene they were added to, whichever viewer the
|
||||||
if (scene && !scene.isDestroyed?.()) {
|
* caller tears down, and free them: a destroyed primitive left in a live
|
||||||
try {
|
* scene throws on its next frame.
|
||||||
if (layerState._billboards)
|
*/
|
||||||
scene.primitives.remove(layerState._billboards);
|
function removeDetectionCollections() {
|
||||||
} catch {
|
const primitive = layerState._iconPrimitive;
|
||||||
/* already torn down */
|
try {
|
||||||
}
|
if (primitive && iconScene && !iconScene.isDestroyed?.())
|
||||||
|
iconScene.primitives.remove(primitive);
|
||||||
|
} catch {
|
||||||
|
/* already torn down */
|
||||||
}
|
}
|
||||||
layerState._billboards = null;
|
if (primitive && !primitive.isDestroyed()) primitive.destroy();
|
||||||
|
iconScene = null;
|
||||||
|
layerState._iconPrimitive = null;
|
||||||
|
layerState._icons = null;
|
||||||
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
|
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
|
||||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
|
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
|
||||||
}
|
}
|
||||||
@@ -331,22 +382,6 @@ export function createRendering({
|
|||||||
return horizonOccluder(camera);
|
return horizonOccluder(camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide detection sprites that sit beyond the ellipsoid horizon (see
|
|
||||||
* {@link applyHorizonCull}). Runs on the cadences that already exist in this
|
|
||||||
* layer — the throttled LOD preRender watcher (camera-moved only), camera
|
|
||||||
* moveEnd, and each detection rebuild — never as a new per-frame pass.
|
|
||||||
* No-ops in the `cells` bands, where the collection is empty.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function refreshHorizonCulling() {
|
|
||||||
if (!layerState._billboards || !layerState._billboards.length) return;
|
|
||||||
components.model.applyHorizonCull(
|
|
||||||
layerState._billboards,
|
|
||||||
fireHorizonOccluder(),
|
|
||||||
layerState._cullPositions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
renderCurrentLod,
|
renderCurrentLod,
|
||||||
aggregateFires,
|
aggregateFires,
|
||||||
@@ -355,6 +390,5 @@ export function createRendering({
|
|||||||
ensureDetectionCollections,
|
ensureDetectionCollections,
|
||||||
removeDetectionCollections,
|
removeDetectionCollections,
|
||||||
fireHorizonOccluder,
|
fireHorizonOccluder,
|
||||||
refreshHorizonCulling,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -88,8 +88,8 @@ export function createSelection({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a scene pick to one of this layer's fire records, or null.
|
* Resolve a scene pick to one of this layer's fire records, or null.
|
||||||
* BillboardCollection picks surface our id either on picked.primitive.id
|
* Icon-set picks carry our row id on picked.id and on picked.primitive.id;
|
||||||
* or directly on picked.id depending on the CesiumJS pick path.
|
* either is accepted.
|
||||||
* @param {*} picked - Result of scene.pick().
|
* @param {*} picked - Result of scene.pick().
|
||||||
* @returns {?Object} Fire record.
|
* @returns {?Object} Fire record.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as Cesium from 'cesium';
|
|||||||
export function createFirmsState({ services, config }) {
|
export function createFirmsState({ services, config }) {
|
||||||
const layerState = {};
|
const layerState = {};
|
||||||
|
|
||||||
/** Pre-baked radial-glow sprites keyed by `<colorStop>:<sizeBucket>`. */
|
/** Pre-baked radial-glow sprites keyed by color stop name. */
|
||||||
|
|
||||||
layerState.glowSpriteCache = new Map();
|
layerState.glowSpriteCache = new Map();
|
||||||
|
|
||||||
@@ -19,7 +19,11 @@ export function createFirmsState({ services, config }) {
|
|||||||
|
|
||||||
layerState._dataSource = null;
|
layerState._dataSource = null;
|
||||||
|
|
||||||
layerState._billboards = null;
|
/** Detection glow rows and the primitive that draws them. */
|
||||||
|
|
||||||
|
layerState._icons = null;
|
||||||
|
|
||||||
|
layerState._iconPrimitive = null;
|
||||||
|
|
||||||
layerState._enabled = false;
|
layerState._enabled = false;
|
||||||
|
|
||||||
@@ -83,16 +87,6 @@ export function createFirmsState({ services, config }) {
|
|||||||
|
|
||||||
layerState._fireByCardId = new Map();
|
layerState._fireByCardId = new Map();
|
||||||
|
|
||||||
/**
|
|
||||||
* Occlusion-test anchors index-aligned with the billboard collection (the
|
|
||||||
* collection is cleared and refilled in one ordered pass per rebuild, and
|
|
||||||
* nothing else adds to it). Lifted where the render anchor sits at/below
|
|
||||||
* the ellipsoid — see {@link fireCullPosition}.
|
|
||||||
* @type {Array<Cesium.Cartesian3>}
|
|
||||||
*/
|
|
||||||
|
|
||||||
layerState._cullPositions = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* gridDegrees -> heat-sorted full cell list. The full-grid aggregation is
|
* gridDegrees -> heat-sorted full cell list. The full-grid aggregation is
|
||||||
* viewport-independent but walks every detection (~200k live), so it is
|
* viewport-independent but walks every detection (~200k live), so it is
|
||||||
|
|||||||
@@ -24,13 +24,7 @@ export function createViewport({
|
|||||||
if (layerState._moveEndRemover || !layerState._viewer) return;
|
if (layerState._moveEndRemover || !layerState._viewer) return;
|
||||||
layerState._moveEndRemover =
|
layerState._moveEndRemover =
|
||||||
layerState._viewer.camera.moveEnd.addEventListener(() => {
|
layerState._viewer.camera.moveEnd.addEventListener(() => {
|
||||||
if (!layerState._enabled) return;
|
if (!layerState._enabled || layerState._loading) return;
|
||||||
// Settled-camera exactness for the horizon pass (the throttled preRender
|
|
||||||
// watcher can last have run up to LOD_CHECK_MS before the camera
|
|
||||||
// stopped). Runs ahead of the `_loading` gate on purpose: the sprites on
|
|
||||||
// screen during a refresh are the ones that need culling.
|
|
||||||
components.rendering.refreshHorizonCulling();
|
|
||||||
if (layerState._loading) return;
|
|
||||||
components.cards.rebuildAmbientLabels();
|
components.cards.rebuildAmbientLabels();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -177,18 +171,8 @@ export function createViewport({
|
|||||||
Cesium.Cartesian3.clone(camera.positionWC, layerState._camPos);
|
Cesium.Cartesian3.clone(camera.positionWC, layerState._camPos);
|
||||||
Cesium.Cartesian3.clone(camera.directionWC, layerState._camDir);
|
Cesium.Cartesian3.clone(camera.directionWC, layerState._camDir);
|
||||||
layerState._camSnapValid = true;
|
layerState._camSnapValid = true;
|
||||||
// The camera moved, so the horizon moved. A rebuild culls its own fresh
|
if (layerState._fires.length && !layerState._loading)
|
||||||
// sprites (renderDetections); an early-out — same LOD band and view
|
components.rendering.renderCurrentLod(false);
|
||||||
// rect, or a refresh in flight — does not, so cull here instead.
|
|
||||||
// Exactly ONE occlusion walk per tick either way, bounded by the
|
|
||||||
// idle-gate + LOD_CHECK_MS throttle above: a parked camera costs
|
|
||||||
// nothing, a moving one pays one ≤maxDetections show-flip walk at
|
|
||||||
// ~1.5 Hz.
|
|
||||||
const rebuilt =
|
|
||||||
layerState._fires.length && !layerState._loading
|
|
||||||
? components.rendering.renderCurrentLod(false)
|
|
||||||
: false;
|
|
||||||
if (!rebuilt) components.rendering.refreshHorizonCulling();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user