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) =>