mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(firms): add Navara renderer checkpoint
This commit is contained in:
1 parent
a9b3125a99
commit
5dd05a4a81
33 files changed
+2112
-285
No files matched your search
@@ -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"
|
||||
]
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user