From 5dd05a4a814472fbe4708726bbfc3e172faf0774 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 09:36:50 +0400 Subject: [PATCH] feat(firms): add Navara renderer checkpoint --- scripts/format-scope.json | 14 +- scripts/package-boundaries.json | 8 +- scripts/qa-firms.mjs | 65 ++- src/app/navaraLayers.test.mjs | 2 + src/app/navaraMode.js | 10 + src/app/navaraScene.test.mjs | 6 +- src/globe/contract.js | 4 + src/globe/movers.js | 7 +- src/globe/movers.test.mjs | 29 ++ src/globe/navara/movers/index.js | 27 +- src/globe/navara/movers/moverMesh.js | 95 +++- src/globe/navara/movers/moverMesh.test.mjs | 83 +++- src/globe/navara/movers/pack.js | 22 +- src/globe/navara/movers/pack.test.mjs | 14 +- src/globe/navara/overlay.js | 35 +- src/globe/navara/overlayDepth.js | 4 + src/globe/navara/overlayPass.js | 209 ++++++++- src/globe/navara/overlayPass.test.mjs | 239 ++++++++-- src/globe/navara/sharpen.js | 32 +- src/layers/earthquakes/render/navara.js | 41 +- .../earthquakes/render/navaraGeometry.js | 66 +-- src/layers/earthquakes/render/navaraGrid.js | 74 +--- src/layers/firms/lifecycle.js | 1 + src/layers/firms/lifecycle.test.mjs | 32 ++ src/layers/firms/render/navara.js | 411 ++++++++++++++++++ src/layers/firms/render/navara.test.mjs | 161 +++++++ src/layers/firms/render/navaraGeometry.js | 69 +++ .../firms/render/navaraGeometry.test.mjs | 120 +++++ src/layers/firms/render/navaraSprites.js | 198 +++++++++ .../firms/render/navaraSprites.test.mjs | 143 ++++++ .../shared/render/navaraGroundGeometry.js | 62 +++ src/layers/shared/render/navaraGroundGrid.js | 69 +++ .../shared/render/navaraGroundSurface.js | 45 ++ 33 files changed, 2112 insertions(+), 285 deletions(-) create mode 100644 src/layers/firms/lifecycle.test.mjs create mode 100644 src/layers/firms/render/navara.js create mode 100644 src/layers/firms/render/navara.test.mjs create mode 100644 src/layers/firms/render/navaraGeometry.js create mode 100644 src/layers/firms/render/navaraGeometry.test.mjs create mode 100644 src/layers/firms/render/navaraSprites.js create mode 100644 src/layers/firms/render/navaraSprites.test.mjs create mode 100644 src/layers/shared/render/navaraGroundGeometry.js create mode 100644 src/layers/shared/render/navaraGroundGrid.js create mode 100644 src/layers/shared/render/navaraGroundSurface.js diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9a6b82f86..8f7719548 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 558054881..d7181b767 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index 8f38045d5..f7d859425 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -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)); } diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 19bd4a1f9..1a868878c 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -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); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 44b5d3dc1..72c743fbf 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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'); diff --git a/src/app/navaraScene.test.mjs b/src/app/navaraScene.test.mjs index d520a93b9..f01af19a4 100644 --- a/src/app/navaraScene.test.mjs +++ b/src/app/navaraScene.test.mjs @@ -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( diff --git a/src/globe/contract.js b/src/globe/contract.js index 368259b85..15888bcf1 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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, diff --git a/src/globe/movers.js b/src/globe/movers.js index 1b4004b16..80cae6b32 100644 --- a/src/globe/movers.js +++ b/src/globe/movers.js @@ -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, diff --git a/src/globe/movers.test.mjs b/src/globe/movers.test.mjs index 72b81f98c..464b26c04 100644 --- a/src/globe/movers.test.mjs +++ b/src/globe/movers.test.mjs @@ -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, + ); +}); diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index f63b33454..5cd5f3768 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -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(); }, }; diff --git a/src/globe/navara/movers/moverMesh.js b/src/globe/navara/movers/moverMesh.js index 718735fab..2adf4fd65 100644 --- a/src/globe/navara/movers/moverMesh.js +++ b/src/globe/navara/movers/moverMesh.js @@ -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 ', VERTEX_PROJECT); vertex = replace(vertex, '#include ', 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 ', colorFragment); fragment = replace( fragment, - '#include ', - fragmentColor(point), + '#include ', + 'gl_FragColor=gevOverlayDisplayColor(gl_FragColor);\n#include ', ); + if (encodedAtlas) + fragment = replace( + fragment, + '#include ', + '#include \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, diff --git a/src/globe/navara/movers/moverMesh.test.mjs b/src/globe/navara/movers/moverMesh.test.mjs index d2e3c3afb..fe9fd011e 100644 --- a/src/globe/navara/movers/moverMesh.test.mjs +++ b/src/globe/navara/movers/moverMesh.test.mjs @@ -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(); +}); diff --git a/src/globe/navara/movers/pack.js b/src/globe/navara/movers/pack.js index 35bedb178..168ad6fd6 100644 --- a/src/globe/navara/movers/pack.js +++ b/src/globe/navara/movers/pack.js @@ -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]; diff --git a/src/globe/navara/movers/pack.test.mjs b/src/globe/navara/movers/pack.test.mjs index 10edf7257..0715ddc11 100644 --- a/src/globe/navara/movers/pack.test.mjs +++ b/src/globe/navara/movers/pack.test.mjs @@ -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]); diff --git a/src/globe/navara/overlay.js b/src/globe/navara/overlay.js index 99e5b6f77..8013d0e98 100644 --- a/src/globe/navara/overlay.js +++ b/src/globe/navara/overlay.js @@ -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 { diff --git a/src/globe/navara/overlayDepth.js b/src/globe/navara/overlayDepth.js index 49a0dd034..1c06a5e93 100644 --- a/src/globe/navara/overlayDepth.js +++ b/src/globe/navara/overlayDepth.js @@ -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) diff --git a/src/globe/navara/overlayPass.js b/src/globe/navara/overlayPass.js index 379f98a00..e3a4dd26d 100644 --- a/src/globe/navara/overlayPass.js +++ b/src/globe/navara/overlayPass.js @@ -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(); + } } diff --git a/src/globe/navara/overlayPass.test.mjs b/src/globe/navara/overlayPass.test.mjs index ba159c768..93d31c5fe 100644 --- a/src/globe/navara/overlayPass.test.mjs +++ b/src/globe/navara/overlayPass.test.mjs @@ -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(); }); diff --git a/src/globe/navara/sharpen.js b/src/globe/navara/sharpen.js index 651f03c39..02c1ea806 100644 --- a/src/globe/navara/sharpen.js +++ b/src/globe/navara/sharpen.js @@ -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); } `; diff --git a/src/layers/earthquakes/render/navara.js b/src/layers/earthquakes/render/navara.js index 4a9888033..b61d186c1 100644 --- a/src/layers/earthquakes/render/navara.js +++ b/src/layers/earthquakes/render/navara.js @@ -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(); diff --git a/src/layers/earthquakes/render/navaraGeometry.js b/src/layers/earthquakes/render/navaraGeometry.js index 299a92565..61e4f0490 100644 --- a/src/layers/earthquakes/render/navaraGeometry.js +++ b/src/layers/earthquakes/render/navaraGeometry.js @@ -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'; diff --git a/src/layers/earthquakes/render/navaraGrid.js b/src/layers/earthquakes/render/navaraGrid.js index e71965d5a..eee3fc328 100644 --- a/src/layers/earthquakes/render/navaraGrid.js +++ b/src/layers/earthquakes/render/navaraGrid.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'; diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 0b0a744aa..b29939b3b 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.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; diff --git a/src/layers/firms/lifecycle.test.mjs b/src/layers/firms/lifecycle.test.mjs new file mode 100644 index 000000000..a702eecf0 --- /dev/null +++ b/src/layers/firms/lifecycle.test.mjs @@ -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); +}); diff --git a/src/layers/firms/render/navara.js b/src/layers/firms/render/navara.js new file mode 100644 index 000000000..53937c814 --- /dev/null +++ b/src/layers/firms/render/navara.js @@ -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 { + 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'); + }, + }; +} diff --git a/src/layers/firms/render/navara.test.mjs b/src/layers/firms/render/navara.test.mjs new file mode 100644 index 000000000..c6bf2b40c --- /dev/null +++ b/src/layers/firms/render/navara.test.mjs @@ -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(); +}); diff --git a/src/layers/firms/render/navaraGeometry.js b/src/layers/firms/render/navaraGeometry.js new file mode 100644 index 000000000..e9a4101b7 --- /dev/null +++ b/src/layers/firms/render/navaraGeometry.js @@ -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), + }; +} diff --git a/src/layers/firms/render/navaraGeometry.test.mjs b/src/layers/firms/render/navaraGeometry.test.mjs new file mode 100644 index 000000000..13a8817ef --- /dev/null +++ b/src/layers/firms/render/navaraGeometry.test.mjs @@ -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}`, + ); + } + } +}); diff --git a/src/layers/firms/render/navaraSprites.js b/src/layers/firms/render/navaraSprites.js new file mode 100644 index 000000000..5276378f9 --- /dev/null +++ b/src/layers/firms/render/navaraSprites.js @@ -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; + }, + }; +} diff --git a/src/layers/firms/render/navaraSprites.test.mjs b/src/layers/firms/render/navaraSprites.test.mjs new file mode 100644 index 000000000..c1f0b149a --- /dev/null +++ b/src/layers/firms/render/navaraSprites.test.mjs @@ -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(); +}); diff --git a/src/layers/shared/render/navaraGroundGeometry.js b/src/layers/shared/render/navaraGroundGeometry.js new file mode 100644 index 000000000..080fe0524 --- /dev/null +++ b/src/layers/shared/render/navaraGroundGeometry.js @@ -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; +} diff --git a/src/layers/shared/render/navaraGroundGrid.js b/src/layers/shared/render/navaraGroundGrid.js new file mode 100644 index 000000000..8f306edd0 --- /dev/null +++ b/src/layers/shared/render/navaraGroundGrid.js @@ -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; + }, + }; +} diff --git a/src/layers/shared/render/navaraGroundSurface.js b/src/layers/shared/render/navaraGroundSurface.js new file mode 100644 index 000000000..7616d31d1 --- /dev/null +++ b/src/layers/shared/render/navaraGroundSurface.js @@ -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); +}