Merge branch 'german/refactor-e5-navara-mesh-colour' into german/navara-refactor

This commit is contained in:
German Khokhlov committed 2026-11-10 15:45:37 +04:00
commit 5b27f26e14
7 files changed
+129 -107

No files matched your search

+4 -3
View File
@@ -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 culling and no depth writes. Colour, image and visibility changes retain the
primitive; geometry, pick-id and depth-test changes replace it. The engine primitive; geometry, pick-id and depth-test changes replace it. The engine
requests frames while the primitive or its image is preparing. Navara draws requests frames while the primitive or its image is preparing. Navara draws
local coordinates relative to a double-precision world origin in its local coordinates relative to a double-precision world origin in the shared
transparent scene pass after tone mapping, with scene depth and triangle overlay pass after tone mapping and anti-aliasing, the pass that draws sprites
picking. Their authored colours are blended in display space. 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 Both engines own geometry, material and texture disposal. The fake engine
keeps the same state and tests triangle intersections for picks. keeps the same state and tests triangle intersections for picks.
-10
View File
@@ -237,12 +237,6 @@ async function runOn(browser, kind) {
outcome.push({ name, skipped: owner[1] }); outcome.push({ name, skipped: owner[1] });
continue; 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 { try {
await run(); await run();
outcome.push({ name, ok: true }); outcome.push({ name, ok: true });
@@ -250,10 +244,6 @@ async function runOn(browser, kind) {
outcome.push({ outcome.push({
name, name,
ok: false, ok: false,
...(knownFailure &&
String(error?.message).startsWith(
'mesh colour within 10 per channel',
) && { knownFailure }),
error: String(error?.message ?? error), error: String(error?.message ?? error),
}); });
} }
+1 -1
View File
@@ -167,7 +167,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
), ),
}), }),
lines: createNavaraLines(view, { overlay }), lines: createNavaraLines(view, { overlay }),
meshes: (parts) => createNavaraMeshes(view, { MeshDesc, ...parts }), meshes: (parts) => createNavaraMeshes(view, { overlay, ...parts }),
ground: createNavaraGround(view, { ground: createNavaraGround(view, {
createPass: createGroundPass, createPass: createGroundPass,
pulseField: (options) => pulseField: (options) =>
+7 -42
View File
@@ -2,7 +2,6 @@ import {
BufferAttribute, BufferAttribute,
BufferGeometry, BufferGeometry,
CanvasTexture, CanvasTexture,
Color,
DoubleSide, DoubleSide,
Group, Group,
Mesh, Mesh,
@@ -14,39 +13,11 @@ import {
} from 'three'; } from 'three';
import { createMeshRegistry } from '../meshes.js'; import { createMeshRegistry } from '../meshes.js';
import { cartesian3 } from '../../geo/index.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 NEAR_SURFACE_M = 2;
const registered = new WeakSet();
export function createNavaraMeshes(view, { MeshDesc, camera, picker }) { export function createNavaraMeshes(view, { overlay, 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);
}
const registry = createMeshRegistry( const registry = createMeshRegistry(
(state) => { (state) => {
const group = new Group(); const group = new Group();
@@ -60,18 +31,14 @@ export function createNavaraMeshes(view, { MeshDesc, camera, picker }) {
const mesh = new Mesh(geometry, material); const mesh = new Mesh(geometry, material);
const sceneDepth = createSceneDepthTest(); const sceneDepth = createSceneDepthTest();
sceneDepth.material(material); sceneDepth.material(material);
sceneDepth.object(group);
sceneDepth.object(mesh); sceneDepth.object(mesh);
let depthFree = false; let depthFree = false;
const pigment = new Color();
const bindDepth = mesh.onBeforeRender; const bindDepth = mesh.onBeforeRender;
mesh.onBeforeRender = function onBeforeRender(...args) { mesh.onBeforeRender = function onBeforeRender(...args) {
// The overlay context can arrive after the mesh is created.
sceneDepth.attach(overlay.ctx);
bindDepth.apply(this, args); bindDepth.apply(this, args);
if (depthFree) sceneDepth.uniforms.uGevSceneDepthOn.value = 0; 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.frustumCulled = mesh.frustumCulled = false;
group.add(mesh); group.add(mesh);
@@ -116,17 +83,15 @@ export function createNavaraMeshes(view, { MeshDesc, camera, picker }) {
material.needsUpdate = true; material.needsUpdate = true;
} else if (texture && !texture.isVideoTexture && 'image' in patch) } else if (texture && !texture.isVideoTexture && 'image' in patch)
texture.needsUpdate = true; texture.needsUpdate = true;
pigment.setRGB(...next.color.slice(0, 3), SRGBColorSpace); material.color.setRGB(...next.color.slice(0, 3), SRGBColorSpace);
material.color.copy(pigment);
material.opacity = next.color[3]; material.opacity = next.color[3];
depthFree = next.depthTest === 'always'; depthFree = next.depthTest === 'always';
material.depthTest = !depthFree; material.depthTest = !depthFree;
group.visible = next.show; group.visible = next.show;
} }
let holder;
try { try {
update(state, state); update(state, state);
holder = view.addMesh({ [KIND]: { group, dispose } }); overlay.add(group);
} catch (error) { } catch (error) {
dispose(); dispose();
throw error; throw error;
@@ -137,7 +102,7 @@ export function createNavaraMeshes(view, { MeshDesc, camera, picker }) {
update, update,
destroy() { destroy() {
try { try {
holder.delete(); overlay.remove(group);
} finally { } finally {
dispose(); dispose();
} }
+102 -26
View File
@@ -1,6 +1,13 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; 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 * as Cesium from 'cesium';
import { createNavaraMeshes } from './meshes.js'; import { createNavaraMeshes } from './meshes.js';
import { createCctvMeshes } from '../../layers/cctv/render/meshes.js'; import { createCctvMeshes } from '../../layers/cctv/render/meshes.js';
@@ -8,40 +15,30 @@ import { viewshedColors } from '../../data/cctvViewshed.js';
function harness() { function harness() {
const scene = new Group(); const scene = new Group();
const kinds = new Map();
const handles = new Set(); const handles = new Set();
let requests = 0; let requests = 0;
let fail = false; let fail = false;
let failedGeometryDisposals = 0; let failedGeometryDisposals = 0;
class MeshDesc {
onDestroy() {}
}
const view = { const view = {
forceUpdate: () => requests++, forceUpdate: () => requests++,
registerMesh: (kind, desc) => kinds.set(kind, desc), toneMappingExposure: 1,
addMesh(config) { };
const overlay = {
ctx: null,
add(object) {
if (fail) { if (fail) {
config.gevWorldMesh.group.children[0].geometry.addEventListener( object.children[0].geometry.addEventListener(
'dispose', 'dispose',
() => failedGeometryDisposals++, () => 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); scene.add(object);
const handle = { handles.add(object);
delete() { },
handles.delete(handle); remove(object) {
scene.remove(object); scene.remove(object);
desc.onDestroy(); handles.delete(object);
},
};
handles.add(handle);
return handle;
}, },
}; };
const pickers = new Set(); const pickers = new Set();
@@ -57,7 +54,7 @@ function harness() {
}, },
}; };
const port = createNavaraMeshes(view, { const port = createNavaraMeshes(view, {
MeshDesc, overlay,
camera: engine.camera, camera: engine.camera,
picker: engine.picker, picker: engine.picker,
}); });
@@ -66,6 +63,8 @@ function harness() {
return { return {
owner, owner,
port, port,
view,
overlay,
drawn: () => scene.children.at(-1)?.children[0], drawn: () => scene.children.at(-1)?.children[0],
engine, engine,
scene, 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 identity = { x: 0, y: 0, z: 0, w: 1 };
const canvas = () => ({ width: 8, height: 8, tagName: 'CANVAS' }); 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) => { test('Navara CCTV plane preserves native rotated geometry, UVs and metre precision', (t) => {
const h = harness(); const h = harness();
t.after(() => { t.after(() => {
@@ -213,7 +289,7 @@ test('world meshes release replaced images and video callbacks exactly once', ()
assert.equal(finalDisposed, 1); 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(); const h = harness();
h.fail(); h.fail();
assert.throws( assert.throws(
@@ -225,7 +301,7 @@ test('world mesh descriptor failure releases geometry and rejects later creates
positions, positions,
identity, identity,
), ),
/descriptor failed/, /overlay failed/,
); );
assert.equal(h.failedGeometryDisposals(), 1); assert.equal(h.failedGeometryDisposals(), 1);
assert.equal(h.handles.size, 0); 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 // gev's screen overlays on Navara: sprites, points, lines and world meshes
// and engine.lines) drawn into the frame after Navara's tone mapping, // (engine.movers, engine.lines and engine.meshes) drawn after tone mapping,
// anti-aliasing (SMAA/FXAA) and the gev sharpening, so their // anti-aliasing (SMAA/FXAA) and the gev sharpening, so their
// colours are the colours asked for (as Cesium draws billboards and // colours are the colours asked for (as Cesium draws billboards and
// polylines), not tone-mapped or graded with the look. A public Navara // 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); 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() { function fakeView() {
const kinds = new Map();
const created = []; const created = [];
const mrt = { const mrt = {
depthBuffer: new DataTexture(), depthBuffer: new DataTexture(),
depthBufferPacking: RGBADepthPacking, depthBufferPacking: RGBADepthPacking,
}; };
const ctx = { findEffect: (key) => (key === 'mrt' ? mrt : undefined) }; const ctx = { findEffect: (key) => (key === 'mrt' ? mrt : undefined) };
class MeshDesc {
constructor(view, context, config) {
this.ctx = context;
this.config = config;
}
onDestroy() {}
}
const view = { const view = {
forceUpdate() {}, 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. */ /** 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 packs = fakeView();
const owned = []; const owned = [];
const port = createNavaraMeshes(packs.view, { const port = createNavaraMeshes(packs.view, {
MeshDesc: packs.MeshDesc, overlay: packs.overlay,
camera: {}, camera: {},
picker: { registerMeshPicker: () => () => {} }, picker: { registerMeshPicker: () => () => {} },
}); });
@@ -156,9 +147,8 @@ test('pack meshes stay transparent and test the scene depth', () => {
{ own: (fn) => owned.push(fn) }, { own: (fn) => owned.push(fn) },
); );
assert.equal(packs.created.length, 1); assert.equal(packs.created.length, 1);
assert.equal(packs.created[0].pass, 'transparent'); assertTested(packs.created[0], packs.mrt);
assertTested(packs.created[0].object, packs.mrt); assert.equal(packs.created[0].children[0].material.depthTest, true);
assert.equal(packs.created[0].object.children[0].material.depthTest, true);
for (const release of owned) release(); for (const release of owned) release();
port.destroy(); port.destroy();
}); });