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(); });