feat(firms): add Navara renderer checkpoint

This commit is contained in:
German Khokhlov committed 2026-10-28 09:36:50 +04:00
1 parent a9b3125a99
commit 5dd05a4a81
33 files changed
+2112 -285

No files matched your search

+13 -1
View File
@@ -1201,5 +1201,17 @@
"src/layers/firms/rectangle.test.mjs",
"src/layers/firms/viewport.test.mjs",
"scripts/qa-w2-firms.mjs",
"scripts/fixtures/w2/firms.mjs"
"scripts/fixtures/w2/firms.mjs",
"scripts/qa-w2-sprite-alpha.mjs",
"src/layers/firms/render/navara.test.mjs",
"src/layers/firms/render/navaraGeometry.test.mjs",
"src/layers/firms/render/navaraSprites.test.mjs",
"scripts/qa-w2-firms-glows.mjs",
"scripts/qa-firms-glow-metrics.mjs",
"src/tooling/firmsGlowMetrics.test.mjs",
"scripts/fixtures/w2/glow-metrics/capture.json",
"src/layers/firms/lifecycle.test.mjs",
"scripts/qa-w2-firms-renderer-entry.mjs",
"scripts/qa-w2-firms-perf.mjs",
"scripts/qa-w2-firms-ground.mjs"
]
+7 -1
View File
@@ -2698,7 +2698,13 @@
"src/layers/earthquakes/render/navaraGeometry.js",
"src/layers/earthquakes/render/navaraGrid.js",
"src/layers/firms/rectangle.js",
"src/layers/firms/render/cesium.js"
"src/layers/firms/render/cesium.js",
"src/layers/firms/render/navara.js",
"src/layers/firms/render/navaraGeometry.js",
"src/layers/firms/render/navaraSprites.js",
"src/layers/shared/render/navaraGroundGeometry.js",
"src/layers/shared/render/navaraGroundGrid.js",
"src/layers/shared/render/navaraGroundSurface.js"
],
"external": [
"hls.js",
+47 -18
View File
@@ -30,25 +30,33 @@
*/
import puppeteer from 'puppeteer';
import { redact } from './qa-browserEvidence.mjs';
import {
isExpectedPreviewSetupError,
redact,
} from './qa-browserEvidence.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4420');
const appUrl = new URL(getOpt('--url', 'http://localhost:4420'));
const ENGINE = getOpt('--engine', appUrl.searchParams.get('engine') || 'cesium');
if (!['cesium', 'navara'].includes(ENGINE)) throw new Error('Unknown engine');
appUrl.searchParams.set('engine', ENGINE);
const APP_URL = appUrl.href;
const HEADFUL = argv.includes('--headful');
const FIXTURES = argv.includes('--fixtures');
const PREVIEW = argv.includes('--preview');
const ANGLE = getOpt('--angle', 'swiftshader');
const OUT = getOpt('--out', null);
const SHOTS_DIR = OUT ? path.resolve(OUT).replace(/\.json$/, '') + '-shots' : path.join(REPO_ROOT, 'qa-shots');
if (!['metal', 'swiftshader'].includes(ANGLE)) throw new Error('Unknown ANGLE backend');
const runtimeErrors = [];
const expectedSourceErrors = [];
@@ -163,9 +171,11 @@ async function waitForFirmsActionCount(page, expected, { timeoutMs = 12000 } = {
async function bootAndEnable(page, { timeoutS = 45 } = {}) {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
() => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
);
const actualEngine = await page.evaluate(() => window.__godsEyeView.engine.kind);
if (actualEngine !== ENGINE) throw new Error('Requested FIRMS engine did not load');
await sleep(5000);
await page.keyboard.press('Escape');
return page.evaluate(async (tS) => {
@@ -184,8 +194,15 @@ async function bootAndEnable(page, { timeoutS = 45 } = {}) {
/** Teleport the camera (duck-typed cartographic — no Cesium global). */
async function setView(page, lon, lat, height) {
await page.evaluate((lo, la, h) => {
await page.evaluate(async (lo, la, h) => {
const gev = window.__godsEyeView;
if (!gev.viewer) {
const geo = await import('/src/geo/index.js');
gev.engine.camera.cancelFlight();
gev.engine.camera.setView({destination:geo.cartesian3.fromDegrees(lo,la,h),orientation:{heading:0,pitch:-Math.PI/2,roll:0}});
gev.engine.render.request('firms-legacy-view');
return;
}
const ell = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180;
// The app's intro flyTo animation clobbers a setView issued mid-flight.
@@ -271,6 +288,10 @@ async function main() {
page.on('error', error => runtimeErrors.push(redact(error.message)));
page.on('console', message => {
if (message.type() !== 'error') return;
if (isExpectedPreviewSetupError(message, { preview: PREVIEW, baseUrl: APP_URL })) {
expectedSourceErrors.push('/api/setup/status: 404');
return;
}
let expected = false;
try {
const url = new URL(message.location()?.url);
@@ -401,17 +422,22 @@ async function main() {
const target = mod.getDetectableObjects({ maxCount: 1 })[0];
if (!target?.position) return null;
const sentinel = gev.viewer.entities.add({
const native = Boolean(gev.viewer);
const sentinel = native ? gev.viewer.entities.add({
id: 'qa-firms-prior-camera-owner',
position: target.position,
});
gev.viewer.trackedEntity = sentinel;
}) : gev.engine.camera.follow({position:()=>target.position,viewFrom:{x:0,y:-5000,z:8000},
label:{id:'qa-firms-prior-camera-owner',model:{title:'QA prior camera owner'},position:()=>target.position}});
if (native) gev.viewer.trackedEntity = sentinel;
const camera = gev.viewer.camera;
const originalFly = camera.flyToBoundingSphere;
const camera = native ? gev.viewer.camera : gev.engine.camera;
const flightMethod = native ? 'flyToBoundingSphere' : 'flyToSphere';
const originalFly = camera[flightMethod];
const proof = {
sentinel,
originalFly,
flightMethod,
native,
flightCount: 0,
trackedAtFlight: 'not-called',
request: null,
@@ -424,9 +450,9 @@ async function main() {
};
};
window.addEventListener('gev:world-request-focus', proof.onRequest);
camera.flyToBoundingSphere = function qaFirmsObservedFlight(...args) {
camera[flightMethod] = function qaFirmsObservedFlight(...args) {
proof.flightCount += 1;
proof.trackedAtFlight = gev.viewer.trackedEntity?.id || null;
proof.trackedAtFlight = native ? gev.viewer.trackedEntity?.id || null : gev.engine.camera.following()?.label?.id || null;
return originalFly.apply(this, args);
};
window.__qaFirmsActionProof = proof;
@@ -448,7 +474,7 @@ async function main() {
for (let i = 0; i < 30; i += 1) {
const settled = await page.evaluate(() => {
window.__godsEyeView?.viewer?.scene?.requestRender?.();
window.__godsEyeView?.engine?.render?.request?.('firms-legacy');
return (window.__qaFirmsActionProof?.flightCount || 0) > 0;
});
if (settled) break;
@@ -466,7 +492,7 @@ async function main() {
const result = {
flightCount: state?.flightCount || 0,
trackedAtFlight: state?.trackedAtFlight,
trackingReleased: gev.viewer.trackedEntity == null,
trackingReleased: gev.viewer ? gev.viewer.trackedEntity == null : gev.engine.camera.following() == null,
generationBefore: state?.generationBefore,
generationAfter: gev.styleManager._navigationGeneration,
request: state?.request || null,
@@ -476,9 +502,12 @@ async function main() {
).length,
};
if (state) {
gev.viewer.camera.flyToBoundingSphere = state.originalFly;
(state.native ? gev.viewer.camera : gev.engine.camera)[state.flightMethod] = state.originalFly;
window.removeEventListener('gev:world-request-focus', state.onRequest);
if (state.sentinel) gev.viewer.entities.remove(state.sentinel);
if (state.sentinel) {
if (state.native) gev.viewer.entities.remove(state.sentinel);
else state.sentinel.stop();
}
}
delete window.__qaFirmsActionProof;
return result;
@@ -540,8 +569,8 @@ async function main() {
if (OUT) {
const file = path.resolve(OUT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ pass:exitCode === 0 && fail === 0, fixture:FIXTURES, angle:ANGLE,
results, browserErrors:runtimeErrors, expectedSourceErrors }, null, 2) + '\n');
fs.writeFileSync(file, JSON.stringify({ pass:exitCode === 0 && fail === 0, fixture:FIXTURES, angle:ANGLE, engine:ENGINE,
shots:SHOTS_DIR, results, browserErrors:runtimeErrors, expectedSourceErrors }, null, 2) + '\n');
}
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
+2
View File
@@ -25,6 +25,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers,
'satellites',
'ais-live-vessels',
'earthquakes',
'local-firms',
])
assert.equal(layerAvailableOn(id, 'navara'), true, id);
assert.deepEqual(
@@ -39,6 +40,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers,
'satellites',
'rocket-launches',
'ais-live-vessels',
'local-firms',
],
);
assert.equal(layerAvailableOn('satellites', 'navara'), true);
+10
View File
@@ -53,6 +53,7 @@ const NAVARA_CREDIT_KEYS = new Set([
'celestrak',
'launch-library-2',
'usgs',
'firms',
]);
/**
@@ -61,6 +62,15 @@ const NAVARA_CREDIT_KEYS = new Set([
*/
export async function registerNavaraLayerRenderers() {
const loaders = {
'local-firms': async () => {
const { createFirmsNavaraRenderer } =
await import('../layers/firms/render/navara.js');
return (engine, options) =>
createFirmsNavaraRenderer(engine, {
...options,
...navaraViews.get(engine),
});
},
earthquakes: async () => {
const { createEarthquakesNavaraRenderer } =
await import('../layers/earthquakes/render/navara.js');
+5 -1
View File
@@ -181,12 +181,16 @@ test('Navara keeps attribution for every available W1/W2 source and excludes una
'celestrak',
'launch-library-2',
'usgs',
'firms',
'photon-geocoder',
'openstreetmap',
'reearth-terrain',
];
const run = await startScene({
credits: [...keys, 'firms'].map((key) => ({ key, html: `Credit ${key}` })),
credits: [...keys, 'radio-browser'].map((key) => ({
key,
html: `Credit ${key}`,
})),
});
t.after(run.teardown);
assert.deepEqual(
+4
View File
@@ -323,6 +323,10 @@
* colorBlend?: { mode: 'mix'|'replace', amount: number }, unlit?: boolean }} [model]
* A glTF drawn per instance in metres, at least minimumPixelSize px.
* @property {boolean} [pickable]
* @property {boolean} [sharpen] Sprites/points: include the glyph in the
* sharpen/AA stage after tone mapping, for translucent fields such as FIRMS.
* Navara defaults to the later display stage used by W1; Cesium retains its
* native billboard stage. Does not enable sharpening or change its amount.
*
* @typedef {object} MoverFields
* One instance; omitted fields keep their value (defaults: white, scale 1,
+6 -1
View File
@@ -66,7 +66,7 @@ function atlasOf(atlas) {
/**
* The createSet options with defaults filled in, or a TypeError.
* @returns {{ id: string, kind: string, capacity: number, order: number,
* depthTest: 'normal'|'always'|{ disableWithinM: number }, pickable: boolean,
* depthTest: 'normal'|'always'|{ disableWithinM: number }, pickable: boolean, sharpen: boolean,
* cellKeys: string[], sprite: object | null, point: object | null,
* model: object | null }}
*/
@@ -80,6 +80,10 @@ export function normalizeMoverSetOptions(options) {
fail(`capacity must be a positive integer: ${capacity}`);
const order = options.order ?? 0;
if (!isFiniteNumber(order)) fail(`order must be a number: ${order}`);
if (options.sharpen !== undefined && typeof options.sharpen !== 'boolean')
fail('sharpen must be a boolean');
if (options.sharpen && kind === 'model')
fail('sharpen applies to sprites and points');
const normalized = {
id,
kind,
@@ -87,6 +91,7 @@ export function normalizeMoverSetOptions(options) {
order,
depthTest: depthTestOf(options.depthTest),
pickable: options.pickable === true,
sharpen: options.sharpen === true,
cellKeys: [],
sprite: null,
point: null,
+29
View File
@@ -264,3 +264,32 @@ test('point outline RGBA is validated and copied independently of the caller', (
TypeError,
);
});
test('native-stage sprite fields opt into sharpening explicitly and reject model misuse', () => {
const base = {
id: 'firms',
kind: 'sprite',
capacity: 1,
sprite: { atlas, sizePx: 56 },
};
assert.equal(normalizeMoverSetOptions(base).sharpen, false);
assert.equal(
normalizeMoverSetOptions({ ...base, sharpen: true }).sharpen,
true,
);
assert.throws(
() => normalizeMoverSetOptions({ ...base, sharpen: 'true' }),
TypeError,
);
assert.throws(
() =>
normalizeMoverSetOptions({
id: 'm',
kind: 'model',
capacity: 1,
model: { url: '/box.glb' },
sharpen: true,
}),
TypeError,
);
});
+20 -7
View File
@@ -20,6 +20,7 @@ import { createModelMover } from './modelMover.js';
import { createMoverMesh } from './moverMesh.js';
import { createPickIndex } from './pickIds.js';
import { createMoverFrameCamera } from './frameCamera.js';
import { createNavaraOverlay } from '../overlay.js';
const DESC_NAME = 'gevMoverSet';
const registered = new WeakSet();
@@ -94,24 +95,32 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
loader ??= createGltfLoader();
return loader.loadAsync(url);
};
let preSharpenOverlay = null,
preSharpenUsers = 0;
const request = () => view.forceUpdate();
const overlaySet = (options, store) => {
const targetOverlay = options.sharpen
? (preSharpenOverlay ??= createNavaraOverlay(view, {
beforeSharpen: true,
}))
: overlay;
if (options.sharpen) preSharpenUsers++;
const mover = createMoverMesh(options, store, {
onTextureLoad: request,
overlayUniforms: overlay.uniforms,
overlayUniforms: targetOverlay.uniforms,
});
const key = `gevMovers:${options.id}`;
if (options.pickable) {
const wrapper = new PickableInstancedMeshWrapper(
mover.mesh,
options.capacity,
overlay.ctx,
targetOverlay.ctx,
);
overlay.registerPickable(key, wrapper);
targetOverlay.registerPickable(key, wrapper);
pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]);
}
overlay.add(mover.mesh, (time) =>
updateMover(readFrameCamera, overlay.ctx, mover, time),
targetOverlay.add(mover.mesh, (time) =>
updateMover(readFrameCamera, targetOverlay.ctx, mover, time),
);
return {
ready: mover.ready,
@@ -121,9 +130,13 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
},
destroy() {
pickIndex.remove(options.id);
if (options.pickable) overlay.unregisterPickable(key);
overlay.remove(mover.mesh);
if (options.pickable) targetOverlay.unregisterPickable(key);
targetOverlay.remove(mover.mesh);
mover.dispose();
if (options.sharpen && --preSharpenUsers === 0) {
preSharpenOverlay.destroy();
preSharpenOverlay = null;
}
request();
},
};
+75 -20
View File
@@ -8,14 +8,15 @@
import {
CanvasTexture,
DoubleSide,
DynamicDrawUsage,
FrontSide,
InstancedBufferAttribute,
InstancedMesh,
LinearFilter,
MeshBasicMaterial,
PlaneGeometry,
SRGBColorSpace,
NoColorSpace,
Texture,
} from 'three';
import {
@@ -78,19 +79,19 @@ gevSizePx = gevOutlinedSize + 3.0 / uGevPixelRatio;
vGevPixelDistance = 2.0 / max(gevSizePx * uGevPixelRatio, 1e-3);
if (gevStyle.y <= 0.0) gevSizePx = 0.0;
#endif
float gevCos = cos(gevStyle.x);
float gevSin = sin(gevStyle.x);
vec2 gevCorner = position.xy;
vec2 gevTurned = vec2(
gevCorner.x * gevCos - gevCorner.y * gevSin,
gevCorner.x * gevSin + gevCorner.y * gevCos
gevCorner.x * gevStyle.z - gevCorner.y * gevStyle.w,
gevCorner.x * gevStyle.w + gevCorner.y * gevStyle.z
);
vec4 mvPosition = mvCenter;
mvPosition.xy += gevTurned * gevSizePx * uGevFootprint * max(-mvCenter.z, 0.0);
gl_Position = projectionMatrix * mvPosition;
vGevColor = gevColor;
vGevLocal = gevCorner;
#ifndef GEV_ALWAYS_VISIBLE
vGevDepthFree = gevDistance < uGevDisableWithinM ? 1.0 : 0.0;
#endif
`;
const VERTEX_UV = /* glsl */ `
@@ -138,10 +139,15 @@ diffuseColor *= sRGBTransferEOTF(vGevColor);
const DETACHED_OVERLAY = Object.freeze({
tGevSceneDepth: { value: null },
uGevResolution: { value: [0, 0] },
uGevDisplayBlend: { value: 0 },
});
/** Patches three's MeshBasicMaterial shaders into the screen-space quad. */
export function patchMoverShader(shader, uniforms, { point = false } = {}) {
export function patchMoverShader(
shader,
uniforms,
{ point = false, encodedAtlas = false, alwaysVisible = false } = {},
) {
Object.assign(shader.uniforms, uniforms);
const replace = (source, from, to) => {
if (!source.includes(from))
@@ -151,7 +157,7 @@ export function patchMoverShader(shader, uniforms, { point = false } = {}) {
let vertex = replace(
shader.vertexShader,
'void main() {',
`${point ? '#define GEV_POINT\n' : ''}${VERTEX_HEAD}\nvoid main() {`,
`${point ? '#define GEV_POINT\n' : ''}${alwaysVisible ? '#define GEV_ALWAYS_VISIBLE\n' : ''}${VERTEX_HEAD}\nvoid main() {`,
);
vertex = replace(vertex, '#include <project_vertex>', VERTEX_PROJECT);
vertex = replace(vertex, '#include <uv_vertex>', VERTEX_UV);
@@ -160,20 +166,34 @@ export function patchMoverShader(shader, uniforms, { point = false } = {}) {
'void main() {',
`${FRAGMENT_HEAD}\nvoid main() {`,
);
let colorFragment = fragmentColor(point);
if (alwaysVisible)
colorFragment = replace(
colorFragment,
'if (gevOverlayHidden()) discard;',
'',
);
fragment = replace(fragment, '#include <color_fragment>', colorFragment);
fragment = replace(
fragment,
'#include <color_fragment>',
fragmentColor(point),
'#include <colorspace_fragment>',
'gl_FragColor=gevOverlayDisplayColor(gl_FragColor);\n#include <colorspace_fragment>',
);
if (encodedAtlas)
fragment = replace(
fragment,
'#include <map_fragment>',
'#include <map_fragment>\ndiffuseColor.rgb = sRGBTransferEOTF(vec4(diffuseColor.rgb, 1.0)).rgb;',
);
shader.vertexShader = vertex;
shader.fragmentShader = fragment;
return shader;
}
// Sampled as Cesium samples its billboard atlas: linear, no mipmaps (a
// mipmapped atlas greys small glyphs that Cesium draws at full colour).
function cesiumSampling(texture) {
texture.colorSpace = SRGBColorSpace;
function cesiumSampling(texture, encoded = false) {
// Native FIRMS filters its original encoded PNG colors before display blending.
texture.colorSpace = encoded ? NoColorSpace : SRGBColorSpace;
texture.generateMipmaps = false;
texture.minFilter = LinearFilter;
texture.magFilter = LinearFilter;
@@ -181,9 +201,10 @@ function cesiumSampling(texture) {
}
/** The atlas as a three texture (a canvas at once, a URL once loaded). */
function atlasTexture(atlas, onLoad) {
if (atlas.canvas) return cesiumSampling(new CanvasTexture(atlas.canvas));
const texture = cesiumSampling(new Texture());
function atlasTexture(atlas, encoded, onLoad) {
if (atlas.canvas)
return cesiumSampling(new CanvasTexture(atlas.canvas), encoded);
const texture = cesiumSampling(new Texture(), encoded);
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => {
@@ -247,28 +268,41 @@ export function createMoverMesh(
// Shared by every overlay: the scene depth and the target's size.
tGevSceneDepth: overlayUniforms.tGevSceneDepth,
uGevResolution: overlayUniforms.uGevResolution,
uGevDisplayBlend:
overlayUniforms.uGevDisplayBlend ?? DETACHED_OVERLAY.uGevDisplayBlend,
};
// A canvas atlas is there at once; a URL one once its image loads.
let textureReady = point || Boolean(options.sprite.atlas.canvas);
const encodedAtlas = !point && options.sharpen === true;
const material = new MeshBasicMaterial({
map: point
? null
: atlasTexture(options.sprite.atlas, () => {
: atlasTexture(options.sprite.atlas, encodedAtlas, () => {
textureReady = true;
onTextureLoad();
}),
transparent: true,
alphaTest: point ? 0.005 : 0.02,
alphaTest: point || encodedAtlas ? 0.005 : 0.02,
// Drawn after tone mapping, with no depth buffer: the shader tests the
// scene depth itself (overlayDepth.js).
depthTest: false,
depthWrite: false,
side: DoubleSide,
side: FrontSide,
});
material.onBeforeCompile = (shader) =>
patchMoverShader(shader, uniforms, { point });
material.customProgramCacheKey = () => `gev-mover-${kind}`;
patchMoverShader(shader, uniforms, {
point,
encodedAtlas,
alwaysVisible: depthTest === 'always',
});
material.customProgramCacheKey = () =>
`gev-mover-${kind}${encodedAtlas ? '-encoded-atlas' : ''}`;
const mesh = new InstancedMesh(geometry, material, capacity);
mesh.userData.gevStaticSprite =
kind === 'sprite' &&
options.sharpen &&
depthTest === 'always' &&
!style.scaleByDistance;
mesh.instanceMatrix.setUsage(DynamicDrawUsage);
mesh.frustumCulled = false;
mesh.renderOrder = options.order;
@@ -301,6 +335,7 @@ export function createMoverMesh(
mesh.updateMatrix();
mesh.updateMatrixWorld(true);
};
let previousCamera = null;
return {
mesh,
uniforms,
@@ -332,6 +367,26 @@ export function createMoverMesh(
viewportHeightPx,
pixelRatio = 1,
}) {
if (
previousCamera &&
previousCamera.x === cameraPosition.x &&
previousCamera.y === cameraPosition.y &&
previousCamera.z === cameraPosition.z &&
previousCamera.height === cameraHeight &&
previousCamera.fov === fovDeg &&
previousCamera.viewportHeight === viewportHeightPx &&
previousCamera.pixelRatio === pixelRatio
)
return false;
previousCamera = {
x: cameraPosition.x,
y: cameraPosition.y,
z: cameraPosition.z,
height: cameraHeight,
fov: fovDeg,
viewportHeight: viewportHeightPx,
pixelRatio,
};
uniforms.uGevPixelRatio.value = pixelRatio;
uniforms.uGevFootprint.value = footprintPerDepth(
fovDeg,
+82 -1
View File
@@ -1,6 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ShaderLib, UniformsUtils } from 'three';
import {
ShaderLib,
UniformsUtils,
FrontSide,
NoColorSpace,
SRGBColorSpace,
} from 'three';
import { createMoverStore, normalizeMoverSetOptions } from '../../movers.js';
import { createMoverMesh, patchMoverShader } from './moverMesh.js';
@@ -27,6 +33,16 @@ test('the shader patch sizes the quad in pixels and colours it per instance', ()
const shader = patchMoverShader(basicShader(), { uGevSizePx: { value: 9 } });
assert.equal(shader.uniforms.uGevSizePx.value, 9);
assert.match(shader.vertexShader, /attribute vec4 gevStyle;/);
assert.match(shader.vertexShader, /gevCorner\.x \* gevStyle\.z/);
assert.doesNotMatch(
shader.vertexShader,
/cos\(gevStyle\.x\)|sin\(gevStyle\.x\)/,
);
assert.ok(
shader.vertexShader.indexOf('float gevDistance = length(mvCenter.xyz);') <
shader.vertexShader.indexOf('if (uGevHasScaleByDistance > 0.5)'),
'depth testing uses the distance outside the optional scaling block',
);
assert.match(
shader.vertexShader,
/uGevFootprint \* max\(-mvCenter\.z, 0\.0\)/,
@@ -89,6 +105,13 @@ test('update sets the pixel footprint and moves the origin to a far camera', ()
};
assert.equal(mover.update(camera), false, 'close: the origin stays');
assert.ok(Math.abs(mover.uniforms.uGevFootprint.value - 2 / 800) < 1e-12);
const matrixVersion = mover.mesh.instanceMatrix.version;
assert.equal(
mover.update(camera),
false,
'unchanged camera reuses its snapshot',
);
assert.equal(mover.mesh.instanceMatrix.version, matrixVersion);
camera.cameraPosition = { x: 6_378_137 + 5_000, y: 0, z: 0 };
assert.equal(mover.update(camera), true);
assert.equal(mover.origin().x, 6_378_137 + 5_000);
@@ -108,11 +131,22 @@ test('points have no atlas and draw a disc', () => {
const mover = createMoverMesh(options, createMoverStore(options));
assert.equal(mover.mesh.material.map, null);
assert.equal(mover.mesh.material.depthTest, false);
assert.equal(mover.mesh.material.side, FrontSide);
assert.equal(mover.uniforms.uGevDepthMode.value, 0, "'always': no test");
assert.equal(mover.uniforms.uGevOutlinePx.value, 1);
mover.dispose();
});
test('always-visible movers omit scene-depth sampling from the shader', () => {
const shader = patchMoverShader(basicShader(), {}, { alwaysVisible: true });
assert.match(shader.vertexShader, /#define GEV_ALWAYS_VISIBLE/);
assert.match(
shader.vertexShader,
/#ifndef GEV_ALWAYS_VISIBLE\nvGevDepthFree =[^\n]+\n#endif/,
);
assert.doesNotMatch(shader.fragmentShader, /gevOverlayHidden\(\)\) discard/);
});
test('an origin move uploads every instance even when a commit follows in the same frame', () => {
const store = createMoverStore(spriteOptions);
const mover = createMoverMesh(spriteOptions, store);
@@ -144,3 +178,50 @@ test('an origin move uploads every instance even when a commit follows in the sa
for (let i = 0; i < 4 * 16; i++)
assert.ok(covered.has(i), `instance float ${i} is uploaded`);
});
test('sprites share the live display-blend uniform of their overlay target', () => {
const flag = { value: 0 };
const mover = createMoverMesh(
spriteOptions,
createMoverStore(spriteOptions),
{
overlayUniforms: {
tGevSceneDepth: { value: null },
uGevResolution: { value: [1280, 800] },
uGevDisplayBlend: flag,
},
},
);
const shader = basicShader();
mover.mesh.material.onBeforeCompile(shader);
assert.equal(shader.uniforms.uGevDisplayBlend, flag);
flag.value = 1;
assert.equal(shader.uniforms.uGevDisplayBlend.value, 1);
assert.match(
shader.fragmentShader,
/gl_FragColor=gevOverlayDisplayColor\(gl_FragColor\)/,
);
mover.dispose();
});
test('native-stage glow atlas filters encoded colors and keeps the native faint-edge cutoff', () => {
const options = { ...spriteOptions, sharpen: true };
const glow = createMoverMesh(options, createMoverStore(options));
const ordinary = createMoverMesh(
spriteOptions,
createMoverStore(spriteOptions),
);
assert.equal(glow.mesh.material.map.colorSpace, NoColorSpace);
assert.equal(glow.mesh.material.alphaTest, 0.005);
assert.equal(ordinary.mesh.material.map.colorSpace, SRGBColorSpace);
assert.equal(ordinary.mesh.material.alphaTest, 0.02);
assert.notEqual(
glow.mesh.material.customProgramCacheKey(),
ordinary.mesh.material.customProgramCacheKey(),
);
const shader = basicShader();
glow.mesh.material.onBeforeCompile(shader);
assert.match(shader.fragmentShader, /diffuseColor.rgb = sRGBTransferEOTF/);
glow.dispose();
ordinary.dispose();
});
+5 -17
View File
@@ -42,7 +42,7 @@ export function originFor(cameraPosition, origin, cameraHeight) {
/**
* Writes instances [from, to) of a mover store into the upload arrays:
* `matrices` (identity with the offset from `origin` as translation),
* `style` (rotation, scale or 0 when hidden, two spare lanes), `colors`
* `style` (rotation, scale or 0 when hidden, and cached cosine/sine), `colors`
* (rgba) and `uvs` (the instance's atlas rect, from `uvRects`).
*/
export function packInstances(store, from, to, origin, uvRects, out) {
@@ -50,27 +50,15 @@ export function packInstances(store, from, to, origin, uvRects, out) {
const { matrices, style, colors: colorOut, uvs } = out;
for (let index = from; index < to; index++) {
const m = index * 16;
matrices[m] = 1;
matrices[m + 1] = 0;
matrices[m + 2] = 0;
matrices[m + 3] = 0;
matrices[m + 4] = 0;
matrices[m + 5] = 1;
matrices[m + 6] = 0;
matrices[m + 7] = 0;
matrices[m + 8] = 0;
matrices[m + 9] = 0;
matrices[m + 10] = 1;
matrices[m + 11] = 0;
matrices[m + 12] = positions[index * 3] - origin.x;
matrices[m + 13] = positions[index * 3 + 1] - origin.y;
matrices[m + 14] = positions[index * 3 + 2] - origin.z;
matrices[m + 15] = 1;
const s = index * 4;
style[s] = rotations[index];
const rotation = rotations[index];
style[s] = rotation;
style[s + 1] = shows[index] === 1 ? scales[index] : 0;
style[s + 2] = 0;
style[s + 3] = 0;
style[s + 2] = Math.cos(rotation);
style[s + 3] = Math.sin(rotation);
colorOut[s] = colors[s];
colorOut[s + 1] = colors[s + 1];
colorOut[s + 2] = colors[s + 2];
+11 -3
View File
@@ -61,6 +61,12 @@ test('instances pack as offsets from the origin, style, colour and uv', () => {
colors: new Float32Array(12),
uvs: new Float32Array(12),
};
for (let index = 0; index < 3; index++) {
out.matrices[index * 16] = 1;
out.matrices[index * 16 + 5] = 1;
out.matrices[index * 16 + 10] = 1;
out.matrices[index * 16 + 15] = 1;
}
const uvRects = atlasUvRects(options.sprite.atlas.cells, options.cellKeys);
packInstances(store, 0, 2, { x: 1000, y: 2000, z: 3000 }, uvRects, out);
// Identity matrix with the offset as translation.
@@ -69,9 +75,11 @@ test('instances pack as offsets from the origin, style, colour and uv', () => {
[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.5, 0, 0, 1],
);
assert.deepEqual([...out.matrices.subarray(28, 31)], [1, 1, 1]);
// rotation, scale (0 when hidden), and two spare lanes.
assert.deepEqual([...out.style.subarray(0, 4)], [1, 2, 0, 0]);
assert.deepEqual([...out.style.subarray(4, 8)], [0, 0, 0, 0]);
// rotation, scale (0 when hidden), and cached cosine/sine.
assert.deepEqual([...out.style.subarray(0, 2)], [1, 2]);
assert.ok(Math.abs(out.style[2] - Math.cos(1)) < 1e-7);
assert.ok(Math.abs(out.style[3] - Math.sin(1)) < 1e-7);
assert.deepEqual([...out.style.subarray(4, 8)], [0, 0, 1, 0]);
assert.deepEqual([...out.colors.subarray(4, 8)], [1, 0, 0, 0.5]);
assert.deepEqual([...out.uvs.subarray(0, 4)], [0, 0, 1, 1]);
assert.deepEqual([...out.uvs.subarray(4, 8)], [0, 0.5, 0.5, 1]);
+31 -4
View File
@@ -9,12 +9,16 @@
// pass costs nothing while no geometry is visible. Models retain scene
// depth; their lit display colour is resolved here so HDR effects and MSAA
// cannot spread a tint into neighbouring pixels.
// FIRMS opts into a separate native-stage overlay before sharpening/AA.
// Only that stage owns an encoded-RGBA8 blend target; W1 stays in place.
import { EffectDesc, Pass as NavaraPass } from '@navaramap/three';
import { GevOverlayPass } from './overlayPass.js';
import { RGBADepthPacking, Scene } from 'three';
export const OVERLAY_EFFECT_KEY = 'gevOverlay';
const PRE_SHARPEN_KEY = 'gevPreSharpenOverlay';
const registeredViews = new WeakMap();
class GevOverlayEffectDesc extends EffectDesc {
static key = OVERLAY_EFFECT_KEY;
@@ -24,7 +28,7 @@ class GevOverlayEffectDesc extends EffectDesc {
constructor(view, ctx, config) {
super(view, ctx, config);
this.overlay = config[OVERLAY_EFFECT_KEY].overlay;
this.overlay = config[this.constructor.key].overlay;
this.overlay.attach(ctx);
}
@@ -49,14 +53,35 @@ class GevOverlayEffectDesc extends EffectDesc {
}
}
class GevPreSharpenOverlayEffectDesc extends GevOverlayEffectDesc {
static key = PRE_SHARPEN_KEY;
static insertAfter = ['toneMapping'];
static insertBefore = [
'gevSharpen',
'smaa',
'fxaa',
OVERLAY_EFFECT_KEY,
'final',
];
}
/**
* The overlay of one view: add(object, onFrame) puts a three object in the
* overlay scene (onFrame runs each drawn frame before it draws);
* registerPickable/unregisterPickable reach Navara's pick pass.
* @param {object} view an initialised ThreeView
*/
export function createNavaraOverlay(view) {
view.registerEffect(OVERLAY_EFFECT_KEY, GevOverlayEffectDesc);
export function createNavaraOverlay(view, { beforeSharpen = false } = {}) {
const key = beforeSharpen ? PRE_SHARPEN_KEY : OVERLAY_EFFECT_KEY;
let registered = registeredViews.get(view);
if (!registered) registeredViews.set(view, (registered = new Set()));
if (!registered.has(key)) {
view.registerEffect(
key,
beforeSharpen ? GevPreSharpenOverlayEffectDesc : GevOverlayEffectDesc,
);
registered.add(key);
}
const scene = new Scene();
const frameHooks = new Map();
const lights = new Map();
@@ -64,9 +89,11 @@ export function createNavaraOverlay(view) {
const pendingPicks = new Map();
const overlay = {
scene,
displayBlend: beforeSharpen,
modelTints: new Set(),
uniforms: {
uGevModelExposure: { value: 1 },
uGevDisplayBlend: { value: 0 },
tGevSceneDepth: { value: null },
uGevResolution: { value: [0, 0] },
},
@@ -119,7 +146,7 @@ export function createNavaraOverlay(view) {
},
};
const handle = view.addEffect({
[OVERLAY_EFFECT_KEY]: { overlay },
[key]: { overlay },
visible: true,
});
return {
+4
View File
@@ -18,6 +18,10 @@ export const OVERLAY_DEPTH_FRAGMENT = /* glsl */ `
uniform sampler2D tGevSceneDepth;
uniform vec2 uGevResolution;
uniform float uGevDepthMode;
uniform float uGevDisplayBlend;
vec4 gevOverlayDisplayColor(vec4 color) {
return uGevDisplayBlend>0.5 ? sRGBTransferOETF(color) : color;
}
varying float vGevDepthFree;
bool gevOverlayHidden() {
if (uGevDepthMode < 0.5 || vGevDepthFree > 0.5 || uGevResolution.x < 1.0)
+206 -3
View File
@@ -1,23 +1,226 @@
import { Pass } from 'postprocessing';
import { FullScreenQuad } from 'three/addons/postprocessing/Pass.js';
import {
Color,
HalfFloatType,
ShaderMaterial,
WebGLRenderTarget,
NearestFilter,
NoColorSpace,
SRGBColorSpace,
} from 'three';
const vertexShader = `
varying vec2 vUv;
void main() { vUv=uv; gl_Position=vec4(position.xy,0.0,1.0); }
`;
const transfer = `
vec3 gevDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 gevLinear(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
`;
function displayResources(renderer) {
// Keep only the native-stage glyphs in this target. The final composite
// blends them over the input once, avoiding a full-screen encode copy.
const target = new WebGLRenderTarget(1, 1, {
type: HalfFloatType,
minFilter: NearestFilter,
magFilter: NearestFilter,
depthBuffer: false,
stencilBuffer: false,
});
target.texture.colorSpace = NoColorSpace;
const composite = new ShaderMaterial({
vertexShader,
fragmentShader: `uniform sampler2D image; uniform sampler2D glyphs; uniform bool encodeOutput; varying vec2 vUv; ${transfer}
void main(){vec4 bg=texture2D(image,vUv);vec4 glyph=texture2D(glyphs,vUv);if(glyph.a<=0.0){gl_FragColor=vec4(encodeOutput?gevDisplay(bg.rgb):bg.rgb,bg.a);return;}vec3 display=gevDisplay(bg.rgb)*(1.0-glyph.a)+glyph.rgb;display=floor(clamp(display,0.0,1.0)*255.0+0.5)/255.0;vec3 linear=gevLinear(display);gl_FragColor=vec4(encodeOutput?gevDisplay(linear):linear,bg.a);}`,
uniforms: {
image: { value: null },
glyphs: { value: null },
encodeOutput: { value: false },
},
depthTest: false,
depthWrite: false,
toneMapped: false,
});
const quad = new FullScreenQuad(composite);
const canvas = renderer.domElement;
const invalidate = () => {
target.signature = null;
};
canvas?.addEventListener('webglcontextlost', invalidate);
canvas?.addEventListener('webglcontextrestored', invalidate);
return { target, composite, quad, canvas, invalidate };
}
function staticSpriteSignature(scene, camera, renderer, width, height) {
if (
scene.children.length === 0 ||
scene.background ||
scene.fog ||
scene.overrideMaterial
)
return null;
// The globe render updates the shared camera before this pass. Mover origins
// update their world matrices when placed, so a cache hit needn't traverse
// the overlay scene just to compare its static state.
const snapshot = [
width,
height,
renderer.getPixelRatio(),
renderer.toneMapping,
renderer.toneMappingExposure,
camera.layers.mask,
...camera.matrixWorldInverse.elements,
...camera.projectionMatrix.elements,
];
for (const mesh of scene.children) {
if (!mesh.isInstancedMesh || mesh.userData.gevStaticSprite !== true)
return null;
const material = mesh.material,
texture = material?.map;
if (!texture) return null;
snapshot.push(
mesh.id,
mesh.visible ? 1 : 0,
mesh.layers.mask,
mesh.count,
mesh.renderOrder,
mesh.instanceMatrix.version,
mesh.instanceColor?.version ?? -1,
...mesh.matrixWorld.elements,
material.id,
material.version,
material.opacity,
material.alphaTest,
material.side,
material.blending,
material.transparent ? 1 : 0,
material.toneMapped ? 1 : 0,
material.color.r,
material.color.g,
material.color.b,
texture.id,
texture.version,
texture.colorSpace,
texture.format,
texture.type,
texture.minFilter,
texture.magFilter,
...texture.matrix.elements,
mesh.geometry.drawRange.start,
mesh.geometry.drawRange.count,
mesh.geometry.index?.version ?? -1,
mesh.geometry.index?.count ?? 0,
);
for (const [name, attribute] of Object.entries(mesh.geometry.attributes))
snapshot.push(name, attribute.version, attribute.count);
}
return snapshot;
}
function signatureMatches(current, previous) {
return (
current &&
previous &&
current.length === previous.length &&
current.every((value, index) => value === previous[index])
);
}
export class GevOverlayPass extends Pass {
constructor(scene, camera, overlay) {
super('GevOverlayPass', scene, camera);
this.needsSwap = false;
this.overlay = overlay;
this.display = null;
}
render(renderer, inputBuffer) {
render(renderer, inputBuffer, outputBuffer) {
this.needsSwap = false;
if (!this.overlay.hasDrawables()) return;
const resolution = this.overlay.uniforms.uGevResolution.value;
resolution[0] = inputBuffer?.width ?? 0;
resolution[1] = inputBuffer?.height ?? 0;
if (!this.overlay.displayBlend) {
const autoClear = renderer.autoClear;
renderer.autoClear = false;
try {
renderer.setRenderTarget(this.renderToScreen ? null : inputBuffer);
renderer.render(this.scene, this.camera);
} finally {
renderer.autoClear = autoClear;
}
return;
}
const display = (this.display ??= displayResources(renderer));
const signature = staticSpriteSignature(
this.scene,
this.camera,
renderer,
resolution[0],
resolution[1],
);
const targetWidth = resolution[0];
const targetHeight = resolution[1];
if (
display.target.width !== targetWidth ||
display.target.height !== targetHeight
) {
display.target.setSize(targetWidth, targetHeight);
display.signature = null;
}
const autoClear = renderer.autoClear;
const output = this.renderToScreen ? null : outputBuffer;
this.needsSwap = !this.renderToScreen;
const blend = this.overlay.uniforms.uGevDisplayBlend;
const reuseGlyphs = signatureMatches(signature, display.signature);
const clearColor = reuseGlyphs ? null : renderer.getClearColor(new Color());
const clearAlpha = reuseGlyphs ? null : renderer.getClearAlpha();
renderer.autoClear = false;
try {
renderer.setRenderTarget(this.renderToScreen ? null : inputBuffer);
renderer.render(this.scene, this.camera);
if (!reuseGlyphs) {
renderer.setClearColor(0x000000, 0);
renderer.setRenderTarget(display.target);
renderer.clear(true, false, false);
blend.value = 1;
renderer.render(this.scene, this.camera);
blend.value = 0;
display.signature = signature;
}
display.composite.uniforms.image.value = inputBuffer.texture;
display.composite.uniforms.glyphs.value = display.target.texture;
display.composite.uniforms.encodeOutput.value =
this.renderToScreen && renderer.outputColorSpace === SRGBColorSpace;
renderer.setRenderTarget(output);
display.quad.material = display.composite;
display.quad.render(renderer);
} finally {
if (!reuseGlyphs) {
blend.value = 0;
renderer.setClearColor(clearColor, clearAlpha);
}
renderer.setRenderTarget(output);
renderer.autoClear = autoClear;
}
}
dispose() {
if (this.display) {
this.display.canvas?.removeEventListener(
'webglcontextlost',
this.display.invalidate,
);
this.display.canvas?.removeEventListener(
'webglcontextrestored',
this.display.invalidate,
);
this.display.target.dispose();
this.display.composite.dispose();
this.display.quad.dispose();
this.display = null;
}
super.dispose();
}
}
+210 -29
View File
@@ -1,43 +1,224 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Scene, PerspectiveCamera } from 'three';
import {
Scene,
PerspectiveCamera,
HalfFloatType,
NoColorSpace,
SRGBColorSpace,
InstancedMesh,
MeshBasicMaterial,
PlaneGeometry,
Texture,
} from 'three';
import { GevOverlayPass } from './overlayPass.js';
test('overlay draws in place only with visible objects and restores renderer state on failure', () => {
let active = false;
function harness() {
let active = false,
target = null,
clearColor = 0x123456,
clearAlpha = 1;
const overlay = {
displayBlend: true,
hasDrawables: () => active,
uniforms: { uGevResolution: { value: [0, 0] } },
uniforms: {
uGevResolution: { value: [0, 0] },
uGevDisplayBlend: { value: 0 },
},
};
const pass = new GevOverlayPass(
new Scene(),
new PerspectiveCamera(),
overlay,
);
const input = { width: 1280, height: 800 };
let target,
renders = 0;
const scene = new Scene(),
pass = new GevOverlayPass(scene, new PerspectiveCamera(), overlay),
draws = [];
const renderer = {
autoClear: true,
setRenderTarget: (value) => {
outputColorSpace: SRGBColorSpace,
getPixelRatio: () => 1,
getClearColor(color) {
return color.set(clearColor);
},
getClearAlpha() {
return clearAlpha;
},
setClearColor(color, alpha) {
clearColor = color.isColor ? color.getHex() : color;
clearAlpha = alpha;
},
clear() {},
get clearAlpha() {
return clearAlpha;
},
setRenderTarget(value) {
target = value;
},
render: () => {
renders++;
render(object) {
draws.push({
object,
target,
displayBlend: overlay.uniforms.uGevDisplayBlend.value,
});
},
};
pass.render(renderer, input);
assert.equal(renders, 0);
active = true;
pass.render(renderer, input);
assert.equal(target, input);
assert.equal(renders, 1);
assert.equal(pass.needsSwap, false);
assert.equal(renderer.autoClear, true);
assert.deepEqual(overlay.uniforms.uGevResolution.value, [1280, 800]);
renderer.render = () => {
throw new Error('render failed');
return {
overlay,
scene,
pass,
draws,
renderer,
setActive(value) {
active = value;
},
get target() {
return target;
},
};
assert.throws(() => pass.render(renderer, input), /render failed/);
assert.equal(renderer.autoClear, true);
pass.dispose();
}
test('empty overlays allocate no display target or draw calls', () => {
const h = harness();
h.pass.render(h.renderer, { width: 1280, height: 800, texture: {} }, {});
assert.equal(h.pass.display, null);
assert.deepEqual(h.draws, []);
assert.equal(h.renderer.autoClear, true);
h.pass.dispose();
});
test('display overlay composites encoded glyphs once into the next frame', () => {
const h = harness(),
input = {
width: 1280,
height: 800,
samples: 4,
texture: { name: 'linear frame' },
},
output = { texture: { name: 'next frame' } };
h.setActive(true);
h.pass.render(h.renderer, input, output);
const display = h.pass.display;
assert.equal(h.draws.length, 2);
assert.equal(h.draws[0].object, h.scene);
assert.equal(h.draws[0].target, display.target);
assert.equal(h.draws[1].object, display.quad._mesh);
assert.equal(h.draws[1].target, output);
assert.deepEqual(
h.draws.map((draw) => draw.displayBlend),
[1, 0],
);
assert.equal(h.overlay.uniforms.uGevDisplayBlend.value, 0);
assert.equal(
display.target.samples,
0,
'native-stage alpha-masked glyphs need no second multisample raster',
);
assert.equal(display.target.texture.type, HalfFloatType);
assert.equal(display.target.texture.colorSpace, NoColorSpace);
assert.equal(display.target.depthBuffer, false);
assert.deepEqual(h.overlay.uniforms.uGevResolution.value, [1280, 800]);
assert.equal(h.pass.needsSwap, true);
assert.equal(h.renderer.autoClear, true);
assert.equal(display.composite.uniforms.image.value, input.texture);
assert.equal(display.composite.uniforms.glyphs.value, display.target.texture);
h.pass.render(h.renderer, input, output);
assert.equal(h.pass.display, display);
input.width = 640;
h.pass.render(h.renderer, input, output);
assert.equal(h.pass.display, display);
assert.equal(display.target.width, 640);
h.pass.renderToScreen = true;
h.pass.render(h.renderer, input, output);
assert.equal(h.target, null);
assert.equal(display.composite.uniforms.encodeOutput.value, true);
let sampleResets = 0;
display.target.addEventListener('dispose', () => sampleResets++);
input.samples = 0;
h.pass.render(h.renderer, input, output);
assert.equal(display.target.samples, 0);
assert.equal(
sampleResets,
0,
'changing scene raster samples leaves the glyph target unchanged',
);
let targetDisposals = 0,
materialDisposals = 0;
display.target.addEventListener('dispose', () => targetDisposals++);
display.composite.addEventListener('dispose', () => materialDisposals++);
h.pass.dispose();
h.pass.dispose();
assert.equal(targetDisposals, 1);
assert.equal(materialDisposals, 1);
assert.equal(h.pass.display, null);
});
test('overlay restores blend flag, destination and autoClear after a draw failure', () => {
const h = harness(),
input = { width: 1280, height: 800, texture: {} },
output = {};
h.setActive(true);
const render = h.renderer.render;
h.renderer.render = (object) => {
if (object === h.scene) throw new Error('render failed');
render(object);
};
assert.throws(
() => h.pass.render(h.renderer, input, output),
/render failed/,
);
assert.equal(h.renderer.autoClear, true);
assert.equal(h.overlay.uniforms.uGevDisplayBlend.value, 0);
assert.equal(h.target, output);
assert.equal(h.renderer.clearAlpha, 1);
h.pass.dispose();
});
test('static sprite glyphs reuse their target until instance data changes', () => {
const h = harness(),
input = { width: 1280, height: 800, texture: {} },
output = {},
texture = new Texture(),
mesh = new InstancedMesh(
new PlaneGeometry(1, 1),
new MeshBasicMaterial({ map: texture }),
1,
);
mesh.userData.gevStaticSprite = true;
mesh.count = 1;
mesh.instanceMatrix.needsUpdate = true;
h.scene.add(mesh);
h.setActive(true);
h.pass.render(h.renderer, input, output);
h.pass.render(h.renderer, input, output);
assert.equal(h.draws.filter((draw) => draw.object === h.scene).length, 1);
mesh.instanceMatrix.needsUpdate = true;
h.pass.render(h.renderer, input, output);
assert.equal(h.draws.filter((draw) => draw.object === h.scene).length, 2);
h.pass.camera.position.x = 1;
h.pass.camera.updateMatrixWorld();
h.pass.render(h.renderer, input, output);
assert.equal(h.draws.filter((draw) => draw.object === h.scene).length, 3);
h.pass.dispose();
texture.dispose();
mesh.geometry.dispose();
mesh.material.dispose();
});
test('empty display overlay does not swap an unwritten frame buffer', () => {
const h = harness();
h.pass.render(h.renderer, { width: 1280, height: 800, texture: {} }, {});
assert.equal(h.pass.needsSwap, false);
assert.deepEqual(h.draws, []);
h.pass.dispose();
});
test('ordinary W1 overlays keep the direct pass without a display copy or target', () => {
const h = harness(),
input = { width: 1280, height: 800, samples: 4, texture: {} };
h.overlay.displayBlend = false;
h.setActive(true);
h.pass.render(h.renderer, input);
assert.equal(h.pass.display, null);
assert.equal(h.draws.length, 1);
assert.equal(h.draws[0].object, h.scene);
assert.equal(h.target, input);
assert.equal(h.renderer.autoClear, true);
h.renderer.render = () => {
throw new Error('ordinary draw failed');
};
assert.throws(() => h.pass.render(h.renderer, input), /ordinary draw failed/);
assert.equal(h.renderer.autoClear, true);
h.pass.dispose();
});
+17 -15
View File
@@ -3,17 +3,11 @@
// (src/ui/visualPresets.js SHARPEN_SHADER): a 3×3 box blur, then
// center + (center − blur) × amount, clamped to [0, 1].
//
// Where it sits: right after tone mapping (after gev's overlay of sprites,
// points and lines, which Cesium's sharpen stage also sharpens) and before
// SMAA or FXAA (before `final` in a look without them), as Cesium runs its
// custom stages after
// its tone mapping and before FXAA. After tone mapping the colours are the
// display's [0, 1] range, so highlights do not blow up; anti-aliasing after
// it smooths the stair steps sharpening brings out. Cesium sharpens
// gamma-encoded colours (its tone mapping ends with czm_inverseGamma) while
// Navara's buffers stay linear until the final copy, so the mask encodes
// with Cesium's gamma 2.2 and decodes after: the same amount sharpens the
// same.
// After tone mapping and native-stage fields such as FIRMS, before AA and
// the ordinary W1 display overlay. Native SHARPEN_SHADER filters encoded
// display RGB. Navara buffers stay linear, so use the exact sRGB transfer
// before the box mask and its inverse afterward. A gamma-2.2 approximation
// changes the colors of small, high-contrast glows even at the same amount.
//
// The sky is left alone: Navara's clouds are dithered (a fine grain at the
// `low` quality the look uses), and the mask turned that grain into visible
@@ -39,11 +33,19 @@ uniform bool uSkipSky;
const float SKY_DEPTH = 0.99999;
vec3 encodeGamma(vec3 color) {
return pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
return mix(12.92*color,1.055*pow(max(color,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),color));
}
vec3 decodeGamma(vec3 color) {
return mix(color/12.92,pow(max((color+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),color));
}
vec3 encoded8(vec3 color) {
return floor(clamp(color, 0.0, 1.0) * 255.0 + 0.5) / 255.0;
}
vec3 sampleGamma(vec2 uv) {
return encodeGamma(texture2D(inputBuffer, uv).rgb);
return encoded8(encodeGamma(texture2D(inputBuffer, uv).rgb));
}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
@@ -53,7 +55,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
return;
}
vec2 texel = texelSize;
vec3 center = encodeGamma(inputColor.rgb);
vec3 center = encoded8(encodeGamma(inputColor.rgb));
vec3 blur = (
sampleGamma(uv + vec2(-texel.x, -texel.y)) +
sampleGamma(uv + vec2( 0.0, -texel.y)) +
@@ -66,7 +68,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
sampleGamma(uv + vec2( texel.x, texel.y))
) / 9.0;
vec3 sharpened = clamp(center + (center - blur) * amount, 0.0, 1.0);
outputColor = vec4(pow(sharpened, vec3(2.2)), inputColor.a);
outputColor = vec4(decodeGamma(sharpened), inputColor.a);
}
`;
+8 -33
View File
@@ -2,15 +2,13 @@
// Navara post-effect. Native ground colour blends in display space; doing
// it explicitly preserves translucent colours and overlap order.
import { Effect, EffectAttribute } from 'postprocessing';
import { Uniform } from 'three';
import {
DataTexture,
FloatType,
RGBAFormat,
NearestFilter,
Matrix4,
Uniform,
RGBADepthPacking,
} from 'three';
createGroundTexture as texture,
createGroundStorage as storage,
groundSurfaceUniforms,
updateGroundSurface,
} from '../../shared/render/navaraGroundSurface.js';
import * as geo from '../../../geo/index.js';
import {
DISC_EDGES,
@@ -103,19 +101,6 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
outputColor=vec4(toLinear(color),inputColor.a);
}
`;
function texture(data, width, height) {
const t = new DataTexture(data, width, height, RGBAFormat, FloatType);
t.minFilter = NearestFilter;
t.magFilter = NearestFilter;
t.needsUpdate = true;
return t;
}
function storage(texels) {
const width = Math.min(1024, Math.max(1, texels));
const height = Math.max(1, Math.ceil(texels / width));
const data = new Float32Array(width * height * 4);
return { data, width, height, texture: texture(data, width, height) };
}
function buildState(discs) {
const frames = discs.map(discFrame);
const edges = storage(discs.length * DISC_EDGES);
@@ -225,8 +210,7 @@ function groundEffectClass(EffectDesc, NavaraEffect) {
// Keep a separate pass: inputColor is the already tone-mapped frame.
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
['tAllDepth', new Uniform(null)],
['tSurfaceDepth', new Uniform(null)],
...groundSurfaceUniforms(),
['tEdges', new Uniform(null)],
['tFrames', new Uniform(null)],
['tGrid', new Uniform(null)],
@@ -234,8 +218,6 @@ function groundEffectClass(EffectDesc, NavaraEffect) {
['uEdgeSize', new Uniform([1, 1])],
['uFrameSize', new Uniform([1, 1])],
['uCandidateSize', new Uniform([1, 1])],
['uInverseProjection', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)],
]),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
@@ -245,14 +227,9 @@ function groundEffectClass(EffectDesc, NavaraEffect) {
camera = this.view.camera.raw,
mrt = this.find('mrt');
if (!this.effect || !camera || !state) return;
if (mrt?.depthBufferPacking !== RGBADepthPacking)
throw new Error(
'Earthquake classification requires packed scene depth',
);
updateGroundSurface(this.effect, camera, this.ctx, mrt);
state.update(camera, this.ctx);
const u = this.effect.uniforms;
u.get('tAllDepth').value = mrt.depthBuffer;
u.get('tSurfaceDepth').value = this.ctx.getRenderTarget().depthTexture;
u.get('tEdges').value = state.edges.texture;
u.get('uEdgeSize').value = [state.edges.width, state.edges.height];
u.get('tFrames').value = state.packed.texture;
@@ -263,8 +240,6 @@ function groundEffectClass(EffectDesc, NavaraEffect) {
state.candidates.width,
state.candidates.height,
];
u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse);
u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
}
onDestroy() {
this.effect?.dispose();
@@ -78,65 +78,7 @@ export function discFrame(disc) {
};
}
/** Camera-relative transforms in JS float64; only nearby metres enter GLSL. */
export function viewFrame(frame, matrix, out) {
const m = matrix.elements;
const transform = (p, offset, translate) => {
out[offset] =
m[0] * p.x + m[4] * p.y + m[8] * p.z + (translate ? m[12] : 0);
out[offset + 1] =
m[1] * p.x + m[5] * p.y + m[9] * p.z + (translate ? m[13] : 0);
out[offset + 2] =
m[2] * p.x + m[6] * p.y + m[10] * p.z + (translate ? m[14] : 0);
};
transform(frame.center, 0, true);
transform(frame.east, 3, false);
transform(frame.north, 6, false);
transform(frame.up, 9, false);
return out;
}
/** Conservative clip bounds, including a camera inside/crossing the volume. */
export function discClipBounds(frame, view, projection, out) {
const p = projection.elements;
let x0 = 1,
y0 = 1,
x1 = -1,
y1 = -1,
front = 0,
behind = 0;
for (let i = 0; i < 8; i++) {
const x = (i & 1 ? 1 : -1) * frame.radius;
const y = (i & 2 ? 1 : -1) * frame.radius;
const z = i & 4 ? frame.ceiling : frame.floor;
const vx = view[0] + view[3] * x + view[6] * y + view[9] * z;
const vy = view[1] + view[4] * x + view[7] * y + view[10] * z;
const vz = view[2] + view[5] * x + view[8] * y + view[11] * z;
const w = p[3] * vx + p[7] * vy + p[11] * vz + p[15];
if (w <= 0) {
behind++;
continue;
}
front++;
const cx = (p[0] * vx + p[4] * vy + p[8] * vz + p[12]) / w;
const cy = (p[1] * vx + p[5] * vy + p[9] * vz + p[13]) / w;
x0 = Math.min(x0, cx);
y0 = Math.min(y0, cy);
x1 = Math.max(x1, cx);
y1 = Math.max(y1, cy);
}
if (front && behind) {
x0 = -1;
y0 = -1;
x1 = 1;
y1 = 1;
}
if (!front || x1 < -1 || y1 < -1 || x0 > 1 || y0 > 1) out.fill(2);
else {
out[0] = Math.max(-1, x0);
out[1] = Math.max(-1, y0);
out[2] = Math.min(1, x1);
out[3] = Math.min(1, y1);
}
return out;
}
export {
viewFrame,
groundClipBounds as discClipBounds,
} from '../../shared/render/navaraGroundGeometry.js';
+5 -69
View File
@@ -1,69 +1,5 @@
// A screen grid of conservative projected volumes. Prefix-sum storage has
// no feature/cell cap: overlapping discs retain their source order.
export const GRID_COLS = 32;
export const GRID_ROWS = 20;
export function createDiscGrid(count) {
const counts = new Uint32Array(GRID_COLS * GRID_ROWS);
const offsets = new Uint32Array(counts.length);
const cursor = new Uint32Array(counts.length);
const bounds = new Float64Array(count * 4);
const ranges = new Int32Array(count * 4);
let indices = new Uint32Array(0);
const clamp = (value, max) => Math.min(max - 1, Math.max(0, value));
return {
bounds,
counts,
offsets,
get indices() {
return indices;
},
pack() {
counts.fill(0);
for (let i = 0; i < count; i++) {
const b = i * 4;
const valid =
bounds[b] <= bounds[b + 2] &&
bounds[b + 1] <= bounds[b + 3] &&
bounds[b] <= 1 &&
bounds[b + 1] <= 1 &&
bounds[b + 2] >= -1 &&
bounds[b + 3] >= -1;
ranges[b] = valid
? clamp(Math.floor((bounds[b] * 0.5 + 0.5) * GRID_COLS), GRID_COLS)
: -1;
ranges[b + 1] = clamp(
Math.floor((bounds[b + 1] * 0.5 + 0.5) * GRID_ROWS),
GRID_ROWS,
);
ranges[b + 2] = clamp(
Math.floor((bounds[b + 2] * 0.5 + 0.5) * GRID_COLS),
GRID_COLS,
);
ranges[b + 3] = clamp(
Math.floor((bounds[b + 3] * 0.5 + 0.5) * GRID_ROWS),
GRID_ROWS,
);
if (!valid) continue;
for (let y = ranges[b + 1]; y <= ranges[b + 3]; y++)
for (let x = ranges[b]; x <= ranges[b + 2]; x++)
counts[y * GRID_COLS + x]++;
}
let total = 0;
for (let i = 0; i < counts.length; i++) {
offsets[i] = total;
total += counts[i];
cursor[i] = offsets[i];
}
if (indices.length < total)
indices = new Uint32Array(Math.max(total, indices.length * 2));
for (let i = 0; i < count; i++) {
const b = i * 4;
if (ranges[b] < 0) continue;
for (let y = ranges[b + 1]; y <= ranges[b + 3]; y++)
for (let x = ranges[b]; x <= ranges[b + 2]; x++)
indices[cursor[y * GRID_COLS + x]++] = i;
}
return total;
},
};
}
export {
GRID_COLS,
GRID_ROWS,
createGroundGrid as createDiscGrid,
} from '../../shared/render/navaraGroundGrid.js';
+1
View File
@@ -98,6 +98,7 @@ export function createLifecycle({
layerState._fires = [];
layerState._firesByFrp = [];
layerState._cellCacheByGrid.clear();
layerState.glowSpriteCache.clear();
layerState._count = 0;
layerState._cellCount = 0;
layerState._lastUpdate = null;
+32
View File
@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFirmsState } from './state.js';
import { createLifecycle } from './lifecycle.js';
test('destroy releases the owned generated PNG cache even when the layer facade remains referenced', () => {
const state = createFirmsState({ services: {}, config: {} });
state.glowSpriteCache.set(
'red:28',
'data:image/png;base64,' + 'x'.repeat(65536),
);
const noop = () => {};
const lifecycle = createLifecycle({
layerState: state,
services: {
picking: { unregisterPickOwner: noop },
context: { clearSelectedEntityContextForLayer: noop },
},
components: {
viewport: { removeLodWatcher: noop, removeMoveEndWatcher: noop },
selection: { removeClickHandler: noop, clearContextRegistrations: noop },
rendering: { removeDetectionCollections: noop },
},
config: {
id: 'firms-cache',
overlayHost: { clearSource: noop, setVisible: noop },
},
});
lifecycle.methods.destroy();
assert.equal(state.glowSpriteCache.size, 0);
lifecycle.methods.destroy();
assert.equal(state.glowSpriteCache.size, 0);
});
+411
View File
@@ -0,0 +1,411 @@
// FIRMS terrain/tiles classification and actual engine mover glows.
import { Effect, EffectAttribute } from 'postprocessing';
import { Matrix4, Uniform } from 'three';
import { createEngineLayerView } from '../../shared/render/engineView.js';
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
import { LOD_LEVELS } from '../policy.js';
import { createFirmsSpriteBank } from './navaraSprites.js';
import { rectangleFrame } from './navaraGeometry.js';
import {
viewFrame,
groundClipBounds,
} from '../../shared/render/navaraGroundGeometry.js';
import {
createGroundGrid,
GRID_COLS,
GRID_ROWS,
} from '../../shared/render/navaraGroundGrid.js';
import {
createGroundTexture,
createGroundStorage,
groundSurfaceUniforms,
updateGroundSurface,
} from '../../shared/render/navaraGroundSurface.js';
const KEY = 'gevFirmsGround';
const registeredViews = new WeakSet();
const fragmentShader = /* glsl */ `
uniform sampler2D tAllDepth;
uniform sampler2D tSurfaceDepth;
uniform sampler2D tCells;
uniform sampler2D tGrid;
uniform sampler2D tCandidates;
uniform vec2 uCellSize;
uniform vec2 uCandidateSize;
uniform mat4 uInverseProjection;
uniform mat4 uInverseView;
uniform float uLogDepthFC;
vec4 atSlot(sampler2D image,vec2 size,float slot) {
return texture2D(image,(vec2(mod(slot,size.x),floor(slot/size.x))+0.5)/size);
}
vec3 toDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 toLinear(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
vec3 pixelPlane(float distance,vec3 normal,vec3 dx,vec3 dy) {
return vec3(distance,dot(normal,dx),dot(normal,dy));
}
float rectangleCoverage(vec3 west,vec3 east,vec3 south,vec3 north) {
vec3 edges[4];
edges[0]=west;edges[1]=east;edges[2]=south;edges[3]=north;
float coverage=1.0;
int partial=0;
for(int i=0;i<4;i++) {
float width=abs(edges[i].y)+abs(edges[i].z);
coverage=min(coverage,clamp(edges[i].x/max(width,0.001)+0.5,0.0,1.0));
if(coverage<=0.0) return 0.0;
if(edges[i].x<=width*0.5) partial++;
}
if(partial<2) return coverage;
// Intersect the SAME pixel square with all sides. Minimum soft-edge
// coverage creates long miter halos at acute/polar rectangle corners.
vec2 polygon[8],clipped[8];
polygon[0]=vec2(-0.5,-0.5);polygon[1]=vec2(0.5,-0.5);
polygon[2]=vec2(0.5,0.5);polygon[3]=vec2(-0.5,0.5);
int count=4;
for(int side=0;side<4;side++) {
int nextCount=0;
vec2 previous=polygon[count-1];
float before=edges[side].x+dot(edges[side].yz,previous);
for(int i=0;i<8;i++) {
if(i>=count) break;
vec2 current=polygon[i];
float after=edges[side].x+dot(edges[side].yz,current);
if((before>0.0&&after<0.0)||(before<0.0&&after>0.0))
clipped[nextCount++]=mix(previous,current,before/(before-after));
if(after>=0.0) clipped[nextCount++]=current;
previous=current;before=after;
}
count=nextCount;
if(count<3) return 0.0;
for(int i=0;i<8;i++) { if(i>=count) break;polygon[i]=clipped[i]; }
}
float area=0.0;
vec2 previous=polygon[count-1];
for(int i=0;i<8;i++) {
if(i>=count) break;
area+=previous.x*polygon[i].y-previous.y*polygon[i].x;
previous=polygon[i];
}
return clamp(abs(area)*0.5,0.0,1.0);
}
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
outputColor=inputColor;
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
vec3 point=ray.xyz*(distance/-ray.z);
// Evaluate derivatives before divergent candidate branches, as in quakes.
vec3 world=(uInverseView*vec4(point,1.0)).xyz;
vec3 dx=(uInverseView*vec4(dFdx(point),0.0)).xyz;
vec3 dy=(uInverseView*vec4(dFdy(point),0.0)).xyz;
// A quad crossing the sky has no surface derivative. Keep exact halfspaces
// there; otherwise its far-plane lane spreads cell AA along the globe limb.
float onSurface=1.0-step(1.0,depth);
float derivativeMask=1.0-step(0.5,abs(dFdx(onSurface))+abs(dFdy(onSurface)));
dx*=derivativeMask;
dy*=derivativeMask;
if(depth>=1.0) return;
vec2 tile=clamp(floor(uv*vec2(${GRID_COLS}.0,${GRID_ROWS}.0)),vec2(0.0),vec2(${GRID_COLS - 1}.0,${GRID_ROWS - 1}.0));
vec2 range=texture2D(tGrid,(tile+0.5)/vec2(${GRID_COLS}.0,${GRID_ROWS}.0)).xy;
if(range.y<0.5) return;
float surface=texture2D(tSurfaceDepth,uv).r;
if(depth<surface-2e-7) return;
float rho=length(world.xy);
vec2 radial=world.xy/max(rho,0.001);
vec3 color=toDisplay(inputColor.rgb);
for(int k=0;k<int(range.y);k++) {
float id=atSlot(tCandidates,uCandidateSize,range.x+float(k)).x;
vec4 longitude=atSlot(tCells,uCellSize,id*4.0);
vec3 south=atSlot(tCells,uCellSize,id*4.0+1.0).xyz;
vec3 north=atSlot(tCells,uCellSize,id*4.0+2.0).xyz;
vec3 westNormal=vec3(-longitude.x,longitude.y,0.0);
vec3 eastNormal=vec3(longitude.z,-longitude.w,0.0);
float coverage=rectangleCoverage(
pixelPlane(dot(westNormal,world),westNormal,dx,dy),
pixelPlane(dot(eastNormal,world),eastNormal,dx,dy),
pixelPlane(world.z*south.y-rho*south.x+south.z,vec3(-south.x*radial,south.y),dx,dy),
pixelPlane(rho*north.x-world.z*north.y-north.z,vec3(north.x*radial,-north.y),dx,dy));
if(coverage<=0.0) continue;
vec4 fill=atSlot(tCells,uCellSize,id*4.0+3.0);
color=mix(color,fill.rgb,fill.a*coverage);
}
outputColor=vec4(toLinear(color),inputColor.a);
}
`;
function buildState(cells) {
const frames = cells.map(rectangleFrame),
packed = createGroundStorage(cells.length * 4);
const grid = createGroundGrid(cells.length),
gridData = new Float32Array(GRID_COLS * GRID_ROWS * 4);
const gridTexture = createGroundTexture(gridData, GRID_COLS, GRID_ROWS);
let candidates = createGroundStorage(1);
const scratch = new Float64Array(12),
bounds = new Float64Array(4);
const lastView = new Float64Array(16).fill(NaN),
lastProjection = new Float64Array(16).fill(NaN);
let lastWidth = 0,
lastHeight = 0;
frames.forEach((frame, index) => {
const offset = index * 16,
color = cells[index].color;
packed.data.set(frame.bounds.longitude, offset);
packed.data.set(frame.bounds.south, offset + 4);
packed.data.set(frame.bounds.north, offset + 8);
packed.data.set(
[color.red, color.green, color.blue, color.alpha],
offset + 12,
);
});
return {
count: cells.length,
packed,
gridTexture,
get candidates() {
return candidates;
},
update(camera, context) {
const input = context.getInputBuffer(),
view = camera.matrixWorldInverse.elements,
projection = camera.projectionMatrix.elements;
let changed = input.width !== lastWidth || input.height !== lastHeight;
for (let i = 0; i < 16 && !changed; i++)
changed =
view[i] !== lastView[i] || projection[i] !== lastProjection[i];
if (!changed) return;
lastView.set(view);
lastProjection.set(projection);
lastWidth = input.width;
lastHeight = input.height;
frames.forEach((frame, index) => {
viewFrame(frame, camera.matrixWorldInverse, scratch);
groundClipBounds(frame, scratch, camera.projectionMatrix, bounds);
if (bounds[0] <= 1) {
bounds[0] -= 2 / Math.max(1, input.width);
bounds[2] += 2 / Math.max(1, input.width);
bounds[1] -= 2 / Math.max(1, input.height);
bounds[3] += 2 / Math.max(1, input.height);
}
grid.bounds.set(bounds, index * 4);
});
const total = grid.pack();
if (candidates.data.length / 4 < total) {
candidates.texture.dispose();
candidates = createGroundStorage(
Math.max(total, candidates.data.length / 2),
);
}
for (let i = 0; i < total; i++) candidates.data[i * 4] = grid.indices[i];
for (let i = 0; i < grid.counts.length; i++) {
gridData[i * 4] = grid.offsets[i];
gridData[i * 4 + 1] = grid.counts[i];
}
gridTexture.needsUpdate = true;
candidates.texture.needsUpdate = true;
},
dispose() {
packed.texture.dispose();
gridTexture.dispose();
candidates.texture.dispose();
},
};
}
function groundEffectClass(EffectDesc, NavaraEffect) {
return class FirmsGroundEffectDesc extends EffectDesc {
static key = KEY;
static insertAfter = ['toneMapping'];
static insertBefore = [
'gevSharpen',
'smaa',
'fxaa',
'gevPreSharpenOverlay',
'gevOverlay',
'final',
];
constructor(view, context, config) {
super(view, context, config);
this.owner = config[KEY].owner;
}
createPass() {
this.effect = new Effect('GevFirmsGround', fragmentShader, {
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms(),
['tCells', new Uniform(null)],
['tGrid', new Uniform(null)],
['tCandidates', new Uniform(null)],
['uCellSize', new Uniform([1, 1])],
['uCandidateSize', new Uniform([1, 1])],
['uInverseView', new Uniform(new Matrix4())],
]),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
}
update() {
const state = this.owner.state,
camera = this.view.camera.raw;
if (!this.effect || !state || !camera) return;
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
state.update(camera, this.ctx);
const u = this.effect.uniforms;
u.get('tCells').value = state.packed.texture;
u.get('uCellSize').value[0] = state.packed.width;
u.get('uCellSize').value[1] = state.packed.height;
u.get('tGrid').value = state.gridTexture;
u.get('tCandidates').value = state.candidates.texture;
u.get('uCandidateSize').value[0] = state.candidates.width;
u.get('uCandidateSize').value[1] = state.candidates.height;
u.get('uInverseView').value.copy(camera.matrixWorld);
}
onDestroy() {
this.effect?.dispose();
super.onDestroy();
}
};
}
export function createFirmsNavaraRenderer(
engine,
{ view: nativeView, EffectDesc, NavaraEffect, id = 'local-firms' } = {},
) {
if (!engine?.movers || !nativeView?.camera || !EffectDesc || !NavaraEffect)
throw new TypeError('FIRMS requires a Navara view');
if (!registeredViews.has(nativeView)) {
nativeView.registerEffect(KEY, groundEffectClass(EffectDesc, NavaraEffect));
registeredViews.add(nativeView);
}
let destroyed = false,
visible = false,
cells = [],
queued = false,
handle = null,
sprites = null;
const owner = { state: buildState([]) },
dataSource = { show: false },
handlers = new Set();
const adapter = createEngineLayerView(engine, {
layerId: id,
isDestroyed: () => destroyed,
});
adapter.view.camera.moveEnd = engine.camera.moveEnd;
adapter.view.scene.preRender = engine.frames.preRender;
const sync = () => {
const show = visible && owner.state.count > 0;
if (show && !handle)
handle = nativeView.addEffect({ [KEY]: { owner }, visible: true });
else handle?.update({ visible: show });
};
const scheduleCells = () => {
if (queued || destroyed) return;
queued = true;
queueMicrotask(() => {
queued = false;
if (destroyed) return;
const next = buildState(cells),
previous = owner.state;
owner.state = next;
sync();
previous.dispose();
if (visible) engine.render.request('firms-cells');
});
};
const removeSprites = () => {
sprites?.destroy();
sprites = null;
};
return {
kind: 'navara',
view: adapter.view,
dataSource,
clickType: 'click',
project: (position, result) =>
engine.projector.worldToWindow(position, result),
viewRectangle(result) {
const rect = engine.camera.viewRectangle();
return rect ? Object.assign(result ?? {}, rect) : null;
},
horizon: () => ({
isPointVisible: (position) => engine.projector.horizonVisible(position),
}),
createInputHandler() {
let remove = null;
const handler = {
setInputAction(callback) {
remove?.();
remove = selectionDispatcherFor(engine).register(id, {
owns: (pickId) => {
const collection = sprites?.collection;
if (!collection) return false;
for (let i = 0; i < collection.length; i++)
if (collection.get(i).id === pickId) return true;
return false;
},
onClick: (event) => {
if (!destroyed && visible) callback(event);
},
});
},
destroy() {
remove?.();
remove = null;
handlers.delete(handler);
},
};
handlers.add(handler);
return handler;
},
pick: (position, event) => event?.picked,
setVisible(show) {
if (destroyed) return;
const next = show !== false;
if (visible === next) return;
visible = next;
dataSource.show = next;
sprites?.setVisible(next);
sync();
engine.render.request('firms-visibility');
},
clearCells() {
if (destroyed) return;
cells = [];
scheduleCells();
},
addCell(cell) {
if (destroyed) return null;
cells.push(cell);
scheduleCells();
return cell;
},
ensureSprites() {
if (destroyed) return null;
if (!sprites) {
sprites = createFirmsSpriteBank(engine, {
id: `firms:${id}`,
capacity: Math.max(
...LOD_LEVELS.map((lod) => lod.maxDetections ?? 0),
),
});
sprites.setVisible(visible);
}
return sprites.collection;
},
addSprite(description) {
return destroyed ? null : sprites.add(description);
},
removeSprites,
onEnable() {},
destroy() {
if (destroyed) return;
destroyed = true;
visible = false;
dataSource.show = false;
cells = [];
for (const handler of [...handlers]) handler.destroy();
removeSprites();
handle?.delete();
owner.state.dispose();
adapter.destroy();
engine.render.request('firms-destroy');
},
};
}
+161
View File
@@ -0,0 +1,161 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createEngineEvent } from '../../../globe/contract.js';
import * as geo from '../../../geo/index.js';
import { createFirmsNavaraRenderer } from './navara.js';
const settle = () => new Promise((resolve) => setImmediate(resolve));
function harness() {
const effects = [],
registered = [],
requests = [],
input = new Map();
const frames = {
preUpdate: createEngineEvent(),
preRender: createEngineEvent(),
};
const position = geo.cartesian3.fromDegrees(0, 0, 4000000);
const engine = {
frames,
host: { size: () => ({ width: 1280, height: 800 }) },
movers: {
createSet() {
throw new Error('cells do not allocate sprite movers');
},
},
render: { request: (reason) => requests.push(reason) },
camera: {
livePosition: position,
moveEnd: createEngineEvent(),
viewRectangle: () => ({ west: -1, south: -0.5, east: 1, north: 0.5 }),
pose: () => ({
position,
cartographic: { longitude: 0, latitude: 0, height: 4000000 },
direction: { x: -1, y: 0, z: 0 },
up: { x: 0, y: 0, z: 1 },
right: { x: 0, y: 1, z: 0 },
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
fovy: 1,
}),
},
projector: {
worldToWindow: (point, result) =>
Object.assign(result ?? {}, { x: 10, y: 20 }),
horizonVisible: () => true,
},
input: {
on(type, listener) {
input.set(type, listener);
return () => input.delete(type);
},
},
};
const view = {
camera: {},
registerEffect: (key, desc) => registered.push([key, desc]),
addEffect(config) {
const handle = {
config,
updates: [],
deleted: 0,
update(value) {
this.updates.push(value);
},
delete() {
this.deleted++;
},
};
effects.push(handle);
return handle;
},
};
const options = { view, EffectDesc: class {}, NavaraEffect: class {} };
return {
engine,
options,
effects,
registered,
requests,
input,
frames,
renderer: createFirmsNavaraRenderer(engine, options),
};
}
const cell = {
west: 0,
south: 0,
east: 1,
north: 1,
color: { red: 1, green: 0, blue: 0, alpha: 0.3 },
};
test('ground changes are atomic, lazy and dispose replaced and destroyed GPU textures once', async () => {
const h = harness(),
r = h.renderer;
r.setVisible(true);
assert.equal(h.effects.length, 0);
r.clearCells();
r.addCell(cell);
r.addCell({ ...cell, west: 1, east: 2 });
assert.equal(h.effects.length, 0);
await settle();
assert.equal(h.effects.length, 1);
const owner = h.effects[0].config.gevFirmsGround.owner;
assert.equal(owner.state.count, 2);
let disposed = 0;
for (const t of [
owner.state.packed.texture,
owner.state.gridTexture,
owner.state.candidates.texture,
])
t.addEventListener('dispose', () => disposed++);
r.clearCells();
r.addCell(cell);
await settle();
assert.equal(owner.state.count, 1);
assert.equal(disposed, 3);
r.setVisible(false);
assert.equal(r.dataSource.show, false);
assert.deepEqual(h.effects[0].updates.at(-1), { visible: false });
r.clearCells();
r.addCell(cell);
r.destroy();
r.destroy();
const before = h.requests.length;
await settle();
assert.equal(h.requests.length, before);
assert.equal(h.effects[0].deleted, 1);
assert.equal(h.frames.preUpdate.numberOfListeners, 0);
const second = createFirmsNavaraRenderer(h.engine, h.options);
second.destroy();
assert.equal(h.registered.length, 1);
});
test('FIRMS selection uses actual engine event picks and releases its dispatcher subscriptions', () => {
const h = harness(),
r = h.renderer;
r.setVisible(true);
const handler = r.createInputHandler();
let event;
handler.setInputAction((value) => (event = value), 'click');
assert.equal(h.input.size, 4);
const picked = { id: 'sibling-contact' };
h.input.get('click')({ position: { x: 10, y: 20 }, picked, drilled: [] });
assert.equal(r.pick(event.position, event), picked);
assert.equal(r.view.camera.moveEnd, h.engine.camera.moveEnd);
assert.equal(r.view.scene.preRender, h.engine.frames.preRender);
assert.deepEqual(r.viewRectangle({}), {
west: -1,
south: -0.5,
east: 1,
north: 0.5,
});
assert.deepEqual(r.project({}, {}), { x: 10, y: 20 });
event = null;
r.setVisible(false);
h.input.get('click')({ position: { x: 10, y: 20 }, picked, drilled: [] });
assert.equal(event, null);
handler.destroy();
handler.destroy();
assert.equal(h.input.size, 0);
r.destroy();
});
+69
View File
@@ -0,0 +1,69 @@
// Exact geodetic rectangle sides, including latitude parallels at any height.
import * as geo from '../../../geo/index.js';
const ellipsoid = geo.ellipsoid.WGS84;
const A = ellipsoid.radii.x,
B = ellipsoid.radii.z;
const E2 = 1 - (B * B) / (A * A);
export function latitudeBoundary(degrees) {
const angle = geo.math.toRadians(degrees),
sin = Math.sin(angle),
cos = Math.cos(angle);
return [sin, cos, ((E2 * A) / Math.sqrt(1 - E2 * sin * sin)) * sin * cos, 0];
}
export function rectangleBoundaries({ west, south, east, north }) {
const w = geo.math.toRadians(west),
e = geo.math.toRadians(east);
return {
longitude: [Math.sin(w), Math.cos(w), Math.sin(e), Math.cos(e)],
south: latitudeBoundary(south),
north: latitudeBoundary(north),
};
}
export function rectangleDistances(bounds, point) {
const { x, y, z } = point,
rho = Math.hypot(x, y),
l = bounds.longitude;
return [
y * l[1] - x * l[0],
x * l[2] - y * l[3],
z * bounds.south[1] - rho * bounds.south[0] + bounds.south[2],
rho * bounds.north[0] - z * bounds.north[1] - bounds.north[2],
];
}
export function rectangleFrame(cell) {
let eastBound = cell.east;
if (eastBound < cell.west) eastBound += 360;
const lon = (cell.west + eastBound) / 2,
lat = (cell.south + cell.north) / 2;
const center = geo.cartesian3.fromDegrees(lon, lat);
const up = ellipsoid.geodeticSurfaceNormal(center, geo.cartesian3.create());
const angle = geo.math.toRadians(lon);
const east = geo.cartesian3.create(-Math.sin(angle), Math.cos(angle), 0);
const north = geo.cartesian3.cross(up, east, geo.cartesian3.create());
let radius = 0;
for (const latitude of [cell.south, lat, cell.north])
for (const longitude of [cell.west, lon, eastBound]) {
const p = geo.cartesian3.fromDegrees(longitude, latitude);
const dx = p.x - center.x,
dy = p.y - center.y,
dz = p.z - center.z;
radius = Math.max(
radius,
Math.hypot(
dx * east.x + dy * east.y + dz * east.z,
dx * north.x + dy * north.y + dz * north.z,
),
);
}
radius = radius * (1 + 101000 / ellipsoid.minimumRadius) + 1;
return {
center,
east,
north,
up,
radius,
floor: -100000 - ((radius * radius) / (2 * ellipsoid.minimumRadius)) * 1.1,
ceiling: 12000,
bounds: rectangleBoundaries(cell),
};
}
@@ -0,0 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
rectangleBoundaries,
rectangleDistances,
rectangleFrame,
} from './navaraGeometry.js';
const cases = [];
for (const lat of [-90, -80, -45, 0, 45, 80, 88])
for (const west of [-180, -123, 0, 178])
for (const width of [1, 2])
cases.push({
west,
south: lat,
east: west + width,
north: Math.min(90, lat + width),
});
test('geodetic cell sides contain public native rectangle vertices and retain exact parallels at every height', () => {
let vertices = 0,
heights = 0;
for (const rect of cases) {
const bounds = rectangleBoundaries(rect);
const native = Cesium.RectangleGeometry.createGeometry(
new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(
rect.west,
rect.south,
rect.east,
rect.north,
),
vertexFormat: Cesium.VertexFormat.POSITION_ONLY,
}),
);
const positions = native.attributes.position.values;
for (let i = 0; i < positions.length; i += 3) {
const distance = rectangleDistances(bounds, {
x: positions[i],
y: positions[i + 1],
z: positions[i + 2],
});
assert.ok(Math.min(...distance) > -1e-6, JSON.stringify(rect));
vertices++;
}
for (const h of [-100000, 0, 200, 12000])
for (const t of [0.1, 0.5, 0.9]) {
const lon = rect.west + (rect.east - rect.west) * t,
lat = rect.south + (rect.north - rect.south) * t;
for (const [x, y] of [
[lon, rect.south],
[lon, rect.north],
[rect.west, lat],
[rect.east, lat],
]) {
const d = rectangleDistances(
bounds,
geo.cartesian3.fromDegrees(x, y, h),
);
assert.ok(
Math.abs(Math.min(...d)) < 1e-6,
`${JSON.stringify(rect)} height ${h}`,
);
heights++;
}
assert.ok(
Math.min(
...rectangleDistances(
bounds,
geo.cartesian3.fromDegrees(lon, lat, h),
),
) > 0,
);
assert.ok(
Math.min(
...rectangleDistances(
bounds,
geo.cartesian3.fromDegrees(rect.west - 0.01, lat, h),
),
) < 0,
);
assert.ok(
Math.min(
...rectangleDistances(
bounds,
geo.cartesian3.fromDegrees(rect.east + 0.01, lat, h),
),
) < 0,
);
}
}
assert.equal(vertices, 562);
assert.equal(heights, 2688);
});
test('candidate volumes enclose geodetic cells through their conservative terrain/roof height window', () => {
for (const rect of cases) {
const frame = rectangleFrame(rect);
for (const lat of [rect.south, (rect.south + rect.north) / 2, rect.north])
for (const lon of [rect.west, (rect.west + rect.east) / 2, rect.east])
for (const h of [-100000, 0, 12000]) {
const p = geo.cartesian3.fromDegrees(lon, lat, h),
delta = {
x: p.x - frame.center.x,
y: p.y - frame.center.y,
z: p.z - frame.center.z,
};
const x = geo.cartesian3.dot(delta, frame.east),
y = geo.cartesian3.dot(delta, frame.north),
z = geo.cartesian3.dot(delta, frame.up);
assert.ok(
Math.abs(x) <= frame.radius && Math.abs(y) <= frame.radius,
JSON.stringify(rect),
);
assert.ok(
z >= frame.floor - 1e-6 && z <= frame.ceiling + 1e-6,
`${JSON.stringify(rect)} ${z}`,
);
}
}
});
+198
View File
@@ -0,0 +1,198 @@
// The real FIRMS PNGs in one engine mover atlas.
const WHITE = Object.freeze([1, 1, 1, 1]);
const CELL = 60,
PAD = 2,
BASE_SIZE = 56;
const imageFromUrl = (url) =>
new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
image.onload = image.onerror = null;
resolve(image);
};
image.onerror = () => {
image.onload = image.onerror = null;
reject(new Error('FIRMS sprite decode failed'));
};
image.src = url;
});
export function createFirmsSpriteBank(
engine,
{
id,
capacity,
loadImage = imageFromUrl,
createCanvas = () => document.createElement('canvas'),
} = {},
) {
let rows = [],
visible = false,
destroyed = false,
generation = 0,
queued = false,
set = null,
atlas = null;
const images = new Map();
const loaded = new Map();
const getImage = (url) => {
let task = images.get(url);
if (!task) {
task = Promise.resolve()
.then(() => loadImage(url))
.then((image) => {
if (!destroyed) loaded.set(url, image);
return image;
})
.catch((error) => {
images.delete(url);
throw error;
});
images.set(url, task);
}
return task;
};
const hide = () => {
if (set && set.count() !== 0) {
set.setCount(0);
set.commit();
}
};
function buildAtlas() {
const entries = [...loaded],
side = Math.ceil(Math.sqrt(entries.length));
const canvas = createCanvas();
canvas.width = side * CELL;
canvas.height = Math.ceil(entries.length / side) * CELL;
const context = canvas.getContext('2d');
if (!context) throw new Error('FIRMS atlas requires a canvas');
const cells = {},
lookup = new Map();
entries.forEach(([url, image], index) => {
const width = image.naturalWidth ?? image.width,
height = image.naturalHeight ?? image.height;
if (!(width > 0 && width <= BASE_SIZE && height === width))
throw new Error('FIRMS sprite dimensions are invalid');
const x = (index % side) * CELL + PAD,
y = Math.floor(index / side) * CELL + PAD;
context.drawImage(image, x, y);
const key = String(index);
cells[key] = [
x / canvas.width,
y / canvas.height,
(x + width) / canvas.width,
(y + height) / canvas.height,
];
lookup.set(url, { key, scale: width / BASE_SIZE });
});
return { canvas, cells, lookup };
}
async function flush() {
if (destroyed) return;
if (!visible || !rows.length) {
hide();
return;
}
const version = generation,
snapshot = rows.slice();
const urls = [...new Set(snapshot.map((row) => row.image))];
await Promise.all(urls.map(getImage));
if (destroyed || !visible || version !== generation) return;
if (!atlas || urls.some((url) => !atlas.lookup.has(url))) {
const next = buildAtlas();
set?.destroy();
set = engine.movers.createSet({
id,
kind: 'sprite',
capacity,
depthTest: 'always',
pickable: true,
sharpen: true,
sprite: {
atlas: { canvas: next.canvas, cells: next.cells },
sizePx: BASE_SIZE,
},
});
atlas = next;
}
snapshot.forEach((row, index) => {
const cell = atlas.lookup.get(row.image);
set.set(index, {
position: row.position,
cell: cell.key,
scale: cell.scale,
color: WHITE,
show: row.show,
pickId: row.id,
});
});
set.setCount(snapshot.length);
set.commit();
}
function schedule() {
generation++;
if (queued || destroyed) return;
queued = true;
queueMicrotask(() => {
queued = false;
const version = generation;
void flush().catch(() => {
if (!destroyed && visible && version === generation)
console.error('FIRMS sprite atlas failed');
});
});
}
const collection = {
get length() {
return rows.length;
},
get(index) {
return rows[index];
},
removeAll() {
if (destroyed) return;
rows = [];
schedule();
},
};
return {
collection,
add(description) {
if (destroyed) return null;
if (rows.length >= capacity)
throw new RangeError('FIRMS sprite capacity exceeded');
let show = description.show !== false;
const row = {
...description,
get show() {
return show;
},
set show(value) {
const next = Boolean(value);
if (next === show || destroyed) return;
show = next;
schedule();
},
};
rows.push(row);
schedule();
return row;
},
setVisible(value) {
if (destroyed || visible === Boolean(value)) return;
visible = Boolean(value);
if (!visible) hide();
schedule();
},
destroy() {
if (destroyed) return;
destroyed = true;
generation++;
rows = [];
images.clear();
loaded.clear();
set?.destroy();
set = null;
atlas = null;
},
};
}
@@ -0,0 +1,143 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMoverRegistry } from '../../../globe/movers.js';
import { createFirmsSpriteBank } from './navaraSprites.js';
const settle = () => new Promise((resolve) => setImmediate(resolve));
function harness(loadImage) {
let commits = 0,
disposals = 0;
const draws = [],
canvases = [];
const registry = createMoverRegistry((options, store) => ({
ready: () => true,
commit() {
commits++;
},
destroy() {
disposals++;
},
}));
const bank = createFirmsSpriteBank(
{ movers: registry.movers },
{
id: 'firms-test',
capacity: 3,
loadImage,
createCanvas() {
const canvas = {
getContext: () => ({ drawImage: (...args) => draws.push(args) }),
};
canvases.push(canvas);
return canvas;
},
},
);
return {
bank,
registry,
draws,
canvases,
get commits() {
return commits;
},
get disposals() {
return disposals;
},
};
}
test('actual image dimensions set both the atlas cell and visible/pickable quad, with batched horizon flips', async () => {
const h = harness(async (url) => ({
width: url === 'small' ? 16 : 56,
height: url === 'small' ? 16 : 56,
}));
const first = h.bank.add({
id: 'firms-0',
position: { x: 1, y: 2, z: 3 },
image: 'small',
});
const second = h.bank.add({
id: 'firms-1',
position: { x: 4, y: 5, z: 6 },
image: 'large',
});
h.bank.setVisible(true);
await settle();
const entry = h.registry.sets.get('firms-test');
assert.equal(h.registry.sets.size, 1);
assert.equal(entry.options.sharpen, true);
assert.equal(entry.options.depthTest, 'always');
assert.equal(entry.options.pickable, true);
assert.equal(entry.store.count, 2);
assert.ok(Math.abs(entry.store.scales[0] - 16 / 56) < 1e-7);
assert.equal(entry.store.scales[1], 1);
assert.deepEqual(entry.store.pickIds.slice(0, 2), ['firms-0', 'firms-1']);
assert.equal(h.draws.length, 2);
assert.ok(
h.draws.every((args) => args.length === 3),
'atlas does not resize source PNGs',
);
const rect = entry.options.sprite.atlas.cells['0'];
assert.ok(Math.abs((rect[2] - rect[0]) * h.canvases[0].width - 16) < 1e-10);
assert.ok(
rect[0] > 0 && rect[1] > 0,
'transparent neighbor padding is retained',
);
const before = h.commits;
first.show = false;
second.show = false;
await settle();
assert.equal(h.commits, before + 1);
assert.equal(h.canvases.length, 1);
assert.deepEqual([...entry.store.shows.slice(0, 2)], [0, 0]);
h.bank.setVisible(false);
assert.equal(entry.store.count, 0);
const hidden = h.commits;
await settle();
assert.equal(h.commits, hidden);
h.bank.destroy();
h.bank.destroy();
assert.equal(h.registry.sets.size, 0);
assert.equal(h.disposals, 1);
});
test('uncooperative image completion after disable or destroy cannot create movers or request a frame', async () => {
let finish;
const h = harness(() => new Promise((resolve) => (finish = resolve)));
h.bank.add({ id: 'firms-0', position: { x: 1, y: 2, z: 3 }, image: 'late' });
h.bank.setVisible(true);
await settle();
h.bank.setVisible(false);
finish({ width: 16, height: 16 });
await settle();
assert.equal(h.registry.sets.size, 0);
assert.equal(h.commits, 0);
h.bank.collection.removeAll();
h.bank.add({ id: 'firms-1', position: { x: 1, y: 2, z: 3 }, image: 'later' });
h.bank.setVisible(true);
await settle();
h.bank.destroy();
finish({ width: 56, height: 56 });
await settle();
assert.equal(h.registry.sets.size, 0);
assert.equal(h.commits, 0);
assert.equal(h.bank.collection.length, 0);
});
test('clearing rows during decode preserves the new generation and commits only the replacement', async () => {
const complete = new Map(),
h = harness((url) => new Promise((resolve) => complete.set(url, resolve)));
h.bank.setVisible(true);
h.bank.add({ id: 'old', position: { x: 1, y: 2, z: 3 }, image: 'old' });
await settle();
h.bank.collection.removeAll();
h.bank.add({ id: 'new', position: { x: 4, y: 5, z: 6 }, image: 'new' });
await settle();
complete.get('old')({ width: 16, height: 16 });
await settle();
assert.equal(h.registry.sets.size, 0);
complete.get('new')({ width: 56, height: 56 });
await settle();
const entry = h.registry.sets.get('firms-test');
assert.equal(entry.store.count, 1);
assert.equal(entry.store.pickIds[0], 'new');
assert.equal(h.commits, 1);
h.bank.destroy();
});
@@ -0,0 +1,62 @@
/** Camera-relative transforms in JS float64; only nearby metres enter GLSL. */
export function viewFrame(frame, matrix, out) {
const m = matrix.elements;
const transform = (p, offset, translate) => {
out[offset] =
m[0] * p.x + m[4] * p.y + m[8] * p.z + (translate ? m[12] : 0);
out[offset + 1] =
m[1] * p.x + m[5] * p.y + m[9] * p.z + (translate ? m[13] : 0);
out[offset + 2] =
m[2] * p.x + m[6] * p.y + m[10] * p.z + (translate ? m[14] : 0);
};
transform(frame.center, 0, true);
transform(frame.east, 3, false);
transform(frame.north, 6, false);
transform(frame.up, 9, false);
return 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,
front = 0,
behind = 0;
for (let i = 0; i < 8; i++) {
const x = (i & 1 ? 1 : -1) * frame.radius;
const y = (i & 2 ? 1 : -1) * frame.radius;
const z = i & 4 ? frame.ceiling : frame.floor;
const vx = view[0] + view[3] * x + view[6] * y + view[9] * z;
const vy = view[1] + view[4] * x + view[7] * y + view[10] * z;
const vz = view[2] + view[5] * x + view[8] * y + view[11] * z;
const w = p[3] * vx + p[7] * vy + p[11] * vz + p[15];
if (w <= 0) {
behind++;
continue;
}
front++;
const cx = (p[0] * vx + p[4] * vy + p[8] * vz + p[12]) / w;
const cy = (p[1] * vx + p[5] * vy + p[9] * vz + p[13]) / w;
x0 = Math.min(x0, cx);
y0 = Math.min(y0, cy);
x1 = Math.max(x1, cx);
y1 = Math.max(y1, cy);
}
if (front && behind) {
x0 = -1;
y0 = -1;
x1 = 1;
y1 = 1;
}
if (!front || x1 < -1 || y1 < -1 || x0 > 1 || y0 > 1) out.fill(2);
else {
out[0] = Math.max(-1, x0);
out[1] = Math.max(-1, y0);
out[2] = Math.min(1, x1);
out[3] = Math.min(1, y1);
}
return out;
}
@@ -0,0 +1,69 @@
// A screen grid of conservative projected volumes. Prefix-sum storage has
// no feature/cell cap: overlapping shapes retain their source order.
export const GRID_COLS = 64;
export const GRID_ROWS = 40;
export function createGroundGrid(count) {
const counts = new Uint32Array(GRID_COLS * GRID_ROWS);
const offsets = new Uint32Array(counts.length);
const cursor = new Uint32Array(counts.length);
const bounds = new Float64Array(count * 4);
const ranges = new Int32Array(count * 4);
let indices = new Uint32Array(0);
const clamp = (value, max) => Math.min(max - 1, Math.max(0, value));
return {
bounds,
counts,
offsets,
get indices() {
return indices;
},
pack() {
counts.fill(0);
for (let i = 0; i < count; i++) {
const b = i * 4;
const valid =
bounds[b] <= bounds[b + 2] &&
bounds[b + 1] <= bounds[b + 3] &&
bounds[b] <= 1 &&
bounds[b + 1] <= 1 &&
bounds[b + 2] >= -1 &&
bounds[b + 3] >= -1;
ranges[b] = valid
? clamp(Math.floor((bounds[b] * 0.5 + 0.5) * GRID_COLS), GRID_COLS)
: -1;
ranges[b + 1] = clamp(
Math.floor((bounds[b + 1] * 0.5 + 0.5) * GRID_ROWS),
GRID_ROWS,
);
ranges[b + 2] = clamp(
Math.floor((bounds[b + 2] * 0.5 + 0.5) * GRID_COLS),
GRID_COLS,
);
ranges[b + 3] = clamp(
Math.floor((bounds[b + 3] * 0.5 + 0.5) * GRID_ROWS),
GRID_ROWS,
);
if (!valid) continue;
for (let y = ranges[b + 1]; y <= ranges[b + 3]; y++)
for (let x = ranges[b]; x <= ranges[b + 2]; x++)
counts[y * GRID_COLS + x]++;
}
let total = 0;
for (let i = 0; i < counts.length; i++) {
offsets[i] = total;
total += counts[i];
cursor[i] = offsets[i];
}
if (indices.length < total)
indices = new Uint32Array(Math.max(total, indices.length * 2));
for (let i = 0; i < count; i++) {
const b = i * 4;
if (ranges[b] < 0) continue;
for (let y = ranges[b + 1]; y <= ranges[b + 3]; y++)
for (let x = ranges[b]; x <= ranges[b + 2]; x++)
indices[cursor[y * GRID_COLS + x]++] = i;
}
return total;
},
};
}
@@ -0,0 +1,45 @@
// Public depth inputs shared by actual terrain/tiles classifiers.
import {
DataTexture,
FloatType,
RGBAFormat,
NearestFilter,
Matrix4,
Uniform,
RGBADepthPacking,
} from 'three';
export function createGroundTexture(data, width, height) {
const texture = new DataTexture(data, width, height, RGBAFormat, FloatType);
texture.minFilter = NearestFilter;
texture.magFilter = NearestFilter;
texture.needsUpdate = true;
return texture;
}
export function createGroundStorage(texels) {
const width = Math.min(1024, Math.max(1, texels));
const height = Math.max(1, Math.ceil(texels / width));
const data = new Float32Array(width * height * 4);
return {
data,
width,
height,
texture: createGroundTexture(data, width, height),
};
}
export function groundSurfaceUniforms() {
return [
['tAllDepth', new Uniform(null)],
['tSurfaceDepth', new Uniform(null)],
['uInverseProjection', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)],
];
}
export function updateGroundSurface(effect, camera, context, mrt) {
if (mrt?.depthBufferPacking !== RGBADepthPacking)
throw new Error('Ground classification requires packed scene depth');
const u = effect.uniforms;
u.get('tAllDepth').value = mrt.depthBuffer;
u.get('tSurfaceDepth').value = context.getRenderTarget().depthTexture;
u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse);
u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
}