From 28f3030c31fc5c7c3a0c995c6e45e3c9fa00e676 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 15:45:26 +0400 Subject: [PATCH] Draw Navara world meshes in the overlay pass after tone mapping Meshes now go through the same overlay as sprites and lines, so their colours are the ones asked for without undoing the view's exposure. The contract suite no longer carries the mesh colour check as a known failure on Navara. --- docs/GLOBE-PRIMITIVES.md | 7 +- scripts/qa-contract-suite.mjs | 10 --- src/globe/navara/index.js | 2 +- src/globe/navara/meshes.js | 49 ++-------- src/globe/navara/meshes.test.mjs | 128 +++++++++++++++++++++------ src/globe/navara/overlay.js | 4 +- src/globe/navara/sceneDepth.test.mjs | 36 +++----- 7 files changed, 129 insertions(+), 107 deletions(-) diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 6741a020e..322c13698 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -538,9 +538,10 @@ Cesium draws a synchronous Primitive with a flat material, no back-face culling and no depth writes. Colour, image and visibility changes retain the primitive; geometry, pick-id and depth-test changes replace it. The engine requests frames while the primitive or its image is preparing. Navara draws -local coordinates relative to a double-precision world origin in its -transparent scene pass after tone mapping, with scene depth and triangle -picking. Their authored colours are blended in display space. +local coordinates relative to a double-precision world origin in the shared +overlay pass after tone mapping and anti-aliasing, the pass that draws sprites +and lines, with scene depth and triangle picking. Their authored colours are +blended in display space. Both engines own geometry, material and texture disposal. The fake engine keeps the same state and tests triangle intersections for picks. diff --git a/scripts/qa-contract-suite.mjs b/scripts/qa-contract-suite.mjs index fb0410eaa..befb56d24 100644 --- a/scripts/qa-contract-suite.mjs +++ b/scripts/qa-contract-suite.mjs @@ -237,12 +237,6 @@ async function runOn(browser, kind) { outcome.push({ name, skipped: owner[1] }); continue; } - const knownFailure = - engine.kind === 'navara' && - name === - 'meshes.createMesh: a single colour draws as asked within 10 per channel' - ? 'Meshes are drawn before tone mapping' - : null; try { await run(); outcome.push({ name, ok: true }); @@ -250,10 +244,6 @@ async function runOn(browser, kind) { outcome.push({ name, ok: false, - ...(knownFailure && - String(error?.message).startsWith( - 'mesh colour within 10 per channel', - ) && { knownFailure }), error: String(error?.message ?? error), }); } diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 1a921fa97..8f2346078 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -167,7 +167,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) { ), }), lines: createNavaraLines(view, { overlay }), - meshes: (parts) => createNavaraMeshes(view, { MeshDesc, ...parts }), + meshes: (parts) => createNavaraMeshes(view, { overlay, ...parts }), ground: createNavaraGround(view, { createPass: createGroundPass, pulseField: (options) => diff --git a/src/globe/navara/meshes.js b/src/globe/navara/meshes.js index 50e00b364..be468422d 100644 --- a/src/globe/navara/meshes.js +++ b/src/globe/navara/meshes.js @@ -2,7 +2,6 @@ import { BufferAttribute, BufferGeometry, CanvasTexture, - Color, DoubleSide, Group, Mesh, @@ -14,39 +13,11 @@ import { } from 'three'; import { createMeshRegistry } from '../meshes.js'; import { cartesian3 } from '../../geo/index.js'; -import { attachSceneDepth, createSceneDepthTest } from './sceneDepth.js'; +import { createSceneDepthTest } from './sceneDepth.js'; -const KIND = 'gevWorldMesh'; const NEAR_SURFACE_M = 2; -const registered = new WeakSet(); -export function createNavaraMeshes(view, { MeshDesc, camera, picker }) { - if (!registered.has(view)) { - view.registerMesh( - KIND, - class extends MeshDesc { - constructor(owner, ctx, config) { - super(owner, ctx, config); - this.resource = config[KIND]; - } - // Transparent, drawn after the effects: the colours are the ones - // asked for (as Cesium draws them), not tone-mapped with the look. - // The scene-depth test hides a mesh behind terrain, tiles and Earth. - getPassKey() { - return 'transparent'; - } - createMesh() { - attachSceneDepth(this.resource.group, this.ctx); - return this.resource.group; - } - onDestroy() { - this.resource.dispose(); - super.onDestroy(); - } - }, - ); - registered.add(view); - } +export function createNavaraMeshes(view, { overlay, camera, picker }) { const registry = createMeshRegistry( (state) => { const group = new Group(); @@ -60,18 +31,14 @@ export function createNavaraMeshes(view, { MeshDesc, camera, picker }) { const mesh = new Mesh(geometry, material); const sceneDepth = createSceneDepthTest(); sceneDepth.material(material); - sceneDepth.object(group); sceneDepth.object(mesh); let depthFree = false; - const pigment = new Color(); const bindDepth = mesh.onBeforeRender; mesh.onBeforeRender = function onBeforeRender(...args) { + // The overlay context can arrive after the mesh is created. + sceneDepth.attach(overlay.ctx); bindDepth.apply(this, args); if (depthFree) sceneDepth.uniforms.uGevSceneDepthOn.value = 0; - // The pass still scales by the view's exposure: undo it. - material.color - .copy(pigment) - .multiplyScalar(1 / Math.max(1, view.toneMappingExposure ?? 1)); }; group.frustumCulled = mesh.frustumCulled = false; group.add(mesh); @@ -116,17 +83,15 @@ export function createNavaraMeshes(view, { MeshDesc, camera, picker }) { material.needsUpdate = true; } else if (texture && !texture.isVideoTexture && 'image' in patch) texture.needsUpdate = true; - pigment.setRGB(...next.color.slice(0, 3), SRGBColorSpace); - material.color.copy(pigment); + material.color.setRGB(...next.color.slice(0, 3), SRGBColorSpace); material.opacity = next.color[3]; depthFree = next.depthTest === 'always'; material.depthTest = !depthFree; group.visible = next.show; } - let holder; try { update(state, state); - holder = view.addMesh({ [KIND]: { group, dispose } }); + overlay.add(group); } catch (error) { dispose(); throw error; @@ -137,7 +102,7 @@ export function createNavaraMeshes(view, { MeshDesc, camera, picker }) { update, destroy() { try { - holder.delete(); + overlay.remove(group); } finally { dispose(); } diff --git a/src/globe/navara/meshes.test.mjs b/src/globe/navara/meshes.test.mjs index 003567677..e0a97d116 100644 --- a/src/globe/navara/meshes.test.mjs +++ b/src/globe/navara/meshes.test.mjs @@ -1,6 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { Group, Vector3 } from 'three'; +import { + Color, + Group, + RGBADepthPacking, + ShaderLib, + SRGBColorSpace, + Vector3, +} from 'three'; import * as Cesium from 'cesium'; import { createNavaraMeshes } from './meshes.js'; import { createCctvMeshes } from '../../layers/cctv/render/meshes.js'; @@ -8,40 +15,30 @@ import { viewshedColors } from '../../data/cctvViewshed.js'; function harness() { const scene = new Group(); - const kinds = new Map(); const handles = new Set(); let requests = 0; let fail = false; let failedGeometryDisposals = 0; - class MeshDesc { - onDestroy() {} - } const view = { forceUpdate: () => requests++, - registerMesh: (kind, desc) => kinds.set(kind, desc), - addMesh(config) { + toneMappingExposure: 1, + }; + const overlay = { + ctx: null, + add(object) { if (fail) { - config.gevWorldMesh.group.children[0].geometry.addEventListener( + object.children[0].geometry.addEventListener( 'dispose', () => failedGeometryDisposals++, ); - throw new Error('descriptor failed'); + throw new Error('overlay failed'); } - const kind = Object.keys(config)[0]; - const desc = new (kinds.get(kind))(view, {}, config); - // After the effects, depth-tested against the scene's packed depth. - assert.equal(desc.getPassKey(), 'transparent'); - const object = desc.createMesh(); scene.add(object); - const handle = { - delete() { - handles.delete(handle); - scene.remove(object); - desc.onDestroy(); - }, - }; - handles.add(handle); - return handle; + handles.add(object); + }, + remove(object) { + scene.remove(object); + handles.delete(object); }, }; const pickers = new Set(); @@ -57,7 +54,7 @@ function harness() { }, }; const port = createNavaraMeshes(view, { - MeshDesc, + overlay, camera: engine.camera, picker: engine.picker, }); @@ -66,6 +63,8 @@ function harness() { return { owner, port, + view, + overlay, drawn: () => scene.children.at(-1)?.children[0], engine, scene, @@ -82,6 +81,83 @@ const positions = { capCenter: { x: 6378137, y: 1234, z: 5678 } }; const identity = { x: 0, y: 0, z: 0, w: 1 }; const canvas = () => ({ width: 8, height: 8, tagName: 'CANVAS' }); +test('solid and textured overlay meshes keep authored colours through landscape exposure changes', () => { + const h = harness(); + for (const image of [null, canvas()]) { + const handle = h.engine.meshes.createMesh({ + id: image ? 'textured' : 'solid', + positions: [6378137, 0, 0, 6378137, 10, 0, 6378137, 0, 10], + indices: [0, 1, 2], + uv: [0, 0, 1, 0, 0, 1], + image, + }); + const mesh = h.drawn(); + assert.equal(mesh.parent.parent, h.scene); + assert.equal(mesh.material.toneMapped, false); + assert.equal(mesh.material.transparent, true); + assert.equal(mesh.material.depthWrite, false); + if (image) assert.equal(mesh.material.map.colorSpace, SRGBColorSpace); + for (const color of [ + [0, 1, 0, 1], + [0.25, 0.5, 0.75, 0.4], + ]) { + handle.update({ color }); + const expected = new Color().setRGB(...color.slice(0, 3), SRGBColorSpace); + for (const exposure of [1, 10, 1]) { + h.view.toneMappingExposure = exposure; + mesh.onBeforeRender({}); + assert.deepEqual(mesh.material.color, expected); + assert.equal(mesh.material.opacity, color[3]); + } + } + handle.destroy(); + assert.equal(h.scene.children.length, 0); + } + h.port.destroy(); +}); + +test('overlay meshes bind late and replaced scene depth and honour depth-test updates', () => { + const h = harness(); + const plane = h.owner.createPlane( + 'depth', + h.owner.createMaterial(canvas()), + shape, + positions, + identity, + ); + const mesh = h.drawn(); + const shader = { + uniforms: {}, + fragmentShader: ShaderLib.basic.fragmentShader, + }; + mesh.material.onBeforeCompile(shader, null); + assert.match(shader.fragmentShader, /packedDepthOccluded/); + const renderer = { getRenderTarget: () => ({ width: 640, height: 360 }) }; + const { tGevSceneDepth, uGevSceneDepthOn, uGevTargetSize } = shader.uniforms; + mesh.onBeforeRender(renderer); + assert.equal(uGevSceneDepthOn.value, 0); + const mrt = { depthBuffer: {}, depthBufferPacking: RGBADepthPacking }; + h.overlay.ctx = { findEffect: (key) => (key === 'mrt' ? mrt : null) }; + mesh.onBeforeRender(renderer); + assert.equal(tGevSceneDepth.value, mrt.depthBuffer); + assert.equal(uGevSceneDepthOn.value, 1); + assert.deepEqual(uGevTargetSize.value.toArray(), [640, 360]); + plane.mesh.update({ depthTest: 'always' }); + mesh.onBeforeRender(renderer); + assert.equal(uGevSceneDepthOn.value, 0); + assert.equal(mesh.material.depthTest, false); + plane.mesh.update({ depthTest: 'normal' }); + mrt.depthBuffer = {}; + mesh.onBeforeRender({ getDrawingBufferSize: (size) => size.set(1280, 720) }); + assert.equal(tGevSceneDepth.value, mrt.depthBuffer); + assert.equal(uGevSceneDepthOn.value, 1); + assert.equal(mesh.material.depthTest, true); + assert.deepEqual(uGevTargetSize.value.toArray(), [1280, 720]); + h.owner.destroy(); + h.port.destroy(); + assert.equal(h.scene.children.length, 0); +}); + test('Navara CCTV plane preserves native rotated geometry, UVs and metre precision', (t) => { const h = harness(); t.after(() => { @@ -213,7 +289,7 @@ test('world meshes release replaced images and video callbacks exactly once', () assert.equal(finalDisposed, 1); }); -test('world mesh descriptor failure releases geometry and rejects later creates after teardown', () => { +test('world mesh overlay failure releases geometry and rejects later creates after teardown', () => { const h = harness(); h.fail(); assert.throws( @@ -225,7 +301,7 @@ test('world mesh descriptor failure releases geometry and rejects later creates positions, identity, ), - /descriptor failed/, + /overlay failed/, ); assert.equal(h.failedGeometryDisposals(), 1); assert.equal(h.handles.size, 0); diff --git a/src/globe/navara/overlay.js b/src/globe/navara/overlay.js index fe30a2361..09f10319a 100644 --- a/src/globe/navara/overlay.js +++ b/src/globe/navara/overlay.js @@ -1,5 +1,5 @@ -// gev's screen overlays on Navara: sprites, points and lines (engine.movers -// and engine.lines) drawn into the frame after Navara's tone mapping, +// gev's screen overlays on Navara: sprites, points, lines and world meshes +// (engine.movers, engine.lines and engine.meshes) drawn after tone mapping, // anti-aliasing (SMAA/FXAA) and the gev sharpening, so their // colours are the colours asked for (as Cesium draws billboards and // polylines), not tone-mapped or graded with the look. A public Navara diff --git a/src/globe/navara/sceneDepth.test.mjs b/src/globe/navara/sceneDepth.test.mjs index c80ac369c..a2dabd330 100644 --- a/src/globe/navara/sceneDepth.test.mjs +++ b/src/globe/navara/sceneDepth.test.mjs @@ -78,34 +78,25 @@ test('the test binds the MRT pass packed depth before each draw, and only packed assert.equal(depth.uniforms.tGevSceneDepth.value, null); }); -/** A Navara view that builds each descriptor as the SDK does. */ +/** An overlay that exposes the scene's packed depth to its meshes. */ function fakeView() { - const kinds = new Map(); const created = []; const mrt = { depthBuffer: new DataTexture(), depthBufferPacking: RGBADepthPacking, }; const ctx = { findEffect: (key) => (key === 'mrt' ? mrt : undefined) }; - class MeshDesc { - constructor(view, context, config) { - this.ctx = context; - this.config = config; - } - onDestroy() {} - } const view = { forceUpdate() {}, - registerMesh: (kind, desc) => kinds.set(kind, desc), - addMesh(config) { - const kind = Object.keys(config)[0]; - const desc = new (kinds.get(kind))(view, ctx, config); - const object = desc.createMesh(); - created.push({ kind, pass: desc.getPassKey(), object }); - return { delete: () => desc.onDestroy() }; - }, }; - return { view, MeshDesc, created, mrt }; + const overlay = { + ctx, + add(object) { + created.push(object); + }, + remove() {}, + }; + return { view, overlay, created, mrt }; } /** Every renderable under an object compiles with the test and binds depth. */ @@ -141,11 +132,11 @@ function assertTested(object, mrt) { } } -test('pack meshes stay transparent and test the scene depth', () => { +test('pack meshes draw in the overlay and test the scene depth', () => { const packs = fakeView(); const owned = []; const port = createNavaraMeshes(packs.view, { - MeshDesc: packs.MeshDesc, + overlay: packs.overlay, camera: {}, picker: { registerMeshPicker: () => () => {} }, }); @@ -156,9 +147,8 @@ test('pack meshes stay transparent and test the scene depth', () => { { own: (fn) => owned.push(fn) }, ); assert.equal(packs.created.length, 1); - assert.equal(packs.created[0].pass, 'transparent'); - assertTested(packs.created[0].object, packs.mrt); - assert.equal(packs.created[0].object.children[0].material.depthTest, true); + assertTested(packs.created[0], packs.mrt); + assert.equal(packs.created[0].children[0].material.depthTest, true); for (const release of owned) release(); port.destroy(); });