diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3ebc8fdfb..6c9f600a6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1559,7 +1559,13 @@ "src/layers/firms/queries.js", "src/layers/firms/rendering.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/motionModel.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", diff --git a/src/data/firmsHeatmap.js b/src/data/firmsHeatmap.js index 95fed982d..1b63b3394 100644 --- a/src/data/firmsHeatmap.js +++ b/src/data/firmsHeatmap.js @@ -16,7 +16,6 @@ const helpers = createFirmsHelpers({ }); export const mapAnalystRecord = helpers.mapAnalystRecord; export const fireCullPosition = helpers.fireCullPosition; -export const applyHorizonCull = helpers.applyHorizonCull; export const buildSelectedFireCard = helpers.buildSelectedFireCard; export const buildFireCard = helpers.buildFireCard; export const buildCellCard = helpers.buildCellCard; diff --git a/src/data/firmsHorizonCull.test.mjs b/src/data/firmsHorizonCull.test.mjs index 277e4f483..105d2eb20 100644 --- a/src/data/firmsHorizonCull.test.mjs +++ b/src/data/firmsHorizonCull.test.mjs @@ -1,11 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { - applyHorizonCull, - createFirmsHeatmapLayer, - fireCullPosition, -} from './firmsHeatmap.js'; +import { createFirmsHeatmapLayer, fireCullPosition } from './firmsHeatmap.js'; import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js'; import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID } from './firmsLabels.js'; import { @@ -15,7 +11,8 @@ import { } from './groundFloor.js'; import { unregisterSpriteCollection } from './spriteOrder.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 }; /** 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. */ 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 - * the pass does not dirty the collection's vertex buffer needlessly. + * Whether the icon set's GPU pass draws `row` from `occluder`'s camera: the + * shader's horizon test on the row's cull point (its CPU twin). */ -function billboard(position, show = true) { - return mirrorBillboard({ - position, - _show: show, - writes: 0, - get show() { return this._show; }, - set show(value) { this._show = value; this.writes += 1; }, - }); +function gpuShows(set, row, occluder) { + const position = set.position(row, 0, new Cesium.Cartesian3()); + return set.shown(row) && occluder.isPointVisible(set.cullPoint(row, position)); } -/** Cesium BillboardCollection-shaped stub. */ -function collection(items) { - return { length: items.length, get: (i) => items[i] }; +/** A fire-shaped row (shown, CULL_LIFT) held at `position`. */ +function fireRow(set, id, position) { + 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`. */ @@ -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 near = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0)); - const nearby = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + 6, AUSTIN.lat + 4, 0)); - const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0)); - const otherSide = billboard(Cesium.Cartesian3.fromDegrees(TOKYO.lon, TOKYO.lat, 0)); + const set = createIconSet(); + const at = (p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, 0); + const near = fireRow(set, 'near', at(AUSTIN)); + 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(near.show, true, 'the fire under the camera renders'); - assert.equal(nearby.show, true, 'a fire well inside the horizon renders'); - 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'); + 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(gpuShows(set, far, occluder), false, 'the antipodal fire must not shine through the planet'); + assert.equal(gpuShows(set, otherSide, occluder), false, 'Tokyo is beyond the limb from Austin at 1.5 Mm'); }); -test('applyHorizonCull: raising the camera reveals fires the low camera hid', () => { - const paris = () => billboard(Cesium.Cartesian3.fromDegrees(2.35, 48.86, 0)); - - const low = paris(); - applyHorizonCull(collection([low]), occluderOverAustin(REPRO_HEIGHT_M)); - 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('icon cull: raising the camera reveals fires the low camera hid', () => { + const set = createIconSet(); + const paris = fireRow(set, 'paris', Cesium.Cartesian3.fromDegrees(2.35, 48.86, 0)); + assert.equal(gpuShows(set, paris, occluderOverAustin(REPRO_HEIGHT_M)), false); + assert.equal(gpuShows(set, paris, occluderOverAustin(35_000_000)), true, 'in view from geostationary height'); }); -test('applyHorizonCull: a hidden fire is restored when it swings back into view', () => { - 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)', () => { +test('icon cull: a sub-ellipsoid anchor is judged lifted, a far-side one still culls', () => { const occluder = occluderOverAustin(REPRO_HEIGHT_M); - const stays = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), true); - const flips = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0), true); + const set = createIconSet(); + 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); - applyHorizonCull(collection([stays, flips]), occluder); - - 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'); + const far = fireRow(set, 'far', Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22)); + assert.equal(gpuShows(set, far, occluder), false, '12 m of lift must not defeat the actual horizon cull'); }); -test('applyHorizonCull: missing collection/occluder/holes are no-ops, not throws', () => { - 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', () => { +test('fireCullPosition: a card is judged where its sprite is', () => { const occluder = occluderOverAustin(REPRO_HEIGHT_M); - const sunken = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22)); - const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12); - - applyHorizonCull(collection([sunken]), occluder); - assert.equal(sunken.show, false, 'baseline: the raw sub-ellipsoid anchor false-hides at the limb'); - - applyHorizonCull(collection([sunken]), occluder, [lifted]); - assert.equal(sunken.show, true, 'the lifted cull point keeps the near-limb fire on screen'); -}); - -test('applyHorizonCull: a lifted cull point does NOT resurrect a genuinely far-side fire', () => { - const occluder = occluderOverAustin(REPRO_HEIGHT_M); - const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22)); - const lifted = Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12); - applyHorizonCull(collection([far]), occluder, [lifted]); - assert.equal(far.show, false, '12 m of lift must not defeat the actual horizon cull'); + const set = createIconSet(); + setMeshFloorPreferred(true); + try { + // Sunk, an 11 m anchor (floor 6 + lift), and well above: each card must + // agree with the GPU test on its own row, at limb points where they can. + for (const [i, floor] of [-27, 6, 900].entries()) { + _clearMeshFloorCellsForTest(); // a reported cell latches its first floor + for (const dLon of [LIMB_DLON, LIFT_BAND_DLON]) { + const lat = AUSTIN.lat; + const lon = AUSTIN.lon + dLon; + reportMeshFloorCell(lat, lon, floor); + const fire = { index: i, lat, lon, frp: 10, position: null }; + const cull = fireCullPosition(fire); + assert.equal(fire.positionHeight, floor + FIRE_ANCHOR_LIFT_M); + const row = fireRow(set, `${floor}:${dLon}`, fire.position); + 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', () => { @@ -305,17 +283,21 @@ class MockEvent { 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() { + const stops = []; return { width: 0, height: 0, getContext: () => ({ - createRadialGradient: () => ({ addColorStop() {} }), + createRadialGradient: () => ({ addColorStop: (offset, color) => stops.push(color) }), fillStyle: null, 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 - * Cesium.BillboardCollection, so the production hooks (not just the pure - * predicate) are what the assertions exercise. + * Boots the REAL layer against a headless viewer stub; what the GPU would draw + * is read off the layer's real icon set (`gpuShows`) from the stub camera. */ function createHarness(rawFires) { const originalFetch = globalThis.fetch; @@ -384,7 +365,13 @@ function createHarness(rawFires) { preRender, primitives: { 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, }, frameState: { mode: Cesium.SceneMode.SCENE3D, mapProjection: new Cesium.GeographicProjection() }, @@ -408,15 +395,19 @@ function createHarness(rawFires) { viewer, preRender, moveEnd, - /** The real BillboardCollection the layer created. */ - billboards: () => primitives[0], + /** The icon set the layer draws its detections from. */ + icons: () => primitives[0].set, + /** Primitives currently in the stub scene. */ + primitives, /** Latest entry list published to the overlay host. */ 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() { - const bb = primitives[0]; + const set = primitives[0].set; + const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, viewer.camera.positionWC); 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; }, 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([ rawFire(AUSTIN.lat, AUSTIN.lon, 120), 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([ rawFire(AUSTIN.lat, AUSTIN.lon, 120), 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.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(); 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'); @@ -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 () => { - const harness = createHarness([ - rawFire(AUSTIN.lat, AUSTIN.lon, 120), - rawFire(TOKYO.lat, TOKYO.lon, 800), - ]); +test('layer: a refresh keeps rows and restyles one whose index now holds another fire', async () => { + const fires = [rawFire(AUSTIN.lat, AUSTIN.lon, 400), rawFire(TOKYO.lat, TOKYO.lon, 800)]; + const harness = createHarness(fires); try { harness.layer.init(harness.viewer); await harness.layer.enable(harness.viewer); - harness.moveCameraTo(TOKYO.lon, TOKYO.lat); - // The watcher is throttled to LOD_CHECK_MS (650 ms) against - // performance.now(), whose value at this point depends on process - // start-up — so emit, wait out the full throttle window, and emit again. - // Whichever tick passes the gate, one of them must run the pass. - harness.preRender.emit(); - await new Promise((resolve) => { setTimeout(resolve, 700); }); - harness.preRender.emit(); + const set = harness.icons(); + const austin = set.row('firms-0'); + const handle = set.handleAt(austin); + const before = set.width(austin); + const beforeImage = set.imageFar(austin); - const show = harness.showById(); - assert.equal(show.get('firms-1'), true, 'Tokyo revealed by the preRender pass'); - assert.equal(show.get('firms-0'), false, 'Austin culled by the preRender pass'); + fires[0] = rawFire(AUSTIN.lat + 0.5, AUSTIN.lon, 1); + await harness.layer.update(); + + 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 { 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([ rawFire(AUSTIN.lat, AUSTIN.lon, 120), 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); // Assert with NO camera event fired and NO refetch (fires are already - // loaded, so enable() does not reload): the enable path itself must have - // re-culled before the collection went visible. + // loaded, so enable() does not reload): the GPU culls from the current + // camera on the first frame. 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-0'), false, 'the Austin fire is NOT left shining through the planet'); } 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 () => { const lat = AUSTIN.lat; 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); await harness.layer.enable(harness.viewer); - const bb = harness.billboards(); - const sunken = bb.get(1); - const carto = Cesium.Cartographic.fromCartesian(sunken.position); + const set = harness.icons(); + const sunken = set.row('firms-1'); + const carto = Cesium.Cartographic.fromCartesian(set.position(sunken, 0, new Cesium.Cartesian3())); assert.ok( Math.abs(carto.height - (-27 + FIRE_ANCHOR_LIFT_M)) < 0.5, `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'); } finally { harness.cleanup(); diff --git a/src/data/spriteOrder.test.mjs b/src/data/spriteOrder.test.mjs index e5ea59d6c..38e1d02c3 100644 --- a/src/data/spriteOrder.test.mjs +++ b/src/data/spriteOrder.test.mjs @@ -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( 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', ); }); diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 7e0b42a00..2b5740335 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -28,17 +28,9 @@ export function createLifecycle({ layerState._viewer = viewer; if (!layerState._dataSource) this.init(viewer); if (layerState._dataSource) layerState._dataSource.show = true; - if (layerState._billboards) { - // The camera can have moved anywhere while the layer was off: moveEnd - // was not being listened to, and the preRender watcher is inert while - // 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; - } + // The GPU culls the far side each frame, so retained rows are right + // for wherever the camera went while the layer was off. + if (layerState._icons) layerState._icons.show = true; overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); components.viewport.installLodWatcher(); components.viewport.installMoveEndWatcher(); @@ -63,7 +55,7 @@ export function createLifecycle({ layerState._enabled = false; components.selection.clearFireSelection(); 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.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); clearSelectedEntityContextForLayer(id); @@ -110,7 +102,6 @@ export function createLifecycle({ layerState._labelLodDistance = 0; layerState._pickIndexById.clear(); layerState._fireByCardId.clear(); - layerState._cullPositions.length = 0; layerState._camSnapValid = false; }, }; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 1c5873812..a6fc1028e 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -9,11 +9,10 @@ import { import { VIEW_PADDING, DETECTION_COLOR_STOPS, - CULL_LIFT_THRESHOLD_M, - CULL_LIFT_M, + GLOW_SPRITE_PX, LABEL_MIN_SEP_PX, } from './policy.js'; -import { billboardState, setBillboard } from '../../data/billboardMirror.js'; +import { ICON_CULL_HEIGHT_M } from '../../data/iconSet.js'; export function createModel({ layerState, @@ -161,18 +160,17 @@ export function createModel({ /** * 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 - * 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 {number} corePx - Bucketed core size in pixels. * @returns {string} PNG data URL. */ - function glowSprite(stop, corePx) { - const key = `${stop.name}:${corePx}`; - const cached = layerState.glowSpriteCache.get(key); + function glowSprite(stop) { + const cached = layerState.glowSpriteCache.get(stop.name); if (cached) return cached; - const dimension = corePx * 2; + const dimension = GLOW_SPRITE_PX; const canvas = document.createElement('canvas'); canvas.width = dimension; canvas.height = dimension; @@ -200,7 +198,7 @@ export function createModel({ context.fillRect(0, 0, dimension, dimension); const dataUrl = canvas.toDataURL('image/png'); - layerState.glowSpriteCache.set(key, dataUrl); + layerState.glowSpriteCache.set(stop.name, dataUrl); return dataUrl; } @@ -231,21 +229,25 @@ export function createModel({ /** * Occlusion-test anchor for one detection. Returns the render position * outright for anchors comfortably above the ellipsoid (the common case) and - * a lazily-cached lifted point otherwise — see CULL_LIFT_THRESHOLD_M. This - * NEVER feeds rendering: the datum-correct anchor from {@link firePosition} - * is what the sprite and the card are drawn at. + * a lazily-cached lifted point otherwise, the point the icon set's GPU + * horizon test judges a fire row at (ICON_CULL_LIFT), so a card shows exactly + * 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. * @returns {Cesium.Cartesian3} */ function fireCullPosition(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) { fire.cullPosition = Cesium.Cartesian3.fromDegrees( fire.lon, fire.lat, - CULL_LIFT_M, + ICON_CULL_HEIGHT_M, ); } return fire.cullPosition; @@ -261,48 +263,7 @@ export function createModel({ */ function cellCullPosition(lon, lat) { - return Cesium.Cartesian3.fromDegrees(lon, lat, CULL_LIFT_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} [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; + return Cesium.Cartesian3.fromDegrees(lon, lat, ICON_CULL_HEIGHT_M); } /** @@ -589,7 +550,6 @@ export function createModel({ firePosition, fireCullPosition, cellCullPosition, - applyHorizonCull, screenSeparated, buildSelectedFireCard, buildFireCard, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 673aab871..72ecf2db1 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -8,7 +8,7 @@ export const REFRESH_INTERVAL_MS = 600_000; /** * LOD bands keyed by camera height. `cells` bands render aggregated * 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 * (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; /** - * Anchors below this height get a separate LIFTED point for occlusion tests - * (never for rendering). Mirrors the flights layer's `cullPosition` idiom - * (`renderAltitudeM < 10` → test at 12 m): `fireAnchorHeight` is ground floor - * + 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. + * Glow sprites are rasterised once per colour at the largest core size + * (frpPixelSize tops out at 28 px, a 56 px sprite) and drawn smaller per fire: + * the gradient stops are relative, so the glow keeps its shape at any size. */ -export const CULL_LIFT_THRESHOLD_M = 10; - -/** Height of the lifted occlusion-test point (flights uses the same 12 m). */ - -export const CULL_LIFT_M = 12; +export const GLOW_SPRITE_PX = 56; /** Color stops shared by cell heat fills and detection glow sprites. */ diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 1a8652f0e..34257718e 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,8 +1,11 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js'; -import { addBillboard } from '../../data/billboardMirror.js'; +import { LOD_LEVELS, CONTEXT_TOP_N, GLOW_SPRITE_PX } from './policy.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({ layerState, @@ -20,18 +23,18 @@ export function createRendering({ * when neither the (hysteresis-damped) LOD band nor the padded view * rectangle changed meaningfully. * @param {boolean} force - Rebuild even if LOD/view appear unchanged. - * @returns {boolean} True when a rebuild actually ran. Callers use this to - * avoid a second horizon walk in the same tick: a rebuild already culls - * its freshly-added sprites, an early-out does not. + * @returns {boolean} True when a rebuild actually ran. */ function renderCurrentLod(force = false) { - if ( - !layerState._dataSource || - !layerState._fires.length || - !layerState._viewer - ) + if (!layerState._dataSource || !layerState._viewer) return false; + if (!layerState._fires.length) { + // A refresh that came back empty takes down what the last one drew, so + // no sprite or card is left on screen (and clickable) for a fire that is + // gone. + if (force) clearRendered(); return false; + } const lodIndex = components.viewport.selectLodIndex( components.viewport.cameraHeight(), ); @@ -60,6 +63,16 @@ export function createRendering({ 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 * 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) { ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); - if (layerState._billboards) layerState._billboards.removeAll(); + layerState._icons?.clear(); layerState._pickIndexById.clear(); - layerState._cullPositions.length = 0; layerState._cellCount = cells.length; 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 - * 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 * per point; instead, the CLOSE band (where terrain parallax is actually * visible) batch-warms the shared cached DEM floor for its rendered @@ -205,10 +217,9 @@ export function createRendering({ function renderDetections(lod, bounds) { ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); - if (!layerState._billboards) return; - layerState._billboards.removeAll(); + const set = layerState._icons; + if (!set) return; layerState._pickIndexById.clear(); - layerState._cullPositions.length = 0; let candidates; if (bounds) { @@ -231,41 +242,28 @@ export function createRendering({ layerState._labelCandidates = []; 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) { - const coreSize = components.model.frpPixelSize(fire.frp); const position = components.model.firePosition(fire); - const cullPosition = components.model.fireCullPosition(fire); const pickId = `firms-${fire.index}`; layerState._pickIndexById.set(pickId, fire); - layerState._cullPositions.push(cullPosition); - const corePx = components.model.sizeBucket(coreSize); - 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, - }); + keep.add(pickId); + placeFireIcon(set, pickId, fire, position); // FRP-sorted walk: candidate order doubles as label priority. layerState._labelCandidates.push({ position, - cullPosition, + cullPosition: components.model.fireCullPosition(fire), fire, 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.selection.refreshContextRegistrations( 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() { - if (!layerState._viewer || layerState._billboards) return; - layerState._billboards = new Cesium.BillboardCollection({ - scene: layerState._viewer.scene, - blendOption: Cesium.BlendOption.TRANSLUCENT, + if (!layerState._viewer || layerState._icons) return; + const epoch = Cesium.JulianDate.now(); + layerState._icons = createIconSet({ epoch }); + layerState._iconPrimitive = new IconSetPrimitive({ + set: layerState._icons, + renderTime: () => epoch, + requestRender: () => layerState._viewer?.scene?.requestRender?.(), }); - layerState._billboards.show = layerState._enabled; - layerState._viewer.scene.primitives.add(layerState._billboards); - registerSpriteCollection('firms', layerState._billboards); + layerState._icons.show = layerState._enabled; + iconScene = layerState._viewer.scene; + iconScene.primitives.add(layerState._iconPrimitive); + registerSpriteCollection('firms', layerState._iconPrimitive); restoreSpriteOrder(layerState._viewer); overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled); } - function removeDetectionCollections(viewer) { - const scene = viewer?.scene || layerState._viewer?.scene; - if (scene && !scene.isDestroyed?.()) { - try { - if (layerState._billboards) - scene.primitives.remove(layerState._billboards); - } catch { - /* already torn down */ - } + /** + * Take the icons out of the scene they were added to, whichever viewer the + * caller tears down, and free them: a destroyed primitive left in a live + * scene throws on its next frame. + */ + function removeDetectionCollections() { + const primitive = layerState._iconPrimitive; + 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.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); } @@ -331,22 +382,6 @@ export function createRendering({ 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 { renderCurrentLod, aggregateFires, @@ -355,6 +390,5 @@ export function createRendering({ ensureDetectionCollections, removeDetectionCollections, fireHorizonOccluder, - refreshHorizonCulling, }; } diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index 3ffc33354..fcba3ecbd 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -88,8 +88,8 @@ export function createSelection({ /** * Resolve a scene pick to one of this layer's fire records, or null. - * BillboardCollection picks surface our id either on picked.primitive.id - * or directly on picked.id depending on the CesiumJS pick path. + * Icon-set picks carry our row id on picked.id and on picked.primitive.id; + * either is accepted. * @param {*} picked - Result of scene.pick(). * @returns {?Object} Fire record. */ diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 48ae11db2..6f13165a8 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -3,7 +3,7 @@ import * as Cesium from 'cesium'; export function createFirmsState({ services, config }) { const layerState = {}; - /** Pre-baked radial-glow sprites keyed by `:`. */ + /** Pre-baked radial-glow sprites keyed by color stop name. */ layerState.glowSpriteCache = new Map(); @@ -19,7 +19,11 @@ export function createFirmsState({ services, config }) { layerState._dataSource = null; - layerState._billboards = null; + /** Detection glow rows and the primitive that draws them. */ + + layerState._icons = null; + + layerState._iconPrimitive = null; layerState._enabled = false; @@ -83,16 +87,6 @@ export function createFirmsState({ services, config }) { 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} - */ - - layerState._cullPositions = []; - /** * gridDegrees -> heat-sorted full cell list. The full-grid aggregation is * viewport-independent but walks every detection (~200k live), so it is diff --git a/src/layers/firms/viewport.js b/src/layers/firms/viewport.js index 932fe2e75..5ea2b191a 100644 --- a/src/layers/firms/viewport.js +++ b/src/layers/firms/viewport.js @@ -24,13 +24,7 @@ export function createViewport({ if (layerState._moveEndRemover || !layerState._viewer) return; layerState._moveEndRemover = layerState._viewer.camera.moveEnd.addEventListener(() => { - if (!layerState._enabled) 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; + if (!layerState._enabled || layerState._loading) return; components.cards.rebuildAmbientLabels(); }); } @@ -177,18 +171,8 @@ export function createViewport({ Cesium.Cartesian3.clone(camera.positionWC, layerState._camPos); Cesium.Cartesian3.clone(camera.directionWC, layerState._camDir); layerState._camSnapValid = true; - // The camera moved, so the horizon moved. A rebuild culls its own fresh - // sprites (renderDetections); an early-out — same LOD band and view - // 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(); + if (layerState._fires.length && !layerState._loading) + components.rendering.renderCurrentLod(false); }); }