From eee4f487454fb0e28df14c525375faabfa11f276 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 21:14:48 +0400 Subject: [PATCH] Hide Navara infrastructure stems and data pack meshes behind walls, terrain and the Earth Navara draws its transparent scene after the post effects into a target without the scene depth, so stems and pack polygons and images painted through 3D tiles. A scene-depth test now drops each of their fragments behind the depth Navara packs after its opaque pass; they stay in the transparent pass, so their look in the open is unchanged (scripts/qa-edge-occlusion.mjs compares it with the previous build). Wind flow was already hidden and is kept as a guard in the same scenario. --- scripts/check-import-directions.mjs | 6 +- scripts/package-boundaries.json | 1 + src/layers/shared/render/navaraSceneDepth.js | 109 ++++++++++++ .../shared/render/navaraSceneDepth.test.mjs | 157 ++++++++++++++++++ src/layers/shared/render/navaraStems.js | 16 +- src/scenes/dataPacks/navara.js | 14 +- 6 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 src/layers/shared/render/navaraSceneDepth.js create mode 100644 src/layers/shared/render/navaraSceneDepth.test.mjs diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index cd2d5de53..5e22de4b0 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -71,7 +71,11 @@ const renderedLayer = (file) => RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir)); const cesiumLayerRenderer = (file) => /\/render\/cesium(?:[^/]*|\/[^/]+)\.js$/.test(file); -const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file); +// Navara renderers outside a layer's render/ directory: like those, they +// reach the app only through the Navara mode's import(). +const NAVARA_SCENE_RENDERERS = new Set(['src/scenes/dataPacks/navara.js']); +const navaraLayerRenderer = (file) => + /\/render\/navara[^/]*\.js$/.test(file) || NAVARA_SCENE_RENDERERS.has(file); const renderer = (file) => /^src\/(?:ui|app|standalone|overlays)\//.test(file) || /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3c63022c3..2b60e4ac0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3706,6 +3706,7 @@ "src/layers/awareness/render/navara.js", "src/layers/infrastructure/render/navara.js", "src/layers/shared/render/navaraStems.js", + "src/layers/shared/render/navaraSceneDepth.js", "src/layers/submarineCables/render/navara.js", "src/layers/directions/render/navara.js", "src/layers/traffic/render/navara.js", diff --git a/src/layers/shared/render/navaraSceneDepth.js b/src/layers/shared/render/navaraSceneDepth.js new file mode 100644 index 000000000..49cbe9a63 --- /dev/null +++ b/src/layers/shared/render/navaraSceneDepth.js @@ -0,0 +1,109 @@ +// Navara draws its transparent scene after the post effects, into a target +// that does not hold the scene's depth, so a transparent mesh behind a wall, +// terrain or the Earth painted through them. Moving such a mesh into the +// opaque scene (as CCTV did) would also run it through the effects and change +// its look. This test keeps the mesh in the transparent pass and drops every +// fragment that lies behind the depth Navara packs after its opaque pass +// (globe, 3D tiles, MRT and opaque meshes): an unhidden fragment is drawn +// exactly as before. scripts/qa-edge-occlusion.mjs checks it in a browser. +import { RGBADepthPacking, Vector2 } from 'three'; + +const KEY = 'gevSceneDepth'; + +// Log-depth units (about 0.01 % of the distance): a stem's foot or a ground +// overlay on the very surface it stands on is not cut by its own ground. +export const SCENE_DEPTH_BIAS = 5e-6; + +const PARS = /* glsl */ ` +uniform sampler2D tGevSceneDepth; +uniform vec2 uGevTargetSize; +uniform float uGevSceneDepthOn; +`; + +const TEST = /* glsl */ ` +if (uGevSceneDepthOn > 0.5) { + vec2 gevDepthSize = vec2(textureSize(tGevSceneDepth, 0)); + ivec2 gevTexel = ivec2(gl_FragCoord.xy * gevDepthSize / uGevTargetSize); + float gevScene = unpackRGBAToDepth(texelFetch(tGevSceneDepth, gevTexel, 0)); + #if defined( USE_LOGARITHMIC_DEPTH_BUFFER ) + float gevDepth = vIsPerspective == 0.0 + ? gl_FragCoord.z + : log2(vFragDepth) * logDepthBufFC * 0.5; + #else + float gevDepth = gl_FragCoord.z; + #endif + if (gevScene < 1.0 && gevDepth > gevScene + ${SCENE_DEPTH_BIAS.toExponential()}) + discard; +} +`; + +/** Patch a fragment shader source with the scene-depth test. */ +export function withSceneDepthTest(fragmentShader) { + const anchor = '#include '; + if (!fragmentShader.includes(anchor)) + throw new Error('A scene-depth test needs logdepthbuf_fragment'); + const pars = fragmentShader.includes('#include ') + ? PARS + : `#include \n${PARS}`; + return fragmentShader + .replace('void main() {', `${pars}\nvoid main() {`) + .replace(anchor, `${anchor}\n${TEST}`); +} + +/** + * One scene-depth test for a set of transparent meshes: `material()` patches + * each material, `object()` binds the depth before each draw, and the mesh + * descriptor calls `attach(ctx)` with its view context once created. + */ +export function createSceneDepthTest() { + const uniforms = { + tGevSceneDepth: { value: null }, + uGevTargetSize: { value: new Vector2(1, 1) }, + uGevSceneDepthOn: { value: 0 }, + }; + let ctx = null; + const bind = (renderer) => { + const mrt = ctx?.findEffect?.('mrt'); + const texture = + mrt?.depthBufferPacking === RGBADepthPacking ? mrt.depthBuffer : null; + uniforms.tGevSceneDepth.value = texture ?? null; + uniforms.uGevSceneDepthOn.value = texture ? 1 : 0; + const target = renderer.getRenderTarget?.(); + if (target) uniforms.uGevTargetSize.value.set(target.width, target.height); + else renderer.getDrawingBufferSize?.(uniforms.uGevTargetSize.value); + }; + const test = { + uniforms, + material(material) { + const previousCompile = material.onBeforeCompile; + const previousKey = material.customProgramCacheKey; + material.onBeforeCompile = (shader, renderer) => { + Object.assign(shader.uniforms, uniforms); + shader.fragmentShader = withSceneDepthTest(shader.fragmentShader); + previousCompile?.call(material, shader, renderer); + }; + material.customProgramCacheKey = () => + `${previousKey ? previousKey.call(material) : ''}_gevSceneDepth`; + material.needsUpdate = true; + return material; + }, + object(object) { + const previous = object.onBeforeRender; + object.onBeforeRender = function onBeforeRender(renderer, ...rest) { + bind(renderer); + return previous?.call(this, renderer, ...rest); + }; + object.userData[KEY] = test; + return object; + }, + attach(next) { + ctx = next ?? null; + }, + }; + return test; +} + +/** Give an object's scene-depth test (if any) its descriptor's context. */ +export function attachSceneDepth(object, ctx) { + object?.userData?.[KEY]?.attach(ctx); +} diff --git a/src/layers/shared/render/navaraSceneDepth.test.mjs b/src/layers/shared/render/navaraSceneDepth.test.mjs new file mode 100644 index 000000000..b2375b376 --- /dev/null +++ b/src/layers/shared/render/navaraSceneDepth.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + BasicDepthPacking, + DataTexture, + MeshBasicMaterial, + RGBADepthPacking, + ShaderLib, +} from 'three'; +import { + SCENE_DEPTH_BIAS, + createSceneDepthTest, + withSceneDepthTest, +} from './navaraSceneDepth.js'; +import { createStemBatch } from './navaraStems.js'; +import { createNavaraPackRenderer } from '../../../scenes/dataPacks/navara.js'; + +const count = (text, part) => text.split(part).length - 1; + +test('the scene-depth test goes after the log depth and brings packing once', () => { + for (const source of [ + ShaderLib.basic.fragmentShader, + '#include \n#include \nvoid main() {\n#include \n}', + ]) { + const patched = withSceneDepthTest(source); + assert.equal(count(patched, '#include '), 1); + assert.equal(count(patched, 'uniform sampler2D tGevSceneDepth;'), 1); + const depth = patched.indexOf('#include '); + const test = patched.indexOf('if (uGevSceneDepthOn > 0.5)'); + assert.ok(depth >= 0 && test > depth, 'tested after gl_FragDepth is set'); + assert.ok( + patched.indexOf('uniform float uGevSceneDepthOn;') < + patched.indexOf('void main() {'), + ); + assert.match(patched, /gevDepth > gevScene \+ 5e-6\)\s*discard;/); + } + assert.ok(SCENE_DEPTH_BIAS > 0 && SCENE_DEPTH_BIAS < 1e-4); + assert.throws(() => withSceneDepthTest('void main() {}'), /logdepthbuf/); +}); + +test('the test binds the MRT pass packed depth before each draw, and only packed RGBA depth', () => { + const depth = createSceneDepthTest(); + const material = depth.material(new MeshBasicMaterial()); + const shader = { + uniforms: {}, + vertexShader: ShaderLib.basic.vertexShader, + fragmentShader: ShaderLib.basic.fragmentShader, + }; + material.onBeforeCompile(shader, null); + assert.match(material.customProgramCacheKey(), /_gevSceneDepth$/); + assert.equal(shader.uniforms.tGevSceneDepth, depth.uniforms.tGevSceneDepth); + assert.match(shader.fragmentShader, /discard;/); + + const drawn = []; + const object = depth.object({ + userData: {}, + onBeforeRender: (renderer) => drawn.push(renderer), + }); + const texture = new DataTexture(); + const mrt = { depthBuffer: texture, depthBufferPacking: RGBADepthPacking }; + const renderer = { getRenderTarget: () => ({ width: 640, height: 360 }) }; + // Before the descriptor attaches its context the test stays off. + object.onBeforeRender(renderer); + assert.equal(depth.uniforms.uGevSceneDepthOn.value, 0); + depth.attach({ findEffect: (key) => (key === 'mrt' ? mrt : undefined) }); + object.onBeforeRender(renderer); + assert.equal(depth.uniforms.tGevSceneDepth.value, texture); + assert.equal(depth.uniforms.uGevSceneDepthOn.value, 1); + assert.deepEqual(depth.uniforms.uGevTargetSize.value.toArray(), [640, 360]); + assert.deepEqual(drawn, [renderer, renderer], 'the mesh hook still runs'); + // Another packing would be read wrong: the mesh draws as before. + mrt.depthBufferPacking = BasicDepthPacking; + object.onBeforeRender(renderer); + assert.equal(depth.uniforms.uGevSceneDepthOn.value, 0); + assert.equal(depth.uniforms.tGevSceneDepth.value, null); +}); + +/** A Navara view that builds each descriptor as the SDK does. */ +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 = { + 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 }; +} + +/** Every renderable under an object compiles with the test and binds depth. */ +function assertTested(object, mrt) { + const renderables = []; + object.traverse?.((child) => { + if (child.material) renderables.push(child); + }); + if (!object.traverse && object.material) renderables.push(object); + assert.ok(renderables.length > 0); + for (const child of renderables) { + const shader = { + uniforms: { ...(child.material.uniforms ?? {}) }, + vertexShader: '', + fragmentShader: + child.material.fragmentShader ?? ShaderLib.basic.fragmentShader, + }; + child.material.onBeforeCompile(shader, null); + assert.match(shader.fragmentShader, /if \(uGevSceneDepthOn > 0\.5\)/); + assert.ok(shader.uniforms.tGevSceneDepth); + child.onBeforeRender( + { + getRenderTarget: () => ({ width: 8, height: 8 }), + getViewport: (target) => target.set(0, 0, 8, 8), + }, + null, + null, + child.geometry, + child.material, + ); + assert.equal(shader.uniforms.tGevSceneDepth.value, mrt.depthBuffer); + assert.equal(shader.uniforms.uGevSceneDepthOn.value, 1); + } +} + +test('stems and pack meshes stay transparent and test the scene depth', () => { + const stems = fakeView(); + createStemBatch(stems, 0xffffff, 4); + const packs = fakeView(); + const owned = []; + const engine = { render: { request() {} } }; + createNavaraPackRenderer(engine, packs).image( + { id: 'p', placement: { bounds: [0, 0, 0.01, 0.01], height: 300 } }, + {}, + { own: (fn) => owned.push(fn) }, + ); + for (const { created, mrt } of [stems, packs]) { + assert.equal(created.length, 1); + assert.equal(created[0].pass, 'transparent', created[0].kind); + assertTested(created[0].object, mrt); + } + for (const release of owned) release(); +}); diff --git a/src/layers/shared/render/navaraStems.js b/src/layers/shared/render/navaraStems.js index 01b0e9a64..8246ddc59 100644 --- a/src/layers/shared/render/navaraStems.js +++ b/src/layers/shared/render/navaraStems.js @@ -1,11 +1,18 @@ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; +import { attachSceneDepth, createSceneDepthTest } from './navaraSceneDepth.js'; const KEY = 'gevInfrastructureStems'; const views = new WeakSet(); /** All visible stems use one draw and one fixed buffer; tips are a point bank. */ -export function createStemBatch({ view, MeshDesc }, color, capacity, width = 3.5, opacity = 1) { +export function createStemBatch( + { view, MeshDesc }, + color, + capacity, + width = 3.5, + opacity = 1, +) { if (!views.has(view)) { view.registerMesh( KEY, @@ -14,10 +21,13 @@ export function createStemBatch({ view, MeshDesc }, color, capacity, width = 3.5 super(host, ctx, config); this.batch = config[KEY]; } + // Transparent, so the effects leave its look alone; the scene-depth + // test hides a stem behind terrain, 3D tiles and the Earth. getPassKey() { return 'transparent'; } createMesh() { + attachSceneDepth(this.batch.line, this.ctx); return this.batch.line; } onDestroy() { @@ -39,7 +49,9 @@ export function createStemBatch({ view, MeshDesc }, color, capacity, width = 3.5 transparent: true, toneMapped: false, }); - const line = new LineSegments2(geometry, material); + const depthTest = createSceneDepthTest(); + depthTest.material(material); + const line = depthTest.object(new LineSegments2(geometry, material)); line.frustumCulled = false; const holder = view.addMesh({ [KEY]: { diff --git a/src/scenes/dataPacks/navara.js b/src/scenes/dataPacks/navara.js index bd8842cff..0107d2cdd 100644 --- a/src/scenes/dataPacks/navara.js +++ b/src/scenes/dataPacks/navara.js @@ -11,6 +11,10 @@ import { SRGBColorSpace, Texture, } from 'three'; +import { + attachSceneDepth, + createSceneDepthTest, +} from '../../layers/shared/render/navaraSceneDepth.js'; const KEY = 'gevScenePack', registered = new WeakSet(); const cyan = [0, 1, 1, 1], @@ -24,10 +28,13 @@ export function createNavaraPackRenderer(engine, { view, MeshDesc }) { super(host, ctx, config); this.object = config[KEY]; } + // Transparent, so the effects leave its look alone; the scene-depth + // test hides a pack behind terrain, 3D tiles and the Earth. getPassKey() { return 'transparent'; } createMesh() { + attachSceneDepth(this.object.group, this.ctx); return this.object.group; } onDestroy() { @@ -39,7 +46,8 @@ export function createNavaraPackRenderer(engine, { view, MeshDesc }) { registered.add(view); } const meshGroup = (handle) => { - const group = new Group(), + const depthTest = createSceneDepthTest(); + const group = depthTest.object(new Group()), owned = []; let released = false; const dispose = () => { @@ -62,7 +70,7 @@ export function createNavaraPackRenderer(engine, { view, MeshDesc }) { dispose(); } }); - return { group, owned }; + return { group, owned, depthTest }; }; function addMesh(batch, shape, properties, id, texture) { if (!shape) return; @@ -99,6 +107,8 @@ export function createNavaraPackRenderer(engine, { view, MeshDesc }) { .copy(pigment) .multiplyScalar(1 / Math.max(1, view.toneMappingExposure ?? 1)); }; + batch.depthTest.material(material); + batch.depthTest.object(mesh); mesh.position.set(origin.x, origin.y, origin.z); mesh.userData.pickId = id; batch.group.add(mesh);