From 7d5a484796a3b197dc22ce6577eeca0d87ad576a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 21:14:52 +0400 Subject: [PATCH] refactor(overlays): layer labels through the engine The world overlay host already projects through engine.projector and tests the horizon with projector.horizonVisible; the last Cesium value on its path, the snapshot of an entry's cullPosition, is now a geo Cartesian3. Navara's horizonVisible is Cesium's EllipsoidalOccluder.isPointVisible: tested against Cesium's own on 8 000 eye/point pairs (600 m to 20 000 km, points under, on and over the ellipsoid; no disagreement) and at the limb (a label 40 m under the ellipsoid just past the horizon is hidden, with its cullPosition lifted 100 m it is shown). It ran per label per frame with three new vectors each call; it now uses scratch vectors. Micro-measure (500 labels per frame, no scavenge during the run): 7 941 B per frame before, 51 after. The W1 sources that projected with Cesium's SceneTransforms themselves - the icon orientation's exact projection and the launch replay overlay - go through the engine's projector when their scene or viewer has a registered engine. Satellites, vessel labels and aircraft recession project nothing themselves (the host does). scripts/qa-overlay-engines.mjs publishes the same labels on both engines at a city, an orbital and a limb pose and compares the painted rectangles: worst 0 / 2 / 0 px; on both, the far-side label is not painted, the lifted limb label is and the buried one is not. --- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 36 ++++++++++++++++------ src/data/firmsHorizonCull.test.mjs | 6 ++-- src/data/iconOrientation.js | 22 +++++++------- src/data/iconOrientation.test.mjs | 24 +++++++++++++++ src/globe/navara/cameraMath.js | 13 ++++++-- src/globe/navara/cameraMath.test.mjs | 45 ++++++++++++++++++++++++++++ src/layers/launches/overlays.js | 20 ++++++++----- src/overlays/worldOverlay.js | 7 ++--- 9 files changed, 139 insertions(+), 37 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b9ba0a87b..6426480cc 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1095,5 +1095,6 @@ "scripts/qa-surface-heights.mjs", "src/globe/navara/followGestures.js", "src/globe/navara/followGestures.test.mjs", - "src/globe/cpuPicks.test.mjs" + "src/globe/cpuPicks.test.mjs", + "scripts/qa-overlay-engines.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a62a9503b..05ebf3baa 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -893,7 +893,9 @@ "src/geo/check.js", "src/geo/debug.js", "src/geo/developerError.js", - "src/time/clock.js" + "src/time/clock.js", + "src/globe/contract.js", + "src/globe/cesium/engineFromViewer.js" ], "external": ["cesium"] }, @@ -947,7 +949,9 @@ "src/geo/check.js", "src/geo/debug.js", "src/geo/developerError.js", - "src/time/clock.js" + "src/time/clock.js", + "src/globe/contract.js", + "src/globe/cesium/engineFromViewer.js" ], "external": ["cesium"] }, @@ -988,7 +992,9 @@ "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/developerError.js", + "src/globe/contract.js", + "src/globe/cesium/engineFromViewer.js" ], "external": ["cesium"] }, @@ -1028,7 +1034,9 @@ "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/developerError.js", + "src/globe/contract.js", + "src/globe/cesium/engineFromViewer.js" ], "external": ["cesium"] }, @@ -1088,7 +1096,9 @@ "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/developerError.js", + "src/globe/contract.js", + "src/globe/cesium/engineFromViewer.js" ], "external": ["cesium"] }, @@ -1171,7 +1181,9 @@ "src/geo/check.js", "src/geo/debug.js", "src/geo/developerError.js", - "src/time/clock.js" + "src/time/clock.js", + "src/globe/contract.js", + "src/globe/cesium/engineFromViewer.js" ], "external": ["cesium"] }, @@ -1263,7 +1275,9 @@ "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/developerError.js", + "src/globe/contract.js", + "src/globe/cesium/engineFromViewer.js" ], "external": ["cesium"] }, @@ -1414,7 +1428,9 @@ "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/developerError.js", + "src/globe/contract.js", + "src/globe/cesium/engineFromViewer.js" ], "external": ["hls.js", "cesium"] }, @@ -1557,7 +1573,9 @@ "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/developerError.js", + "src/globe/contract.js", + "src/globe/cesium/engineFromViewer.js" ], "external": ["cesium"] }, diff --git a/src/data/firmsHorizonCull.test.mjs b/src/data/firmsHorizonCull.test.mjs index 198ffefdc..e7fe3afaf 100644 --- a/src/data/firmsHorizonCull.test.mjs +++ b/src/data/firmsHorizonCull.test.mjs @@ -226,8 +226,10 @@ test('isOverlayPointVisible: horizon test prefers entry.cullPosition when presen test('normalizeOverlayEntry: carries a valid cullPosition, nulls a junk one', () => { const position = { x: 1, y: 2, z: 3 }; - const kept = normalizeOverlayEntry('firms', { id: 'a', position, cullPosition: { x: 4, y: 5, z: 6 } }); - assert.ok(kept.cullPosition instanceof Cesium.Cartesian3, 'stored as a host-owned Cartesian3'); + const source = { x: 4, y: 5, z: 6 }; + const kept = normalizeOverlayEntry('firms', { id: 'a', position, cullPosition: source }); + assert.notEqual(kept.cullPosition, source, 'stored as a host-owned copy (a geo Cartesian3)'); + assert.ok(!(kept.cullPosition instanceof Cesium.Cartesian3), 'not a Cesium value'); assert.deepEqual( [kept.cullPosition.x, kept.cullPosition.y, kept.cullPosition.z], [4, 5, 6], diff --git a/src/data/iconOrientation.js b/src/data/iconOrientation.js index e633cf45b..0c1c41975 100644 --- a/src/data/iconOrientation.js +++ b/src/data/iconOrientation.js @@ -23,6 +23,16 @@ */ import * as Cesium from 'cesium'; import * as geo from '../geo/index.js'; +import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; + +// A window position through the engine's projector when `scene` has a +// registered engine (engineFromViewer), else Cesium's SceneTransforms. +function worldToWindow(scene, position, result) { + const projector = engineFromViewer(scene)?.projector; + return projector + ? projector.worldToWindow(position, result) + : Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, result); +} /** Meters used to give the course vector a stable projection magnitude. */ const FORWARD_PROBE_M = 2000; @@ -331,16 +341,8 @@ export function perspectiveProjectedRotation( ); geo.cartesian3.add(position, _scratchWorldForward, _scratchProbe); - const at = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - position, - _scratchWindowAt, - ); - const ahead = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - _scratchProbe, - _scratchWindowAhead, - ); + const at = worldToWindow(scene, position, _scratchWindowAt); + const ahead = worldToWindow(scene, _scratchProbe, _scratchWindowAhead); if (!at || !ahead) { return screenProjectedRotation(scene, position, courseDeg, previous); } diff --git a/src/data/iconOrientation.test.mjs b/src/data/iconOrientation.test.mjs index 62808ce79..31c627af9 100644 --- a/src/data/iconOrientation.test.mjs +++ b/src/data/iconOrientation.test.mjs @@ -745,3 +745,27 @@ test('the perspective helper is continuous through the sub-pixel band, not a 23 `exact alone at ${s.separation.toFixed(2)} px`, ); }); + +test('the exact projection asks the engine projector of a registered scene', async () => { + const geo = await import('../geo/index.js'); + const { registerViewerEngine } = await import('../globe/cesium/engineFromViewer.js'); + const { perspectiveProjectedRotation: rotation } = await import('./iconOrientation.js'); + const scene = { camera: { rightWC: { x: 0, y: 1, z: 0 }, upWC: { x: 0, y: 0, z: 1 } } }; + const asked = []; + const unregister = registerViewerEngine({ scene }, { + projector: { + // Screen-right for every point further east (the probe), so 90° on screen. + worldToWindow(position, result = geo.cartesian2.create()) { + asked.push(position); + return Object.assign(result, { x: asked.length === 1 ? 100 : 120, y: 100 }); + }, + }, + }); + try { + const value = rotation(scene, geo.cartesian3.fromDegrees(0, 0, 0), 90, null); + assert.equal(asked.length, 2, 'the anchor and the probe'); + assert.ok(Math.abs(value - -Math.PI / 2) < 1e-9, `rotation ${value}`); + } finally { + unregister(); + } +}); diff --git a/src/globe/navara/cameraMath.js b/src/globe/navara/cameraMath.js index a6911d61a..07d68f61a 100644 --- a/src/globe/navara/cameraMath.js +++ b/src/globe/navara/cameraMath.js @@ -221,20 +221,27 @@ export function pickEllipsoid(camera, screen) { return intersectEllipsoid(camera.position, pickRay(camera, screen).direction); } +// horizonVisible runs per label per frame (worldOverlay): no allocation. +const horizonCamera = cartesian3.create(); +const horizonTarget = cartesian3.create(); +const horizonVt = cartesian3.create(); + /** Cesium's EllipsoidalOccluder.isPointVisible from `eye`. */ export function horizonVisible(eye, position) { - const camera = cartesian3.create( + const camera = cartesian3.fromElements( eye.x / RADII.x, eye.y / RADII.y, eye.z / RADII.z, + horizonCamera, ); const limbSquared = cartesian3.magnitudeSquared(camera) - 1; - const target = cartesian3.create( + const target = cartesian3.fromElements( position.x / RADII.x, position.y / RADII.y, position.z / RADII.z, + horizonTarget, ); - const vt = cartesian3.subtract(target, camera, cartesian3.create()); + const vt = cartesian3.subtract(target, camera, horizonVt); const vtDotVc = -cartesian3.dot(vt, camera); const occluded = limbSquared < 0 diff --git a/src/globe/navara/cameraMath.test.mjs b/src/globe/navara/cameraMath.test.mjs index 2499a6f35..8fddd9090 100644 --- a/src/globe/navara/cameraMath.test.mjs +++ b/src/globe/navara/cameraMath.test.mjs @@ -313,3 +313,48 @@ test('cartographic heights of the math agree with src/geo', () => { 'height', ); }); + +test("horizonVisible agrees with Cesium's EllipsoidalOccluder, also at the limb", async () => { + const Cesium = await import('cesium'); + const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); + let seed = 7; + const random = () => { + seed = (seed * 16807) % 2147483647; + return seed / 2147483647; + }; + let disagreements = 0; + let checked = 0; + for (let i = 0; i < 400; i++) { + const eye = cartesian3.fromDegrees( + random() * 360 - 180, + random() * 160 - 80, + [600, 15_000, 400_000, 20_000_000][i % 4], + ); + occluder.cameraPosition = new Cesium.Cartesian3(eye.x, eye.y, eye.z); + for (let j = 0; j < 20; j++) { + // Points on the ground and above it, many near the horizon of the eye. + const point = cartesian3.fromDegrees( + random() * 360 - 180, + random() * 160 - 80, + [0, -40, 50, 10_000][j % 4], + ); + checked++; + if ( + horizonVisible(eye, point) !== + occluder.isPointVisible( + new Cesium.Cartesian3(point.x, point.y, point.z), + ) + ) + disagreements++; + } + } + assert.equal(disagreements, 0, `${disagreements}/${checked} disagree`); + // At the limb: a point 40 m under the ellipsoid just past the horizon is + // hidden, its cullPosition lifted 100 m is visible (worldOverlay's rule). + const eye = cartesian3.fromDegrees(0, 0, 10_000); + const horizonDeg = math.toDegrees(Math.acos(6378137 / (6378137 + 10_000))); + const buried = cartesian3.fromDegrees(horizonDeg + 0.02, 0, -40); + const lifted = cartesian3.fromDegrees(horizonDeg + 0.02, 0, 100); + assert.equal(horizonVisible(eye, buried), false); + assert.equal(horizonVisible(eye, lifted), true); +}); diff --git a/src/layers/launches/overlays.js b/src/layers/launches/overlays.js index 682007e84..fa7ccb88f 100644 --- a/src/layers/launches/overlays.js +++ b/src/layers/launches/overlays.js @@ -1,3 +1,4 @@ +import { engineFromViewer } from '../../globe/cesium/engineFromViewer.js'; import * as Cesium from 'cesium'; import { createCyberSonarSampler, @@ -483,10 +484,16 @@ export function createOverlays({ state: layerState, services, parts, source }) { hideReplayVehicleOverlay(); return null; } - const windowPosition = Cesium.SceneTransforms.worldToWindowCoordinates( - layerState._viewer.scene, - position, - ); + // The engine's projector (engineFromViewer), as the world overlay. + const projector = engineFromViewer(layerState._viewer)?.projector; + const project = (point) => + projector + ? projector.worldToWindow(point) + : Cesium.SceneTransforms.worldToWindowCoordinates( + layerState._viewer.scene, + point, + ); + const windowPosition = project(position); if (!windowPosition) { hideReplayVehicleOverlay(); return null; @@ -548,10 +555,7 @@ export function createOverlays({ state: layerState, services, parts, source }) { useForward ? forwardProgress : backwardProgress, ); const tangentWindowPosition = tangentPosition - ? Cesium.SceneTransforms.worldToWindowCoordinates( - layerState._viewer.scene, - tangentPosition, - ) + ? project(tangentPosition) : null; if (tangentWindowPosition) { const screenDelta = Math.hypot( diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 78b3e682d..e040de295 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import * as geo from '../geo/index.js'; import { isGlobeEngine } from '../globe/contract.js'; import { @@ -416,7 +415,8 @@ export function paintLaneForOverlayEntry(entry = {}) { * point) to the per-frame occluder. * * @param {object} entry - Raw source entry. - * @returns {?Cesium.Cartesian3} Host-owned lifted anchor, or null. + * @returns {?{x: number, y: number, z: number}} Host-owned lifted anchor + * (a geo Cartesian3), or null. */ function snapshotCullPosition(entry) { let candidate; @@ -429,8 +429,7 @@ function snapshotCullPosition(entry) { const { x, y, z } = candidate; if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null; - // Still the sources' own vector type until they move off Cesium (phase 1c). - return new Cesium.Cartesian3(x, y, z); + return geo.cartesian3.create(x, y, z); } export function normalizeOverlayEntry(sourceId, entry) {