From 14b2adb7afe844938ba3ea64e0ebb010e34f6e44 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 12 Nov 2026 00:42:17 +0400 Subject: [PATCH] Never hide a mover behind the terrain it sits on Cesium does not test points, billboards or labels against terrain depth; only the horizon, 3D tiles and models hide them. Navara compared every fragment of a marker's quad with the terrain, so markers on slopes or a little below the rendered terrain disappeared (the flood markers in the Nepal story). Navara now follows Cesium's rule for movers with the normal depth test, the contract describes it, and the contract suite checks it. --- docs/GLOBE-PRIMITIVES.md | 14 +- src/globe/cesium/createCesiumEngine.test.mjs | 2 +- src/globe/contract.js | 2 + src/globe/navara/createNavaraEngine.test.mjs | 38 ++++ src/globe/navara/movers/depth.test.mjs | 193 ++++++++++++++++ src/globe/navara/movers/index.js | 8 +- src/globe/navara/movers/moverMesh.js | 18 +- src/globe/navara/overlayDepth.js | 35 +++ src/globe/testing/contractSuite/movers.mjs | 210 ++++++++++++++++-- .../testing/contractSuite/movers.test.mjs | 157 +++++++++++++ src/globe/testing/fakeEngine.js | 1 + 11 files changed, 655 insertions(+), 23 deletions(-) create mode 100644 src/globe/navara/movers/depth.test.mjs create mode 100644 src/globe/testing/contractSuite/movers.test.mjs diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 322c13698..d391002d7 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -39,11 +39,15 @@ that has a position and is drawn the same size on screen (or as a model). Point instances may also set `outlineColor` independently of the fill, so an expanding callout can fade its outline without changing its width. -Instances move every frame without a rebuild. `depthTest` decides whether -the scene hides them. A pick returns the instance's `pickId` when the set is -`pickable`. `ready(index?)` says whether a model's glTF or a sprite atlas has -loaded and `failed(index?)` whether it failed to: a failed set is not loaded -again, so a layer that retries makes a new set. Cesium loads a model per +Instances move every frame without a rebuild. With `depthTest: 'normal'` +(the default), terrain surface depth never hides a mover, even just below +the surface. The Earth's horizon, 3D tiles and models still occlude it. +Sprites and points can use `'always'` to draw over the scene, or +`{ disableWithinM }` to skip depth testing within that camera distance; +models always use normal depth. A pick returns the instance's `pickId` when +the set is `pickable`. `ready(index?)` says whether a model's glTF or a sprite +atlas has loaded and `failed(index?)` whether it failed to: a failed set is +not loaded again, so a layer that retries makes a new set. Cesium loads a model per instance: an index written with another `pickId` loads its own model, so an instance moved into a failed index does not inherit the failure. diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index df938a163..34a283d7a 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -204,7 +204,7 @@ test('the contract suite movers and lines checks pass on the Cesium engine (no p name, ), ); - assert.equal(checks.length, 11); + assert.equal(checks.length, 12); try { for (const { name, run } of checks) await t.test(name, run); } finally { diff --git a/src/globe/contract.js b/src/globe/contract.js index 9129e30fb..df8b20948 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -428,6 +428,8 @@ * @property {number} [order] Draw order among sets (higher draws later, on * top); 0 by default. * @property {'normal'|'always'|{ disableWithinM: number }} [depthTest] + * 'normal' ignores terrain surface depth (including points just below it), + * but the Earth's horizon, 3D tiles and models still occlude movers. * 'always' draws over the scene; { disableWithinM } skips the depth test for * instances closer to the camera than that (Cesium's * disableDepthTestDistance). 'normal' by default. Sprites and points diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 1ef45efb5..f63d23c12 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -4,6 +4,9 @@ import * as geo from '../../geo/index.js'; import { assertGlobeEngine, engineCapabilities } from '../contract.js'; import { cesiumFovy, orientationVectors } from '../cameraMath.js'; import { createNavaraEngine } from './createNavaraEngine.js'; +import { createMoverRegistry } from '../movers.js'; +import { createMoverMesh } from './movers/moverMesh.js'; +import { createContractSuite } from '../testing/contractSuite.mjs'; const { cartesian2, cartesian3, math } = geo; @@ -155,6 +158,41 @@ const setup = () => { return { view, engine }; }; +test('the contract suite mover checks pass on Navara without pixels', async (t) => { + const view = fakeView(); + view.sampleTerrainHeight = () => 12; + const registry = createMoverRegistry((options, store) => { + const mover = createMoverMesh(options, store); + return { + ready: mover.ready, + failed: mover.failed, + commit: () => mover.commit(store.dirty()), + destroy: () => mover.dispose(), + }; + }); + const engine = createNavaraEngine(view, { + ...ports, + hooks, + movers: { movers: registry.movers, destroy: registry.destroyAll }, + }); + const checks = createContractSuite(engine, { + waitFrames: async (count) => { + for (let i = 0; i < count; i++) { + view.emit('preUpdate', i); + view.emit('preRender', i); + view.emit('postRender', i); + } + }, + }).filter(({ name }) => name.startsWith('movers')); + assert.equal(checks.length, 5); + try { + for (const { name, run } of checks) await t.test(name, run); + } finally { + engine.destroy(); + } + assert.equal(registry.movers.list().length, 0); +}); + test('the engine has every contract capability and reports kind navara', () => { const { engine, view } = setup(); assertGlobeEngine(engine); diff --git a/src/globe/navara/movers/depth.test.mjs b/src/globe/navara/movers/depth.test.mjs new file mode 100644 index 000000000..b4400962c --- /dev/null +++ b/src/globe/navara/movers/depth.test.mjs @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { PerspectiveCamera, ShaderLib } from 'three'; +import { cartesian3 } from '../../../geo/index.js'; +import { createMoverStore, normalizeMoverSetOptions } from '../../movers.js'; +import { createMoverMesh, patchMoverShader } from './moverMesh.js'; + +// Execute the scalar decisions from the material's actual shader patch. +function decision(name) { + const shader = patchMoverShader( + { + uniforms: {}, + vertexShader: ShaderLib.basic.vertexShader, + fragmentShader: ShaderLib.basic.fragmentShader, + }, + {}, + ); + const functions = ['packedDepthOccluded', name].map((key) => { + const body = shader.fragmentShader.match( + new RegExp(`bool ${key}\\([^}]+}`), + ); + assert.ok(body, `${key} is in the compiled mover shader`); + return body[0] + .replace(`bool ${key}`, `function ${key}`) + .replaceAll(/float (\w+)/g, '$1'); + }); + return new Function(`${functions.join('\n')}\nreturn ${name};`)(); +} + +for (const [name, mine, scene, globe, hidden] of [ + ['on terrain', 0.4, 0.4, 0.4, false], + ['below terrain', 0.7, 0.4, 0.4, false], + ['terrain copy rounding', 0.7, 0.4 - 6e-8, 0.4, false], + ['tile in front of terrain', 0.7, 0.3, 0.4, true], + ['tile with globe hidden', 0.7, 0.3, 1, true], + ['model in front of terrain', 0.7, 0.2, 0.4, true], + ['point in front of model', 0.1, 0.2, 0.4, false], + ['no surface', 0.7, 1, 1, false], +]) { + test(`mover depth: ${name}`, () => { + assert.equal(decision('gevMoverSceneHidden')(mine, scene, globe), hidden); + }); +} + +for (const latitude of [0, 45, 90]) { + test(`mover horizon at latitude ${latitude} ignores nearby underground points`, () => { + const hidden = decision('gevMoverHorizonHidden'); + const eye = cartesian3.fromDegrees(0, latitude, 10000); + const radii = [6378137, 6378137, 6356752.3142451793]; + const scaledEye = [eye.x, eye.y, eye.z].map((v, i) => v / radii[i]); + const dot = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0); + const visible = (position) => { + const ray = [ + position.x - eye.x, + position.y - eye.y, + position.z - eye.z, + ].map((v, i) => v / radii[i]); + return !hidden( + dot(scaledEye, scaledEye) - 1, + -dot(ray, scaledEye), + dot(ray, ray), + ); + }; + assert.equal(visible(cartesian3.fromDegrees(0, latitude, 0)), true); + assert.equal(visible(cartesian3.fromDegrees(0, latitude, -1000)), true); + assert.equal(visible(cartesian3.fromDegrees(180, -latitude, 0)), false); + }); +} + +test('a high mover outside the Earth silhouette is not clipped by the limb plane', () => { + assert.equal(decision('gevMoverHorizonHidden')(0.1, 0.2, 1), false); +}); + +test('point and sprite shaders share terrain classification and camera-relative horizon inputs', () => { + for (const kind of ['point', 'sprite']) { + const options = normalizeMoverSetOptions({ + id: kind, + kind, + capacity: 1, + depthTest: 'normal', + point: { sizePx: 24 }, + sprite: { sizePx: 24, atlas: { canvas: {}, cells: { a: [0, 0, 1, 1] } } }, + }); + const mover = createMoverMesh(options, createMoverStore(options)); + try { + mover.update({ + cameraPosition: { x: 6379137, y: 0, z: 0 }, + cameraHeight: 1000, + fovDeg: 60, + viewportHeightPx: 800, + }); + assert.deepEqual(mover.uniforms.uGevScaledCamera?.value, [ + 6379137 / 6378137, + 0, + 0, + ]); + const shader = { + uniforms: {}, + vertexShader: ShaderLib.basic.vertexShader, + fragmentShader: ShaderLib.basic.fragmentShader, + }; + mover.mesh.material.onBeforeCompile(shader); + assert.match(shader.fragmentShader, /#define GEV_MOVER_DEPTH/); + assert.match(shader.fragmentShader, /texelFetch\(tGevSceneDepth/); + assert.match(shader.fragmentShader, /texelFetch\(tGevGlobeDepth/); + assert.match(shader.vertexShader, /mvPosition.xyz \* mat3\(viewMatrix\)/); + assert.equal( + shader.uniforms.tGevGlobeDepth, + mover.uniforms.tGevGlobeDepth, + ); + } finally { + mover.dispose(); + } + } +}); + +test('mover frames bind the current public globe-depth copy', async () => { + // The SDK's descriptor bases are browser-only; the mover and frame callback + // remain real, with the public depth/renderer methods supplied below. + const stub = `data:text/javascript,${encodeURIComponent(` + export class MeshDesc {} + export class EffectDesc {} + export class Pass {} + export class PickableInstancedMeshWrapper {} + export function createGltfLoader() { throw new Error('Unexpected model load'); } + `)}`; + const loader = registerHooks({ + resolve(specifier, context, next) { + return specifier === '@navaramap/three' + ? { url: stub, shortCircuit: true } + : next(specifier, context); + }, + }); + let createNavaraMovers; + try { + ({ createNavaraMovers } = await import('./index.js')); + } finally { + loader.deregister(); + } + const camera = new PerspectiveCamera(60, 1.6, 1, 1e8); + camera.position.set(6379137, 0, 0); + let globeDepth = {}; + let mesh; + let frame; + const overlay = { + uniforms: { + tGevSceneDepth: { value: {} }, + uGevResolution: { value: [1280, 800] }, + }, + ctx: { + getGlobeDepthTexture: () => globeDepth, + getRenderer: () => ({ + domElement: { clientHeight: 800 }, + getPixelRatio: () => 1, + }), + }, + add(value, update) { + mesh = value; + frame = update; + }, + remove() {}, + }; + const owner = createNavaraMovers( + { + camera: { raw: camera }, + registerMesh() {}, + forceUpdate() {}, + }, + { overlay }, + ); + try { + owner.movers.createSet({ + id: 'depth-copy', + kind: 'point', + capacity: 1, + point: { sizePx: 24 }, + }); + const shader = { + uniforms: {}, + vertexShader: ShaderLib.basic.vertexShader, + fragmentShader: ShaderLib.basic.fragmentShader, + }; + mesh.material.onBeforeCompile(shader); + frame(1); + assert.equal(shader.uniforms.tGevGlobeDepth?.value, globeDepth); + globeDepth = {}; + frame(2); + assert.equal(shader.uniforms.tGevGlobeDepth.value, globeDepth); + } finally { + owner.destroy(); + } +}); diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index c8a153db0..6ef1be363 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -134,9 +134,11 @@ export function createNavaraMovers(view, { overlay, aces = false }) { targetOverlay.registerPickable(key, wrapper); pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]); } - targetOverlay.add(mover.mesh, (time) => - updateMover(readFrameCamera, targetOverlay.ctx, mover, time), - ); + targetOverlay.add(mover.mesh, (time) => { + mover.uniforms.tGevGlobeDepth.value = + targetOverlay.ctx.getGlobeDepthTexture(); + updateMover(readFrameCamera, targetOverlay.ctx, mover, time); + }); return { ready: mover.ready, failed: mover.failed, diff --git a/src/globe/navara/movers/moverMesh.js b/src/globe/navara/movers/moverMesh.js index 9abc3a654..7a9a72ab8 100644 --- a/src/globe/navara/movers/moverMesh.js +++ b/src/globe/navara/movers/moverMesh.js @@ -56,6 +56,7 @@ varying float vGevPixelDistance; varying float vGevTranslucency; varying float vGevSonarFactor; uniform vec2 uGevResolution; +varying vec3 vGevScaledRay; ${OVERLAY_DEPTH_VERTEX} ${SONAR_VERTEX} `; @@ -106,6 +107,9 @@ vec2 gevTurned = vec2( vec4 mvPosition = mvCenter; mvPosition.xy += gevTurned * gevSizePx * uGevFootprint * max(-mvCenter.z, 0.0); gl_Position = projectionMatrix * mvPosition; +// Rotate the camera-relative quad into Earth axes before scaling: adding +// its Earth-sized origin in float32 would lose precision near the ground. +vGevScaledRay = (mvPosition.xyz * mat3(viewMatrix)) / vec3(6378137.0, 6378137.0, 6356752.3142451793); // The Cyber sonar (sonar.js): by the anchor's place on screen, never the // corner's, so a contact is treated whole; the position stays as it is. vGevSonarFactor = gevSonarFactor(projectionMatrix * mvCenter, uGevResolution); @@ -191,7 +195,7 @@ export function patchMoverShader( let fragment = replace( shader.fragmentShader, 'void main() {', - `${point ? '#define GEV_POINT\n' : ''}${FRAGMENT_HEAD}\nvoid main() {`, + `#define GEV_MOVER_DEPTH\n${point ? '#define GEV_POINT\n' : ''}${FRAGMENT_HEAD}\nvoid main() {`, ); let colorFragment = fragmentColor(point); if (alwaysVisible) @@ -306,6 +310,9 @@ export function createMoverMesh( }, // Shared by every overlay: the scene depth and the target's size. tGevSceneDepth: overlayUniforms.tGevSceneDepth, + tGevGlobeDepth: { value: null }, + uGevScaledCamera: { value: [0, 0, 0] }, + uGevLimbSquared: { value: -1 }, uGevResolution: overlayUniforms.uGevResolution, uGevDisplayBlend: overlayUniforms.uGevDisplayBlend ?? DETACHED_OVERLAY.uGevDisplayBlend, @@ -446,6 +453,15 @@ export function createMoverMesh( pixelRatio, }; uniforms.uGevPixelRatio.value = pixelRatio; + uniforms.uGevScaledCamera.value[0] = cameraPosition.x / 6378137; + uniforms.uGevScaledCamera.value[1] = cameraPosition.y / 6378137; + uniforms.uGevScaledCamera.value[2] = + cameraPosition.z / 6356752.3142451793; + // Subtract in double precision while the camera is near the surface. + uniforms.uGevLimbSquared.value = uniforms.uGevScaledCamera.value.reduce( + (sum, value) => sum + value * value, + -1, + ); uniforms.uGevFootprint.value = footprintPerDepth( fovDeg, viewportHeightPx, diff --git a/src/globe/navara/overlayDepth.js b/src/globe/navara/overlayDepth.js index c26a2f88f..e05547e54 100644 --- a/src/globe/navara/overlayDepth.js +++ b/src/globe/navara/overlayDepth.js @@ -14,9 +14,29 @@ export const OVERLAY_DEPTH_MODES = Object.freeze({ fail: 2, }); +// Cesium clears terrain depth before primitives, retaining only the limb +// plane. The ray-cone test limits that plane to the Earth's silhouette. +const MOVER_DEPTH_GLSL = /* glsl */ ` +bool gevMoverHorizonHidden(float limbSquared, float towardCenter, float raySquared) { + return limbSquared >= 0.0 && towardCenter > limbSquared && + towardCenter * towardCenter > limbSquared * raySquared; +} +bool gevMoverSceneHidden(float mine, float scene, float globe) { + // Allow the same two depth-copy rounding steps as ground classification. + return scene < globe - 2e-7 && packedDepthOccluded(mine, scene, 2e-6); +} +`; + /** Fragment declarations: uniforms, the varying and gevOverlayHidden(). */ export const OVERLAY_DEPTH_FRAGMENT = /* glsl */ ` ${PACKED_DEPTH_GLSL} +#ifdef GEV_MOVER_DEPTH +${MOVER_DEPTH_GLSL} +uniform sampler2D tGevGlobeDepth; +uniform vec3 uGevScaledCamera; +uniform float uGevLimbSquared; +varying vec3 vGevScaledRay; +#endif uniform sampler2D tGevSceneDepth; uniform vec2 uGevResolution; uniform float uGevDepthMode; @@ -30,7 +50,11 @@ bool gevOverlayHidden() { return false; // three's unpackRGBAToDepth (RGBADepthPacking). float scene = dot( + #ifdef GEV_MOVER_DEPTH + texelFetch(tGevSceneDepth, ivec2(gl_FragCoord.xy), 0), + #else texture2D(tGevSceneDepth, gl_FragCoord.xy / uGevResolution), + #endif vec4(255.0 / 256.0, 255.0 / 65536.0, 255.0 / 16777216.0, 1.0 / 16777216.0) ); #ifdef USE_LOGARITHMIC_DEPTH_BUFFER @@ -39,6 +63,17 @@ bool gevOverlayHidden() { float mine = gl_FragCoord.z; #endif bool occluded = packedDepthOccluded(mine, scene, 2e-6); + #ifdef GEV_MOVER_DEPTH + float globe = dot( + texelFetch(tGevGlobeDepth, ivec2(gl_FragCoord.xy), 0), + vec4(255.0 / 256.0, 255.0 / 65536.0, 255.0 / 16777216.0, 1.0 / 16777216.0) + ); + occluded = gevMoverHorizonHidden( + uGevLimbSquared, + -dot(vGevScaledRay, uGevScaledCamera), + dot(vGevScaledRay, vGevScaledRay) + ) || gevMoverSceneHidden(mine, scene, globe); + #endif return uGevDepthMode < 1.5 ? occluded : !occluded; } `; diff --git a/src/globe/testing/contractSuite/movers.mjs b/src/globe/testing/contractSuite/movers.mjs index f460a735f..224c68d00 100644 --- a/src/globe/testing/contractSuite/movers.mjs +++ b/src/globe/testing/contractSuite/movers.mjs @@ -1,5 +1,6 @@ import { cartesian3, + cartographic, like, MAGENTA, show, @@ -7,6 +8,7 @@ import { near, unit, } from './helpers.mjs'; +import { createSurfacePoints } from '../../surfacePlacement.js'; export function spriteChecks( engine, @@ -257,12 +259,12 @@ export function spriteChecks( ]; } -export function moverChecks( - engine, - { until, nadirView, centre, readRegion, makeCanvas }, -) { +export function moverChecks(engine, context) { + const { until, nadirView, centre, readRegion, makeCanvas } = context; const { camera } = engine; return [ + ...surfaceMoverChecks(engine, context), + ...tilesMoverChecks(engine, context), [ 'movers.createSet: depth tests, rotation, cells, scale, count, points and models', async () => { @@ -430,19 +432,13 @@ export function moverChecks( 'the shrunk set drawn', ); ensure(look(a, MAGENTA).total > 100, 'instance 0 went with them'); - // Depth: under the ground only 'always' and a disableWithinM - // reaching past the camera draw. + // Terrain depth is cleared before movers, including points below it. const drawn = (index) => look(deep(19 + index * 0.7), RED).total > 100; await until(() => drawn(1), "the 'always' sprite drawn"); ensure(drawn(2), 'disableWithinM past the camera is hidden'); - // Cesium's billboards under the app's photoreal tiles (globe - // hidden) are not occluded by them, measured; Navara's overlay - // tests the scene depth. Occlusion is checked on Navara only. - if (engine.kind === 'navara') { - ensure(!drawn(0), "a 'normal' sprite under the ground is drawn"); - ensure(!drawn(3), 'disableWithinM 10 m under the ground is drawn'); - } + ensure(drawn(0), "a 'normal' sprite under terrain is hidden"); + ensure(drawn(3), 'disableWithinM 10 m under terrain is hidden'); // Points: a magenta disc with a dark outline. await until(() => look(p, MAGENTA, 16).total > 80, 'the point drawn'); const ring = look(p, [0, 0, 0], 16).total; @@ -498,6 +494,194 @@ export function moverChecks( ]; } +export function surfaceMoverChecks( + engine, + { settle, until, nadirView, readRegion }, +) { + return [ + [ + 'movers.createSet: sampled surface points ignore terrain and retain horizon occlusion', + async () => { + await nadirView(10000); + const groups = ['normal', 'always'].map((depthTest) => { + const set = engine.movers.createSet({ + id: `contract-surface-point-${depthTest}`, + kind: 'point', + capacity: 1, + depthTest, + point: { sizePx: 24, outlinePx: 2 }, + }); + const point = { + cartographic: cartographic.fromDegrees(20, 10), + position: cartesian3.fromDegrees(20, 10), + }; + set.set(0, { position: point.position, color: [1, 0, 1, 1] }); + set.setCount(1); + set.commit(); + return { set, points: [point] }; + }); + const placement = createSurfacePoints(engine, { + groups: () => groups, + prefer: 'terrain', + request: () => engine.render.request(), + }); + const select = (selected) => { + groups.forEach(({ set }, index) => { + set.setCount(index === selected ? 1 : 0); + set.commit(); + }); + }; + const pixels = (position) => { + const screen = engine.projector.worldToWindow(position); + ensure(screen, 'surface point has no projection'); + const data = readRegion( + Math.round(screen.x) - 24, + Math.round(screen.y) - 24, + 48, + 48, + ); + let count = 0; + for (let i = 0; i < data.length; i += 4) + if (like([1, 0, 1], data, i)) count++; + return count; + }; + try { + await until(() => { + placement.place({ all: true }); + return groups.every(({ points }) => + Number.isFinite(points[0].surfaceHeight), + ); + }, 'surface points placed'); + if (!readRegion) return; + const point = groups[0].points[0]; + // Compare the same pixel footprint, with only the depth mode changed. + select(1); + await settle(); + await until( + () => pixels(point.position) > 200, + 'surface control drawn', + ); + const control = pixels(point.position); + select(0); + await settle(); + await until( + () => pixels(point.position) >= control * 0.8, + 'normal point visible on its supporting surface', + ); + // The same near-side point stays visible below the terrain as well. + placement.destroy(); + const buried = cartesian3.fromDegrees( + 20, + 10, + point.surfaceHeight - 1000, + ); + for (const { set } of groups) { + set.set(0, { position: buried }); + set.commit(); + } + select(1); + await settle(); + await until(() => pixels(buried) > 200, 'buried control drawn'); + select(0); + await settle(); + await until( + () => pixels(buried) >= control * 0.8, + 'normal point visible below terrain', + ); + // Moving a previously visible point to the far side must hide it. + const farSide = cartesian3.fromDegrees(-160, -10); + groups[0].set.set(0, { position: farSide }); + groups[0].set.commit(); + await settle(); + ensure( + pixels(farSide) < 10, + 'normal point behind the horizon is visible', + ); + } finally { + placement.destroy(); + for (const { set } of groups) set.destroy(); + } + }, + ], + ]; +} + +export function tilesMoverChecks( + engine, + { settle, until, readRegion, fixtures }, +) { + const tile = fixtures?.tiles; + if (!tile?.position) return []; + return [ + [ + 'movers.createSet: normal points behind a tiles mesh are hidden', + async () => { + engine.camera.lookAt(tile.position, { + heading: 0, + pitch: -Math.PI / 2, + range: 1000, + }); + const place = cartographic.fromCartesian(tile.position); + const position = cartesian3.fromRadians( + place.longitude, + place.latitude, + place.height - 20, + ); + const sets = ['normal', 'always'].map((depthTest) => { + const set = engine.movers.createSet({ + id: `contract-tile-point-${depthTest}`, + kind: 'point', + capacity: 1, + depthTest, + point: { sizePx: 24 }, + }); + set.set(0, { position, color: [1, 0, 1, 1] }); + return set; + }); + const select = (index) => { + sets.forEach((set, i) => { + set.setCount(i === index ? 1 : 0); + set.commit(); + }); + }; + const pixels = () => { + const screen = engine.projector.worldToWindow(position); + ensure(screen, 'tile point has no projection'); + const data = readRegion( + Math.round(screen.x) - 24, + Math.round(screen.y) - 24, + 48, + 48, + ); + let count = 0; + for (let i = 0; i < data.length; i += 4) + if (like([1, 0, 1], data, i)) count++; + return count; + }; + try { + await until(() => { + const sample = engine.surface.sampleHeightDetailed(place); + return ( + sample?.source === 'tiles' && + Math.abs(sample.height - place.height) < 2 + ); + }, 'tile occluder loaded'); + if (!readRegion) return; + select(1); + await settle(); + await until(() => pixels() > 200, 'tile control drawn'); + select(0); + await settle(); + ensure(pixels() < 10, 'normal point behind a tiles mesh is visible'); + } finally { + for (const set of sets) set.destroy(); + engine.camera.release(); + } + }, + ], + ]; +} + export function failedModelChecks( engine, { until, fixtures, nadirView, readRegion }, diff --git a/src/globe/testing/contractSuite/movers.test.mjs b/src/globe/testing/contractSuite/movers.test.mjs new file mode 100644 index 000000000..c5b59870c --- /dev/null +++ b/src/globe/testing/contractSuite/movers.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { cartesian3, cartographic } from '../../../geo/index.js'; +import { createFakeEngine } from '../fakeEngine.js'; +import { fakeContractOptions } from '../contractFixtures.js'; +import { createContractSuite } from './index.mjs'; + +for (const [scenario, failure] of [ + ['visible surface and underground, occluded horizon', null], + ['surface buried', /normal point visible on its supporting surface/], + ['underground hidden', /normal point visible below terrain/], + ['depth bypass', /normal point behind the horizon is visible/], + ['missing control', /surface control drawn/], + ['missing height', /surface points placed/], +]) { + test(`surface mover check: ${scenario}`, async () => { + const engine = createFakeEngine(); + engine.fake.script('terrainHeight', () => + scenario === 'missing height' ? undefined : 400, + ); + const listeners = engine.frames.preRender.numberOfListeners; + const states = []; + const createSet = engine.movers.createSet.bind(engine.movers); + engine.movers.createSet = (options) => { + const set = createSet(options); + const state = { set, depthTest: options.depthTest }; + states.push(state); + const write = set.set.bind(set); + set.set = (index, fields) => { + if (fields.position) state.position = fields.position; + return write(index, fields); + }; + return set; + }; + let surfaceRead = false; + let buriedRead = false; + let horizonRead = false; + const check = createContractSuite(engine, { + ...fakeContractOptions(engine), + maxWaitFrames: 4, + readRegion: (_x, _y, width, height) => { + const shown = states.filter(({ set }) => set.count() === 1); + assert.equal(shown.length, 1, 'one depth mode at a time'); + const state = shown[0]; + const place = cartographic.fromCartesian(state.position); + const below = place.height < -100; + const farSide = place.longitude < 0; + const normal = state.depthTest === 'normal'; + surfaceRead ||= normal && !below && !farSide; + horizonRead ||= normal && farSide; + buriedRead ||= normal && below; + let count = 400; + if (scenario === 'missing control') count = 0; + else if (normal && farSide && scenario !== 'depth bypass') count = 0; + else if (normal && below && scenario === 'underground hidden') + count = 0; + else if (normal && !below && scenario === 'surface buried') count = 20; + const data = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < count; i++) data.set([255, 0, 255, 255], i * 4); + return data; + }, + }).find(({ name }) => name.startsWith('movers.createSet: sampled surface')); + try { + assert.ok(check, 'surface mover regression is in the contract suite'); + if (failure) await assert.rejects(check.run(), failure); + else { + await check.run(); + assert.ok(surfaceRead && buriedRead && horizonRead); + } + assert.deepEqual(engine.movers.list(), []); + assert.equal(engine.frames.preRender.numberOfListeners, listeners); + } finally { + engine.destroy(); + } + }); +} + +for (const [scenario, failure] of [ + ['occluded', null], + ['depth bypass', /normal point behind a tiles mesh is visible/], + ['missing control', /tile control drawn/], + ['missing tile', /tile occluder loaded/], +]) { + test(`tiles mover check: ${scenario}`, async () => { + const engine = createFakeEngine(); + engine.fake.script('sampleHeightDetailed', () => + scenario === 'missing tile' + ? undefined + : { source: 'tiles', height: 200, tileSizeM: 100 }, + ); + const options = fakeContractOptions(engine); + const check = createContractSuite(engine, { + ...options, + fixtures: { + ...options.fixtures, + tiles: { position: cartesian3.fromDegrees(20, 10, 200) }, + }, + maxWaitFrames: 4, + readRegion: (_x, _y, width, height) => { + const normal = + engine.fake.moverSet('contract-tile-point-normal').store.count === 1; + const count = + scenario === 'missing control' || + (normal && scenario !== 'depth bypass') + ? 0 + : 400; + const data = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < count; i++) data.set([255, 0, 255, 255], i * 4); + return data; + }, + }).find(({ name }) => name.includes('behind a tiles mesh')); + try { + assert.ok( + check, + 'tiles check is registered when the fixture is supplied', + ); + if (failure) await assert.rejects(check.run(), failure); + else await check.run(); + assert.deepEqual(engine.movers.list(), []); + assert.equal(engine.camera.following(), null); + } finally { + engine.destroy(); + } + }); +} + +test('fake normal movers remain pickable below nearby terrain and disappear behind the horizon', () => { + const engine = createFakeEngine(); + engine.fake.script('terrainHeight', () => 400); + engine.camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 10000), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + const set = engine.movers.createSet({ + id: 'terrain-depth', + kind: 'point', + capacity: 1, + depthTest: 'normal', + point: { sizePx: 24 }, + pickable: true, + }); + try { + for (const [position, visible] of [ + [cartesian3.fromDegrees(20, 10, 400), true], + [cartesian3.fromDegrees(20, 10, -600), true], + [cartesian3.fromDegrees(-160, -10), false], + ]) { + set.set(0, { position, pickId: 'marker' }); + set.setCount(1); + set.commit(); + const screen = engine.projector.worldToWindow(position); + assert.equal(engine.picker.pick(screen)?.id === 'marker', visible); + } + } finally { + engine.destroy(); + } +}); diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index e4e75fe5c..b1b8ed1b6 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -717,6 +717,7 @@ export function createFakeEngine(options = {}) { // Unscripted picks find the fake's own movers, lines and ground items, as // Navara's CPU pick does (src/globe/cpuPicks.js, src/globe/ground.js). + // Movers ignore terrain surface depth; the projector retains the horizon. const cpuPick = createCpuPicker({ projector: { worldToWindow: (position, result) =>