From 8328aab779f00bbe2d7ade95e2007e114fe4ef50 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 20:27:24 +0400 Subject: [PATCH] fix(navara): batch contours and order ground overlays Batch consecutive disjoint installation outlines to avoid three stencil draws per feature. Preserve overlapping volumes, material order and local precision. Delete the public effect handle correctly during teardown. Keep imagery below ground classifications and glyphs in every activation order, including minimal mode without tone mapping. Exclude fully offscreen ground volumes from the screen candidate grid. Full unit/allocation, boundaries, formatting and keyless builds pass. Browser checks cover contour coverage, bounded colour differences, a missing-contour negative control, real pass order, sprite alpha and FIRMS glows. Whole-wave performance acceptance remains separate from this implementation checkpoint. --- scripts/package-boundaries.json | 1 + src/globe/navara/overlay.js | 4 +- src/layers/earthquakes/render/navara.js | 12 ++- src/layers/firms/render/navara.js | 8 +- src/layers/installations/render/navara.js | 42 ++++---- .../installations/render/navara.test.mjs | 14 ++- .../render/navaraOutlineBatch.js | 63 ++++++++++++ .../render/navaraOutlineBatch.test.mjs | 97 +++++++++++++++++++ .../recentImagery/render/navaraGround.js | 4 +- .../shared/render/navaraGroundGeometry.js | 8 +- .../render/navaraGroundGeometry.test.mjs | 29 ++++++ .../shared/render/navaraGroundOrder.test.mjs | 69 +++++++++++++ 12 files changed, 320 insertions(+), 31 deletions(-) create mode 100644 src/layers/installations/render/navaraOutlineBatch.js create mode 100644 src/layers/installations/render/navaraOutlineBatch.test.mjs create mode 100644 src/layers/shared/render/navaraGroundGeometry.test.mjs create mode 100644 src/layers/shared/render/navaraGroundOrder.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c6fb1f967..c90ac4412 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2702,6 +2702,7 @@ "src/layers/installations/render/navara.js", "src/layers/bikeshare/render/navara.js", "src/layers/installations/render/navaraGeometry.js", + "src/layers/installations/render/navaraOutlineBatch.js", "src/layers/firms/rectangle.js", "src/layers/firms/render/cesium.js", "src/layers/radio/render/cesium.js", diff --git a/src/globe/navara/overlay.js b/src/globe/navara/overlay.js index 8013d0e98..1b3ce9796 100644 --- a/src/globe/navara/overlay.js +++ b/src/globe/navara/overlay.js @@ -55,7 +55,9 @@ class GevOverlayEffectDesc extends EffectDesc { class GevPreSharpenOverlayEffectDesc extends GevOverlayEffectDesc { static key = PRE_SHARPEN_KEY; - static insertAfter = ['toneMapping']; + // Ground classifiers also insert after tone mapping. Anchor to the next + // stage so glyphs stay above them regardless of layer activation order. + static insertAfter = []; static insertBefore = [ 'gevSharpen', 'smaa', diff --git a/src/layers/earthquakes/render/navara.js b/src/layers/earthquakes/render/navara.js index b61d186c1..4e0bb9825 100644 --- a/src/layers/earthquakes/render/navara.js +++ b/src/layers/earthquakes/render/navara.js @@ -199,8 +199,16 @@ function buildState(discs) { function groundEffectClass(EffectDesc, NavaraEffect) { return class GroundEffectDesc extends EffectDesc { static key = KEY; - static insertAfter = ['toneMapping']; - static insertBefore = ['gevSharpen', 'smaa', 'fxaa', 'gevOverlay', 'final']; + static insertAfter = ['gevRecentImagery', 'toneMapping']; + static insertBefore = [ + 'gevFirmsGround', + 'gevPreSharpenOverlay', + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevOverlay', + 'final', + ]; constructor(view, ctx, config) { super(view, ctx, config); this.owner = config[KEY].owner; diff --git a/src/layers/firms/render/navara.js b/src/layers/firms/render/navara.js index 53937c814..cc8c03f29 100644 --- a/src/layers/firms/render/navara.js +++ b/src/layers/firms/render/navara.js @@ -214,12 +214,16 @@ function buildState(cells) { function groundEffectClass(EffectDesc, NavaraEffect) { return class FirmsGroundEffectDesc extends EffectDesc { static key = KEY; - static insertAfter = ['toneMapping']; + static insertAfter = [ + 'gevEarthquakeGround', + 'gevRecentImagery', + 'toneMapping', + ]; static insertBefore = [ + 'gevPreSharpenOverlay', 'gevSharpen', 'smaa', 'fxaa', - 'gevPreSharpenOverlay', 'gevOverlay', 'final', ]; diff --git a/src/layers/installations/render/navara.js b/src/layers/installations/render/navara.js index 3eb039bfd..7de8100f4 100644 --- a/src/layers/installations/render/navara.js +++ b/src/layers/installations/render/navara.js @@ -11,6 +11,7 @@ import { createInstallationDrapedMesh, createInstallationOutlineDrapedMesh, } from './navaraGeometry.js'; +import { batchInstallationOutlines } from './navaraOutlineBatch.js'; const registeredViews = new WeakSet(); const KEY = 'gevInstallationsDrape'; @@ -131,7 +132,9 @@ export function createInstallationsNavaraRenderer( const contextObjects = new Map(); const namedRecords = new Map(); const selectionMeshes = new Set(); - const outlinesById = new Map(); + const outlineMeshes = new Set(); + let outlineRecords = [], + outlineWidthM = null; let drapeHandle = null; let visible = false; let fillRecord = null; @@ -228,18 +231,17 @@ export function createInstallationsNavaraRenderer( 1, (2 * cameraHeight * Math.tan(fov / 2) * 2) / canvasHeight, ); - const desired = new Map( - records - .filter((record) => !record.pointOnly && !record.namedArea) - .map((record) => [record.id, record]), + const desired = records.filter( + (record) => !record.pointOnly && !record.namedArea, ); - for (const [id, entry] of outlinesById) { - if (desired.get(id) === entry.record && entry.widthM === widthM) continue; - replaceOwnedMeshes(entry.meshes, []); - outlinesById.delete(id); - } - for (const [id, record] of desired) { - if (outlinesById.has(id)) continue; + if ( + outlineWidthM === widthM && + desired.length === outlineRecords.length && + desired.every((record, index) => record === outlineRecords[index]) + ) + return; + const next = []; + for (const record of desired) { const lines = record.outlineLines || (record.footprint ? [record.footprint] : []); const meshes = lines @@ -254,17 +256,17 @@ export function createInstallationsNavaraRenderer( }), ) .filter(Boolean); - const owned = new Set(meshes); - for (const mesh of owned) owner.meshes.add(mesh); - outlinesById.set(id, { record, widthM, meshes: owned }); + next.push(...meshes); } - syncDrapeEffect(); + replaceOwnedMeshes(outlineMeshes, batchInstallationOutlines(next)); + outlineRecords = desired; + outlineWidthM = widthM; } function clearOutlines() { - for (const entry of outlinesById.values()) - replaceOwnedMeshes(entry.meshes, []); - outlinesById.clear(); + replaceOwnedMeshes(outlineMeshes, []); + outlineRecords = []; + outlineWidthM = null; } function registerContext(record) { @@ -643,7 +645,7 @@ export function createInstallationsNavaraRenderer( selection.destroy(); rendering.clearRendered(); namedMarkers.destroy(); - drapeHandle?.destroy(); + drapeHandle?.delete(); drapeHandle = null; normalSet.destroy(); selectedSet.destroy(); diff --git a/src/layers/installations/render/navara.test.mjs b/src/layers/installations/render/navara.test.mjs index b8900689c..cb962ceb6 100644 --- a/src/layers/installations/render/navara.test.mjs +++ b/src/layers/installations/render/navara.test.mjs @@ -42,6 +42,7 @@ test('selected installation fill is reused and removed with its scene resources' }; }, removeEntityContextsForLayer() {}, + clearSelectedEntityContextForLayer() {}, }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, }; @@ -64,13 +65,19 @@ test('selected installation fill is reused and removed with its scene resources' render: { request() {} }, }; let effectConfig; + let deletedEffects = 0; const view = { canvas: { clientHeight: 800 }, camera: { raw: { fov: 60 } }, registerEffect() {}, addEffect(config) { effectConfig = config; - return { update() {}, destroy() {} }; + return { + update() {}, + delete() { + deletedEffects++; + }, + }; }, }; let meshCount = 0; @@ -88,6 +95,8 @@ test('selected installation fill is reused and removed with its scene resources' installationSourceLabel: () => 'OpenStreetMap', }, namedMarkers: { sync() {}, enable() {}, hide() {}, destroy() {} }, + viewport: { clearUnavailableRetry() {} }, + ingestion: { setInstallationStatus() {} }, }; const renderer = createInstallationsNavaraRenderer(engine, { state, @@ -144,4 +153,7 @@ test('selected installation fill is reused and removed with its scene resources' 'clearing selection preserves the ordinary outline', ); assert.equal(sets.length, 3); + renderer.lifecycle.methods.destroy(); + assert.equal(deletedEffects, 1, 'destroy deletes the public effect handle'); + assert.equal(effectConfig.gevInstallationsDrape.owner.meshes.size, 0); }); diff --git a/src/layers/installations/render/navaraOutlineBatch.js b/src/layers/installations/render/navaraOutlineBatch.js new file mode 100644 index 000000000..6da5aff91 --- /dev/null +++ b/src/layers/installations/render/navaraOutlineBatch.js @@ -0,0 +1,63 @@ +import { Matrix4 } from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; + +// Each DrapedMesh needs three stencil draws. Consecutive, equally coloured +// volumes with disjoint XY bounds can share those draws without changing +// translucent overlap order. Keep coordinates local to preserve precision. +// Consumes the input meshes; the returned meshes own the surviving resources. +export function batchInstallationOutlines(meshes) { + const groups = []; + for (const mesh of meshes) { + const group = groups.at(-1); + let geometry; + if ( + group && + mesh.material.color.equals(group.mesh.material.color) && + mesh.material.opacity === group.mesh.material.opacity + ) { + const relative = new Matrix4().multiplyMatrices( + group.inverse, + mesh.matrix, + ); + if (Math.hypot(...relative.elements.slice(12, 15)) <= 100_000) { + geometry = mesh.geometry.clone().applyMatrix4(relative); + geometry.computeBoundingBox(); + const b = geometry.boundingBox; + if ( + group.bounds.every( + (a) => + b.min.x > a.max.x || + b.max.x < a.min.x || + b.min.y > a.max.y || + b.max.y < a.min.y, + ) + ) { + group.geometries.push(geometry); + group.bounds.push(b); + group.mesh.userData.installationIds.push( + mesh.userData.installationId, + ); + mesh.geometry.dispose(); + mesh.material.dispose(); + continue; + } + geometry.dispose(); + } + } + mesh.geometry.computeBoundingBox(); + mesh.userData.installationIds = [mesh.userData.installationId]; + groups.push({ + mesh, + inverse: mesh.matrix.clone().invert(), + geometries: [mesh.geometry], + bounds: [mesh.geometry.boundingBox], + }); + } + return groups.map(({ mesh, geometries }) => { + if (geometries.length > 1) { + mesh.geometry = mergeGeometries(geometries); + for (const geometry of geometries) geometry.dispose(); + } + return mesh; + }); +} diff --git a/src/layers/installations/render/navaraOutlineBatch.test.mjs b/src/layers/installations/render/navaraOutlineBatch.test.mjs new file mode 100644 index 000000000..48f61db73 --- /dev/null +++ b/src/layers/installations/render/navaraOutlineBatch.test.mjs @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BoxGeometry, Mesh, MeshBasicMaterial, Vector3 } from 'three'; +import { batchInstallationOutlines } from './navaraOutlineBatch.js'; + +function volume(id, x, { color = '#ff8800', opacity = 0.85, y = 0 } = {}) { + const mesh = new Mesh( + new BoxGeometry(10, 10, 40000), + new MeshBasicMaterial({ color, opacity }), + ); + mesh.matrix.makeTranslation(x, y, 0); + mesh.userData.installationId = id; + return mesh; +} + +test('disjoint equal-colour outlines share a stencil volume without moving their vertices', () => { + const meshes = [ + volume('a', 6_000_000), + volume('b', 6_000_100), + volume('c', 6_000_200), + ]; + const positions = meshes.flatMap((m) => { + const a = m.geometry.attributes.position; + return Array.from({ length: a.count }, (_, i) => + new Vector3().fromBufferAttribute(a, i).applyMatrix4(m.matrix), + ); + }); + const geometryDisposals = [], + materialDisposals = []; + for (const m of meshes) { + m.geometry.addEventListener('dispose', () => + geometryDisposals.push(m.userData.installationId), + ); + m.material.addEventListener('dispose', () => + materialDisposals.push(m.userData.installationId), + ); + } + const result = batchInstallationOutlines(meshes); + assert.equal(result.length, 1); + assert.deepEqual(result[0].userData.installationIds, ['a', 'b', 'c']); + const a = result[0].geometry.attributes.position; + assert.equal(a.count, positions.length); + for (let i = 0; i < a.count; i++) + assert.ok( + new Vector3() + .fromBufferAttribute(a, i) + .applyMatrix4(result[0].matrix) + .distanceTo(positions[i]) < 1e-5, + ); + assert.deepEqual(geometryDisposals.sort(), ['a', 'b', 'c']); + assert.deepEqual(materialDisposals.sort(), ['b', 'c']); + result[0].geometry.dispose(); + result[0].material.dispose(); + assert.deepEqual(materialDisposals.sort(), ['a', 'b', 'c']); +}); + +test('overlapping volumes, different materials and distant origins retain separate draws and order', () => { + for (const second of [ + volume('b', 5), + volume('b', 100, { color: '#00ff00' }), + volume('b', 100, { opacity: 0.5 }), + volume('b', 100_001), + ]) { + const first = volume('a', 0), + result = batchInstallationOutlines([first, second]); + assert.deepEqual(result, [first, second]); + for (const mesh of result) { + mesh.geometry.dispose(); + mesh.material.dispose(); + } + } + const meshes = [ + volume('a', 0), + volume('b', 100, { color: '#00ff00' }), + volume('c', 200), + ]; + assert.deepEqual(batchInstallationOutlines(meshes), meshes); + for (const mesh of meshes) { + mesh.geometry.dispose(); + mesh.material.dispose(); + } +}); + +test('batch admission compares against every prior volume, including rotated bounds', () => { + const meshes = [volume('a', 0), volume('b', 100), volume('c', 0)]; + meshes[2].matrix.makeRotationZ(Math.PI / 4); + const result = batchInstallationOutlines(meshes); + assert.equal(result.length, 2); + assert.deepEqual( + result.map((m) => m.userData.installationIds), + [['a', 'b'], ['c']], + ); + for (const mesh of result) { + mesh.geometry.dispose(); + mesh.material.dispose(); + } +}); diff --git a/src/layers/recentImagery/render/navaraGround.js b/src/layers/recentImagery/render/navaraGround.js index f9e6b3873..291fd9868 100644 --- a/src/layers/recentImagery/render/navaraGround.js +++ b/src/layers/recentImagery/render/navaraGround.js @@ -69,10 +69,12 @@ export function createImageryGround( static key = KEY; static insertAfter = ['toneMapping']; static insertBefore = [ + 'gevEarthquakeGround', + 'gevFirmsGround', + 'gevPreSharpenOverlay', 'gevSharpen', 'smaa', 'fxaa', - 'gevPreSharpenOverlay', 'gevOverlay', 'final', ]; diff --git a/src/layers/shared/render/navaraGroundGeometry.js b/src/layers/shared/render/navaraGroundGeometry.js index 080fe0524..ef16c2087 100644 --- a/src/layers/shared/render/navaraGroundGeometry.js +++ b/src/layers/shared/render/navaraGroundGeometry.js @@ -19,10 +19,10 @@ export function viewFrame(frame, matrix, out) { /** Conservative clip bounds, including a camera inside/crossing the volume. */ export function groundClipBounds(frame, view, projection, out) { const p = projection.elements; - let x0 = 1, - y0 = 1, - x1 = -1, - y1 = -1, + let x0 = Infinity, + y0 = Infinity, + x1 = -Infinity, + y1 = -Infinity, front = 0, behind = 0; for (let i = 0; i < 8; i++) { diff --git a/src/layers/shared/render/navaraGroundGeometry.test.mjs b/src/layers/shared/render/navaraGroundGeometry.test.mjs new file mode 100644 index 000000000..37dba30f7 --- /dev/null +++ b/src/layers/shared/render/navaraGroundGeometry.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PerspectiveCamera } from 'three'; +import { groundClipBounds } from './navaraGroundGeometry.js'; + +test('ground volumes wholly outside a screen edge do not enter the candidate grid', () => { + const camera = new PerspectiveCamera(60, 1.6, 1, 1000); + const frame = { radius: 1, floor: -1, ceiling: 1 }; + for (const [x, y] of [ + [100, 0], + [-100, 0], + [0, 100], + [0, -100], + ]) { + const view = new Float64Array([x, y, -10, 1, 0, 0, 0, 1, 0, 0, 0, 1]); + assert.deepEqual( + [ + ...groundClipBounds( + frame, + view, + camera.projectionMatrix, + new Float64Array(4), + ), + ], + [2, 2, 2, 2], + `offscreen volume at ${x}, ${y}`, + ); + } +}); diff --git a/src/layers/shared/render/navaraGroundOrder.test.mjs b/src/layers/shared/render/navaraGroundOrder.test.mjs new file mode 100644 index 000000000..e1e60c863 --- /dev/null +++ b/src/layers/shared/render/navaraGroundOrder.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; + +// EffectDesc gives insertAfter priority, then tries insertBefore. Check the +// actual renderer declarations under every activation order, including the +// minimal look, whose pipeline has no toneMapping anchor. +const declarations = [ + ['gevRecentImagery', '../../recentImagery/render/navaraGround.js'], + ['gevEarthquakeGround', '../../earthquakes/render/navara.js'], + ['gevFirmsGround', '../../firms/render/navara.js'], + ['gevPreSharpenOverlay', '../../../globe/navara/overlay.js'], +]; +const specs = await Promise.all( + declarations.map(async ([key, path]) => { + let source = await fs.readFile(new URL(path, import.meta.url), 'utf8'); + if (key === 'gevPreSharpenOverlay') + source = source.split('class GevPreSharpenOverlayEffectDesc')[1]; + const anchors = (name) => + [ + ...source + .match(new RegExp(`static ${name} = \\[([^\\]]*)\\]`))[1] + .matchAll(/'([^']+)'/g), + ].map((m) => m[1]); + return { + key, + after: anchors('insertAfter'), + before: anchors('insertBefore'), + }; + }), +); +const permutations = (items) => + items.length + ? items.flatMap((item, i) => + permutations(items.filter((_, j) => i !== j)).map((rest) => [ + item, + ...rest, + ]), + ) + : [[]]; + +test('all ground/glyph activation orders preserve imagery, classification and marker stages', () => { + for (const toneMapping of [false, true]) + for (const activation of permutations(specs)) { + const order = [ + 'mrt', + ...(toneMapping ? ['toneMapping'] : []), + 'gevSharpen', + 'smaa', + 'gevOverlay', + 'final', + ]; + for (const spec of activation) { + const after = spec.after.find((key) => order.includes(key)); + const before = spec.before.find((key) => order.includes(key)); + const index = after + ? order.indexOf(after) + 1 + : before + ? order.indexOf(before) + : order.length; + order.splice(index, 0, spec.key); + } + assert.deepEqual( + order.filter((key) => specs.some((s) => s.key === key)), + declarations.map(([key]) => key), + `${toneMapping}: ${activation.map((s) => s.key)}`, + ); + } +});