From 10d0e49ddb3b65bd9a13f864875ac9d75ef0d263 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 17 Oct 2026 05:23:50 +0400 Subject: [PATCH] fix(globe): Navara aircraft models face, size and light as Cesium's (10b review 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The near-pose finding (Navara models faint, Cesium ~23 bright aircraft) came from the side-by-side's frozen page clock: the fleet tick ran once per pose, so Cesium never handed its aircraft to their models and drew 3× glyphs, against real models on Navara. With the hand-off driven (the move ends every 10 frames while a pose loads), both engines draw models, and the comparison showed three real differences, all fixed: - Orientation: Navara's glTF loader is three's GLTFLoader, which keeps the scene Y-up; the instance turn is now Cesium's whole axis correction (Y_UP_TO_Z_UP × Z_UP_TO_X_UP), not Z_UP_TO_X_UP alone. Every fleet model drew 90° off its heading. - Size: minimumPixelSize is taken at Cesium's distance (Camera distanceToBoundingSphere: view depth less the radius at the model's own scale), in the mover and in ModelHandle.computedScale; the straight-line distance drew off-centre models up to 16 % larger. - Colour: the instance colour tints by the set's blend amount (Cesium's colorBlendAmount) and its alpha is the model's opacity (Cesium: a model colour's alpha makes it translucent), not a blend weight; a model is translucent only while faded. An ambient share of the tinted surface (MODEL_AMBIENT 0.5, Cesium's image-based lighting) keeps a tinted aircraft near-white at a low sun (median luminance 139 → 190; Cesium 197). qa-flights-engines (now --layer=flights|military) gates the near pose with --models=on: computed scale within ±5 % per model (ratio 1.000007), footprint against the same view with the layer off within ±20 % (1553 vs 1496 px, 104 %), median luminance within ±10 % (189.8 vs 196.7). The probe's drawn() reports a model's computedScale. Flights models on: 19/19. --- src/globe/navara/movers/index.js | 7 ++ src/globe/navara/movers/modelMover.js | 75 +++++++++++++++++++-- src/globe/navara/movers/modelMover.test.mjs | 53 ++++++++++++++- src/layers/aircraft/qaDrawn.js | 8 ++- src/layers/aircraft/render/navara.js | 28 +++++--- src/layers/aircraft/render/navara.test.mjs | 10 ++- 6 files changed, 158 insertions(+), 23 deletions(-) diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index 4eddd4dec..cd80e5a46 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -72,6 +72,7 @@ class GevMoverSetDesc extends MeshDesc { // The camera a mover needs each drawn frame (scratch: no allocation). const frameCamera = { cameraPosition: { x: 0, y: 0, z: 0 }, + cameraDirection: { x: 0, y: 0, z: -1 }, cameraHeight: 0, fovDeg: 60, viewportHeightPx: 0, @@ -86,6 +87,12 @@ function updateMover(view, ctx, mover) { position.x = e[12]; position.y = e[13]; position.z = e[14]; + // A three camera looks down its local −z. + const direction = frameCamera.cameraDirection; + const length = Math.hypot(e[8], e[9], e[10]) || 1; + direction.x = -e[8] / length; + direction.y = -e[9] / length; + direction.z = -e[10] / length; frameCamera.cameraHeight = Math.hypot(position.x, position.y, position.z) - EARTH_RADIUS_M; frameCamera.fovDeg = raw.fov; diff --git a/src/globe/navara/movers/modelMover.js b/src/globe/navara/movers/modelMover.js index e27c6b2f5..b9e3b26fa 100644 --- a/src/globe/navara/movers/modelMover.js +++ b/src/globe/navara/movers/modelMover.js @@ -19,12 +19,39 @@ import { } from 'three'; import { footprintPerDepth, originFor } from './pack.js'; +/** + * Ambient light on the tinted surface, as an emissive share of it: Cesium + * lights models with image-based lighting besides the sun, so a tinted + * aircraft reads near-white at any sun angle; Navara's sky light alone left + * them grey-brown at dawn. 0.5 matched Cesium's model luminance at the near + * pose (qa-flights-engines --models=on: median 190 vs 193 on Cesium; 139 + * without it). + */ +export const MODEL_AMBIENT = 0.5; + +/** + * The distance Cesium sizes a model's minimumPixelSize at (Camera + * distanceToBoundingSphere): |offset · view direction| less `radiusM`, at + * least 0; the straight-line distance when no direction is known. + */ +export function modelPixelDistance(dx, dy, dz, direction, radiusM) { + const along = direction + ? Math.abs(dx * direction.x + dy * direction.y + dz * direction.z) + : Math.sqrt(dx * dx + dy * dy + dz * dz); + return Math.max(0, along - radiusM); +} + const scratchMatrix = new Matrix4(); const scratchQuaternion = new Quaternion(); const scratchTranslation = new Vector3(); const scratchScale = new Vector3(); -/** Mixes the instance colour into the material's colour by uGevBlend × alpha. */ +/** + * Mixes the instance colour into the material's colour by uGevBlend (the + * set's colorBlend, as Cesium's colorBlendAmount); the instance alpha is the + * model's opacity (Cesium: a model colour's alpha makes it translucent; the + * aircraft fade with distance through it), not a blend weight. + */ function blendInstanceColor(material, uniforms) { material.onBeforeCompile = (shader) => { Object.assign(shader.uniforms, uniforms); @@ -40,11 +67,15 @@ function blendInstanceColor(material, uniforms) { shader.fragmentShader = shader.fragmentShader .replace( 'void main() {', - 'uniform float uGevBlend;\nvarying vec4 vGevColor;\nvoid main() {', + 'uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\nvoid main() {', + ) + .replace( + '#include ', + '#include \ntotalEmissiveRadiance += diffuseColor.rgb * uGevAmbient;', ) .replace( '#include ', - '#include \ndiffuseColor.rgb = mix(diffuseColor.rgb, vGevColor.rgb, uGevBlend * vGevColor.a);', + '#include \ndiffuseColor.rgb = mix(diffuseColor.rgb, vGevColor.rgb, uGevBlend);\ndiffuseColor.a *= vGevColor.a;', ); }; material.customProgramCacheKey = () => 'gev-mover-model-blend'; @@ -58,6 +89,7 @@ function blendInstanceColor(material, uniforms) { export function createModelMover(options, store, { load, onLoad = () => {} }) { const { capacity, model } = options; const uniforms = { + uGevAmbient: { value: MODEL_AMBIENT }, uGevBlend: { value: model.colorBlend ? model.colorBlend.mode === 'replace' @@ -71,9 +103,24 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) { let origin = null; let footprint = 0; let cameraPosition = null; + let cameraDirection = null; let loaded = false; let disposed = false; const colorAttributes = []; + const materials = []; + // Translucent only while an instance is faded (opaque models keep the + // opaque pass). + const syncTransparency = () => { + const { colors } = store; + let faded = false; + for (let index = 0; index < store.count && !faded; index++) + if (store.shows[index] === 1 && colors[index * 4 + 3] < 1) faded = true; + for (const material of materials) + if (material.transparent !== faded) { + material.transparent = faded; + material.needsUpdate = true; + } + }; const write = (from, to) => { if (!loaded || !origin) return; @@ -84,7 +131,16 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) { const dx = positions[index * 3] - cameraPosition.x; const dy = positions[index * 3 + 1] - cameraPosition.y; const dz = positions[index * 3 + 2] - cameraPosition.z; - const distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + // Cesium's distance for minimumPixelSize (Camera + // distanceToBoundingSphere): the depth along the view direction + // less the model's radius at its own scale. + const distance = modelPixelDistance( + dx, + dy, + dz, + cameraDirection, + radius * scale, + ); const pixels = (2 * radius * scale) / (footprint * distance); if (pixels < model.minimumPixelSize) scale *= model.minimumPixelSize / pixels; @@ -119,6 +175,7 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) { mesh.instanceMatrix.needsUpdate = true; } for (const attribute of colorAttributes) attribute.needsUpdate = true; + syncTransparency(); }; const group = { meshes: [], ready: null }; @@ -140,6 +197,7 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) { }); else material = material.clone(); blendInstanceColor(material, uniforms); + materials.push(material); const geometry = child.geometry.clone(); const color = new InstancedBufferAttribute( new Float32Array(capacity * 4).fill(1), @@ -189,9 +247,16 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) { for (const { mesh } of parts) mesh.count = store.count; }, /** Each drawn frame: pixel limits need every instance rewritten. */ - update({ cameraPosition: camera, cameraHeight, fovDeg, viewportHeightPx }) { + update({ + cameraPosition: camera, + cameraDirection: direction = null, + cameraHeight, + fovDeg, + viewportHeightPx, + }) { footprint = footprintPerDepth(fovDeg, viewportHeightPx); cameraPosition = camera; + cameraDirection = direction; if (!origin || store.count === 0) return false; const next = originFor(camera, origin, cameraHeight); if (next !== origin) placeAt(next); diff --git a/src/globe/navara/movers/modelMover.test.mjs b/src/globe/navara/movers/modelMover.test.mjs index cf2d88f82..28c2c9562 100644 --- a/src/globe/navara/movers/modelMover.test.mjs +++ b/src/globe/navara/movers/modelMover.test.mjs @@ -9,7 +9,7 @@ import { Vector3, } from 'three'; import { createMoverStore, normalizeMoverSetOptions } from '../../movers.js'; -import { createModelMover } from './modelMover.js'; +import { createModelMover, modelPixelDistance } from './modelMover.js'; const options = normalizeMoverSetOptions({ id: 'm', @@ -69,6 +69,7 @@ test('a far model keeps its minimum pixel size', async () => { await mover.group.ready; mover.update({ cameraPosition: { x: 6_378_137 + 100_000, y: 0, z: 0 }, + cameraDirection: { x: -1, y: 0, z: 0 }, cameraHeight: 100_000, fovDeg: 60, viewportHeightPx: 800, @@ -76,11 +77,57 @@ test('a far model keeps its minimum pixel size', async () => { const matrix = new Matrix4(); mover.group.meshes[0].getMatrixAt(0, matrix); const scale = new Vector3().setFromMatrixScale(matrix).x; - // Box radius √3; 40 px at 100 km with a 60° fov over 800 px. + // Box radius √3; 40 px at 100 km (less the radius, as Cesium) with a 60° + // fov over 800 px. const footprint = (2 * Math.tan(Math.PI / 6)) / 800; - const expected = (40 * footprint * 100_000) / (2 * Math.sqrt(3)); + const expected = + (40 * footprint * (100_000 - Math.sqrt(3))) / (2 * Math.sqrt(3)); assert.ok( Math.abs(scale - expected) / expected < 1e-6, `${scale} vs ${expected}`, ); }); + +test("the instance colour tints by the set's blend amount; its alpha is opacity, as Cesium's model colour", async () => { + const store = createMoverStore(options); + const mover = createModelMover(options, store, { + load: async () => fakeGltf(), + }); + store.set(0, { + position: { x: 6_378_137, y: 0, z: 0 }, + color: [1, 1, 1, 0.4], + }); + store.setCount(1); + mover.commit(store.dirty()); + await mover.group.ready; + const [mesh] = mover.group.meshes; + const shader = { + uniforms: {}, + vertexShader: 'void main() {\n#include \n}', + fragmentShader: + 'void main() {\n#include \n#include \n}', + }; + mesh.material.onBeforeCompile(shader); + assert.match( + shader.fragmentShader, + /diffuseColor\.rgb = mix\(diffuseColor\.rgb, vGevColor\.rgb, uGevBlend\);\s*diffuseColor\.a \*= vGevColor\.a;/, + ); + // Ambient light on the tinted surface (Cesium's image-based lighting). + assert.match( + shader.fragmentShader, + /totalEmissiveRadiance \+= diffuseColor\.rgb \* uGevAmbient;/, + ); + assert.equal(mover.uniforms.uGevAmbient.value, 0.5); + assert.equal(mesh.material.transparent, true, 'a faded instance'); + store.set(0, { color: [1, 1, 1, 1] }); + mover.commit(store.dirty()); + assert.equal(mesh.material.transparent, false, 'all opaque again'); +}); + +test("the minimum pixel size is taken at Cesium's distance: view depth less the radius", () => { + const ahead = { x: 0, y: 0, z: -1 }; + // 3 km ahead and 4 km aside: depth 3 km, not the 5 km straight line. + assert.equal(modelPixelDistance(4000, 0, -3000, ahead, 30), 2970); + assert.equal(modelPixelDistance(0, 0, -10, ahead, 30), 0); + assert.equal(modelPixelDistance(4000, 0, -3000, null, 0), 5000); +}); diff --git a/src/layers/aircraft/qaDrawn.js b/src/layers/aircraft/qaDrawn.js index bca923a46..24ade0dcc 100644 --- a/src/layers/aircraft/qaDrawn.js +++ b/src/layers/aircraft/qaDrawn.js @@ -30,7 +30,13 @@ export function aircraftQaDrawn(state) { } : null, model: model - ? { scale: model.scale, show: model.show, ready: model.ready } + ? { + scale: model.scale, + // The drawn scale, after the minimum pixel size. + computedScale: model.computedScale, + show: model.show, + ready: model.ready, + } : null, }); } diff --git a/src/layers/aircraft/render/navara.js b/src/layers/aircraft/render/navara.js index dcc9fba31..a8447e744 100644 --- a/src/layers/aircraft/render/navara.js +++ b/src/layers/aircraft/render/navara.js @@ -27,15 +27,15 @@ import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js' const BASE_SIZE_PX = 20; /** - * The model frame → glTF scene axes on Navara. Cesium draws a glTF through - * Y_UP_TO_Z_UP × Z_UP_TO_X_UP (data/modelVisualAnchor.js); Navara's glTF - * loader already turns the scene Y-up to Z-up, so here it is Z_UP_TO_X_UP - * alone: nose, top and heading of airplane.glb where Cesium draws them - * (checked side by side with Cesium on a tracked aircraft at 300 m against - * five other axis candidates). + * The model frame → glTF scene axes on Navara: Cesium's own glTF axis + * correction, Y_UP_TO_Z_UP × Z_UP_TO_X_UP (data/modelVisualAnchor.js). + * Navara's glTF loader is three's GLTFLoader, which keeps the scene Y-up. + * (10b used Z_UP_TO_X_UP alone, on the belief that the loader turned the + * scene Z-up: every fleet model drew 90° off its heading — side by side + * with Cesium at the near pose, 10b review item 3.) */ -// Cesium's Axis.Z_UP_TO_X_UP, column-major. -const NAVARA_GLTF_AXIS = [0, 0, -1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1]; +// Cesium's Axis.Y_UP_TO_Z_UP × Axis.Z_UP_TO_X_UP, column-major. +const NAVARA_GLTF_AXIS = [0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1]; const FIRST_CAPACITY = 1024; const ATLAS_PAD = 2; const TRAIL_ALPHA = 0.85; @@ -644,6 +644,8 @@ export function createAircraftNavaraRenderer({ return bank; }; + const scratchOffset = geo.cartesian3.create(); + class ModelHandle { constructor(spec) { this.spec = spec; @@ -669,9 +671,13 @@ export function createAircraftNavaraRenderer({ const minimum = this.spec.minimumPixelSize ?? 0; if (!(minimum > 0)) return this._scale; const cam = freshCamera(); - const distance = geo.cartesian3.distance( - cam.positionWC, - this.translation, + // Cesium's distance (Camera distanceToBoundingSphere): the depth + // along the view less the radius at the model's own scale. + geo.cartesian3.subtract(this.translation, cam.positionWC, scratchOffset); + const distance = Math.max( + 0, + Math.abs(geo.cartesian3.dot(scratchOffset, cam.directionWC)) - + this.radiusM * this._scale, ); const height = engine.host.size().height; if (!(distance > 0) || !(height > 0)) return this._scale; diff --git a/src/layers/aircraft/render/navara.test.mjs b/src/layers/aircraft/render/navara.test.mjs index 6c186caa6..08730e0ec 100644 --- a/src/layers/aircraft/render/navara.test.mjs +++ b/src/layers/aircraft/render/navara.test.mjs @@ -227,11 +227,15 @@ test("a model pose is the Cesium model frame, turned to Navara's glTF axes", asy ); for (let index = 0; index < 16; index++) assert.ok(Math.abs(model.modelMatrix[index] - expected[index]) < 1e-6); - // The instance orientation: modelMatrix × Cesium's Z_UP_TO_X_UP (Navara's - // loader already turned the glTF Z-up). + // The instance orientation: modelMatrix × Cesium's glTF axis correction + // (Y_UP_TO_Z_UP × Z_UP_TO_X_UP; Navara's loader keeps the glTF Y-up). const corrected = Cesium.Matrix4.multiplyTransformation( expected, - Cesium.Axis.Z_UP_TO_X_UP, + Cesium.Matrix4.multiplyTransformation( + Cesium.Axis.Y_UP_TO_Z_UP, + Cesium.Axis.Z_UP_TO_X_UP, + new Cesium.Matrix4(), + ), new Cesium.Matrix4(), ); const q = Cesium.Quaternion.fromRotationMatrix(