mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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.
This commit is contained in:
1 parent
9ba5a3aba9
commit
8328aab779
12 files changed
+320
-31
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -69,10 +69,12 @@ export function createImageryGround(
|
||||
static key = KEY;
|
||||
static insertAfter = ['toneMapping'];
|
||||
static insertBefore = [
|
||||
'gevEarthquakeGround',
|
||||
'gevFirmsGround',
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
'smaa',
|
||||
'fxaa',
|
||||
'gevPreSharpenOverlay',
|
||||
'gevOverlay',
|
||||
'final',
|
||||
];
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user