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.
This commit is contained in:
German Khokhlov committed 2026-11-10 15:45:26 +04:00
1 parent a90407d547
commit 28f3030c31
7 files changed
+129 -107

No files matched your search

+1 -1
View File
@@ -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) =>
+7 -42
View File
@@ -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();
}
+102 -26
View File
@@ -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);
+2 -2
View File
@@ -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
+13 -23
View File
@@ -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();
});