fix(globe): Navara aircraft models face, size and light as Cesium's (10b review 3)

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.
This commit is contained in:
German Khokhlov committed 2026-10-17 05:23:50 +04:00
1 parent 9dc446bd24
commit 10d0e49ddb
6 files changed
+158 -23

No files matched your search

+7
View File
@@ -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;
+70 -5
View File
@@ -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 <emissivemap_fragment>',
'#include <emissivemap_fragment>\ntotalEmissiveRadiance += diffuseColor.rgb * uGevAmbient;',
)
.replace(
'#include <map_fragment>',
'#include <map_fragment>\ndiffuseColor.rgb = mix(diffuseColor.rgb, vGevColor.rgb, uGevBlend * vGevColor.a);',
'#include <map_fragment>\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);
+50 -3
View File
@@ -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 <begin_vertex>\n}',
fragmentShader:
'void main() {\n#include <map_fragment>\n#include <emissivemap_fragment>\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);
});
+7 -1
View File
@@ -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,
});
}
+17 -11
View File
@@ -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;
+7 -3
View File
@@ -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(