From b3daed4a9bf4b522d6692a6135798ac61e7e2b23 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 19:12:25 +0400 Subject: [PATCH] Drape a 'tiles' ground set only on 3D tiles on Navara Navara's ground pass took a pixel for a tile whenever its depth lay in front of the saved globe depth, without checking which buffer the visible depth came from; the scene buffer can be cleared apart from the globe depth, so a set draped on 'tiles' also drew on bare terrain, which Cesium never does. The pass now matches the visible depth to the globe or the tiles before it drapes. A contract check compares a 'tiles' set hidden, shown and hidden again over terrain, with a mutant. --- scripts/contract-mutants.test.mjs | 37 ++++++++- src/globe/navara/ground/pass.js | 23 +++--- src/globe/navara/ground/surface.js | 18 +++++ src/globe/navara/ground/surface.test.mjs | 77 ++++++++++++++++++ src/globe/testing/contractSuite/ground.mjs | 94 ++++++++++++++-------- 5 files changed, 207 insertions(+), 42 deletions(-) create mode 100644 src/globe/navara/ground/surface.test.mjs diff --git a/scripts/contract-mutants.test.mjs b/scripts/contract-mutants.test.mjs index 49857ab6a..d4c06183c 100644 --- a/scripts/contract-mutants.test.mjs +++ b/scripts/contract-mutants.test.mjs @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createFakeEngine } from '../src/globe/testing/fakeEngine.js'; import { fakeContractOptions } from '../src/globe/testing/contractFixtures.js'; -import { runContractSuite } from '../src/globe/testing/contractSuite/index.mjs'; +import { + createContractSuite, + runContractSuite, +} from '../src/globe/testing/contractSuite/index.mjs'; const replace = (object, member, wrap) => { object[member] = wrap(object[member].bind(object)); @@ -396,3 +399,35 @@ for (const [name, killedBy, mutate] of mutants) { } }); } + +test('tiles-only ground pixels on terrain are detected without a tileset fixture or a picking defect', async () => { + for (const drawsOnTerrain of [false, true]) { + const engine = createFakeEngine(); + try { + const check = createContractSuite(engine, { + ...fakeContractOptions(engine), + fixtures: null, + mapSources: null, + readRegion(x, y, width, height) { + const shown = engine.ground + .list() + .some((set) => set.id === 'contract-tiles-drape' && set.shown); + const color = + drawsOnTerrain && shown + ? [255, 0, 255, 255] + : [50, 100, 150, 255]; + return Uint8Array.from( + { length: width * height * 4 }, + (_, i) => color[i % 4], + ); + }, + }).find(({ name }) => name.startsWith('ground.createSet: tiles drape')); + assert.ok(check, 'tiles drape check runs without a tileset fixture'); + if (drawsOnTerrain) + await assert.rejects(check.run(), /tiles drape changed terrain pixels/); + else await check.run(); + } finally { + engine.destroy(); + } + } +}); diff --git a/src/globe/navara/ground/pass.js b/src/globe/navara/ground/pass.js index 21545e6d9..df9d7a6aa 100644 --- a/src/globe/navara/ground/pass.js +++ b/src/globe/navara/ground/pass.js @@ -25,7 +25,11 @@ import { Uniform, } from 'three'; import { DASH_EDGE, groundSeconds } from '../../ground.js'; -import { groundSurfaceUniforms, updateGroundSurface } from './surface.js'; +import { + GROUND_SURFACE_GLSL, + groundSurfaceUniforms, + updateGroundSurface, +} from './surface.js'; import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js'; import { REVEAL_OFFSET, ITEM_TEXELS, LOOK, MARGIN_RATIO } from './corridors.js'; import { @@ -64,6 +68,7 @@ vec4 atSlot(highp sampler2D image, int slot) { } ${DISPLAY_COLOR_GLSL} ${VIEW_POSITION_GLSL} +${GROUND_SURFACE_GLSL} // A corridor item over the colour, nearest metres from its line and along // metres from its start: its width, glow, pulse and dash, in LDR and // premultiplied, as Cesium writes ground polylines. @@ -119,7 +124,9 @@ vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along } void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { outputColor = inputColor; - float depth = unpackRGBAToDepth(texture2D(tAllDepth, uv)); + // All three depths must name one pixel, including at tile boundaries. + ivec2 depthPixel = ivec2(uv * vec2(textureSize(tAllDepth, 0))); + float depth = unpackRGBAToDepth(texelFetch(tAllDepth, depthPixel, 0)); float distance = viewDistance(depth, uLogDepthFC); vec4 ray = uInverseProjection * vec4(uv * 2.0 - 1.0, -1.0, 1.0); vec3 point = viewPosition(ray, distance); @@ -135,11 +142,10 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) ivec2 tile = clamp(ivec2(floor(uv * vec2(${GRID_COLS}.0, ${GRID_ROWS}.0))), ivec2(0), ivec2(${GRID_COLS - 1}, ${GRID_ROWS - 1})); vec2 range = texelFetch(tGrid, tile, 0).xy; if (range.y < 0.5) return; - float surface = texture2D(tSurfaceDepth, uv).r; - // The packed depth also holds forward meshes, which are not ground. - if (depth < surface - 2e-7) return; - float globeDepth = unpackRGBAToDepth(texture2D(tGlobeDepth, uv)); - bool onTiles = surface < globeDepth - 2e-7; + float surface = texelFetch(tSurfaceDepth, depthPixel, 0).r; + float globeDepth = unpackRGBAToDepth(texelFetch(tGlobeDepth, depthPixel, 0)); + float surfaceType = groundSurfaceType(depth, surface, globeDepth); + if (surfaceType == 0.0) return; vec3 color = toDisplay(inputColor.rgb); // The pixel's ground in world space, once: every corridor tile tests the // same value, so exactly one tile of a set holds it. @@ -154,7 +160,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) vec4 east = atSlot(tFrames, id * ${TEXELS} + 1); vec4 north = atSlot(tFrames, id * ${TEXELS} + 2); vec4 surfaceFrame = atSlot(tFrames, id * ${TEXELS} + 3); - if ((surfaceFrame.w == 1.0 && onTiles) || (surfaceFrame.w == 2.0 && !onTiles)) continue; + if (surfaceFrame.w != 0.0 && surfaceFrame.w != surfaceType) continue; vec3 up = surfaceFrame.xyz; vec4 span = atSlot(tStyles, id * ${STYLE_TEXELS} + 3); vec3 delta = point - origin.xyz; @@ -453,7 +459,6 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) { textures.candidates.needsUpdate = true; } const u = this.effect.uniforms; - u.get('tGlobeDepth').value = mrt.globeDepthBuffer; u.get('uViewToWorld').value.copy(camera.matrixWorld); u.get('uTime').value = groundSeconds(); u.get('uPixelRatio').value = pixelRatio; diff --git a/src/globe/navara/ground/surface.js b/src/globe/navara/ground/surface.js index 5ef26eb43..3bf893786 100644 --- a/src/globe/navara/ground/surface.js +++ b/src/globe/navara/ground/surface.js @@ -1,5 +1,18 @@ // Public depth inputs shared by actual terrain/tiles classifiers. import { Matrix4, Uniform, RGBADepthPacking } from 'three'; + +export const GROUND_SURFACE_GLSL = /* glsl */ ` +float groundSurfaceType(float depth, float surface, float globeDepth) { + if (depth >= 1.0) return 0.0; + // The scene target can be cleared after the globe is saved. Classify + // the visible depth, not a difference between the two source buffers. + if (globeDepth < 1.0 && abs(depth - globeDepth) <= 2e-7) return 1.0; + if (surface < 1.0 && abs(depth - surface) <= 2e-7) return 2.0; + // A forward mesh has depth in neither ground buffer. + return 0.0; +} +`; + export function groundSurfaceUniforms({ surface = true } = {}) { return [ ['tAllDepth', new Uniform(null)], @@ -15,6 +28,11 @@ export function updateGroundSurface(effect, camera, context, mrt) { u.get('tAllDepth').value = mrt.depthBuffer; if (u.has('tSurfaceDepth')) u.get('tSurfaceDepth').value = context.getRenderTarget().depthTexture; + if (u.has('tGlobeDepth')) { + if (mrt.globeDepthBufferPacking !== RGBADepthPacking) + throw new Error('Ground classification requires packed globe depth'); + u.get('tGlobeDepth').value = mrt.globeDepthBuffer; + } u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse); u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1); } diff --git a/src/globe/navara/ground/surface.test.mjs b/src/globe/navara/ground/surface.test.mjs new file mode 100644 index 000000000..af81b5b11 --- /dev/null +++ b/src/globe/navara/ground/surface.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BasicDepthPacking, Matrix4, RGBADepthPacking, Uniform } from 'three'; +import { + GROUND_SURFACE_GLSL, + groundSurfaceUniforms, + updateGroundSurface, +} from './surface.js'; + +// Execute the scalar shader decision with the same inputs as a depth texel. +const classify = new Function( + 'abs', + `${GROUND_SURFACE_GLSL.replace('float groundSurfaceType', 'function groundSurfaceType').replaceAll(/float (\w+)/g, '$1')} + return groundSurfaceType;`, +)(Math.abs); + +for (const [name, depth, surface, globe, expected] of [ + ['only globe depth after the scene depth is cleared', 0.6, 1, 0.6, 1], + ['only tile depth', 0.4, 0.4, 1, 2], + ['tiles in front of the globe', 0.4, 0.4, 0.6, 2], + ['globe in front of tiles', 0.4, 0.6, 0.4, 1], + ['globe retained in the scene depth', 0.6, 0.6, 0.6, 1], + ['visible globe with a different scene depth', 0.6, 0.4, 0.6, 1], + [ + 'globe with scene depth differing by one depth step', + 0.6, + 0.6 - 6e-8, + 0.6, + 1, + ], + ['forward mesh in front of the globe', 0.2, 0.6, 0.6, 0], + ['forward mesh in front of tiles', 0.2, 0.4, 0.6, 0], + ['no ground depth', 1, 1, 1, 0], + ['forward mesh without ground', 0.2, 1, 1, 0], +]) { + test(`ground classification: ${name}`, () => { + assert.equal(classify(depth, surface, globe), expected); + }); +} + +test('ground depth bindings refresh together and require the globe packing they decode', () => { + const effect = { + uniforms: new Map([ + ...groundSurfaceUniforms(), + ['tGlobeDepth', new Uniform(null)], + ]), + }; + const camera = { projectionMatrixInverse: new Matrix4(), far: 1000 }; + const target = { depthTexture: {} }; + const context = { getRenderTarget: () => target }; + const mrt = { + depthBuffer: {}, + depthBufferPacking: RGBADepthPacking, + globeDepthBuffer: {}, + globeDepthBufferPacking: RGBADepthPacking, + }; + for (let frame = 0; frame < 2; frame++) { + mrt.depthBuffer = {}; + mrt.globeDepthBuffer = {}; + target.depthTexture = {}; + updateGroundSurface(effect, camera, context, mrt); + assert.equal(effect.uniforms.get('tAllDepth').value, mrt.depthBuffer); + assert.equal( + effect.uniforms.get('tGlobeDepth').value, + mrt.globeDepthBuffer, + ); + assert.equal( + effect.uniforms.get('tSurfaceDepth').value, + target.depthTexture, + ); + } + mrt.globeDepthBufferPacking = BasicDepthPacking; + assert.throws( + () => updateGroundSurface(effect, camera, context, mrt), + /packed globe depth/, + ); +}); diff --git a/src/globe/testing/contractSuite/ground.mjs b/src/globe/testing/contractSuite/ground.mjs index ab44bd0af..e94abe162 100644 --- a/src/globe/testing/contractSuite/ground.mjs +++ b/src/globe/testing/contractSuite/ground.mjs @@ -1097,44 +1097,74 @@ export function groundBehaviourChecks( }, ], ]; - if (fixtures?.tiles) - checks.push([ - 'ground.createSet: tiles drape ignores terrain and picks only tiles', - async () => { - await nadirView(10000); - const set = engine.ground.createSet({ - id: 'contract-tiles-drape', - kind: 'cell', - drape: 'tiles', - pickable: true, - }); - try { - set.replace([ - { - west: 19.98, - south: 9.98, - east: 20.02, - north: 10.02, - fill: [1, 0, 1, 1], - pickId: 'tiles-drape', - }, - ]); - set.commit(); - await engine.maps.setPhotorealTiles(null); - ensure( - engine.picker.pick(centre())?.id !== 'tiles-drape', - 'tiles drape picked on terrain', + checks.push([ + 'ground.createSet: tiles drape ignores terrain and picks only tiles', + async () => { + await engine.maps.setPhotorealTiles(null); + await nadirView(10000); + const set = engine.ground.createSet({ + id: 'contract-tiles-drape', + kind: 'cell', + drape: 'tiles', + pickable: true, + }); + try { + set.replace([ + { + west: 19.98, + south: 9.98, + east: 20.02, + north: 10.02, + fill: [1, 0, 1, 1], + pickId: 'tiles-drape', + }, + ]); + set.commit(); + await until(() => set.ready(), 'tiles drape ready'); + ensure( + engine.picker.pick(centre())?.id !== 'tiles-drape', + 'tiles drape picked on terrain', + ); + if (readRegion) { + set.show(false); + await until( + () => engine.surface.tilesLoaded?.() !== false, + 'terrain settles', ); + await settle(5); + const sample = () => meanAt(engine, readRegion, 20, 10, 2); + const baseline = sample(); + ensure( + !nearColour(baseline, MAGENTA, 30), + 'terrain is already magenta', + ); + set.show(true); + await settle(5); + const shown = sample(); + set.show(false); + await settle(3); + ensure( + nearColour(sample(), baseline, 10), + 'terrain baseline changed', + ); + ensure( + nearColour(shown, baseline, 10), + 'tiles drape changed terrain pixels', + ); + set.show(true); + } + if (fixtures?.tiles) { await engine.maps.setPhotorealTiles(mapSources.photoreal); ensure( engine.picker.pick(centre())?.id === 'tiles-drape', 'tiles drape missed tiles', ); - } finally { - set.destroy(); - await engine.maps.setPhotorealTiles(null); } - }, - ]); + } finally { + set.destroy(); + await engine.maps.setPhotorealTiles(null); + } + }, + ]); return checks; }