feat(globe): mover sets report ready(); Navara scales by distance as Cesium

Task 10b foundation.
- MoverSet.ready(): whether what the set draws has loaded (Navara: the
  model set's glTF, a URL atlas; Cesium: every instance's Model, the cropped
  atlas; parts without it draw at once). The aircraft renderer keeps the 2D
  glyph until a model set is ready, as the Cesium path waits on Model.ready.
  Contract suite: a drawn model set is ready() (both engines); unit test of
  the neutral store.
- Navara sprites and points interpolate scaleByDistance as Cesium's
  NearFarScalar (over the squared camera distance, eased by ^0.2) instead
  of linearly in distance, so aircraft glyphs have Cesium's size at every
  range; the CPU pick radius follows (distanceScale, tested equal to
  focusDeemphasis' port of czm_nearFarScalar). The contract text says so.
This commit is contained in:
German Khokhlov committed 2026-10-17 01:16:59 +04:00
1 parent d81ebf1268
commit 4dfcba8989
10 files changed
+103 -14

No files matched your search

+7
View File
@@ -139,6 +139,7 @@ function spritePart(scene, options, store, factories, requestRender, owners) {
.catch((error) => console.warn(`[movers] ${id}: atlas failed`, error));
return {
primitive: collection,
ready: () => cells !== null,
commit(range) {
if (cells && range) apply(range.from, range.to);
requestRender('movers');
@@ -254,6 +255,11 @@ function modelPart(options, store, factories, requestRender, owners) {
};
return {
primitive: collection,
ready() {
for (let index = 0; index < store.count; index++)
if (!models[index]?.ready) return false;
return true;
},
commit(range) {
if (range)
for (let index = range.from; index < range.to; index++) {
@@ -308,6 +314,7 @@ export function createCesiumMovers(
rootCollection().add(primitive, at);
return {
primitive,
ready: part.ready,
commit: () => part.commit(store.dirty()),
destroy() {
part.destroy();
+5 -1
View File
@@ -317,7 +317,8 @@
* sizePx: number, scaleByDistance?: number[] }} [sprite] An image atlas
* whose cells are [u0, v0, u1, v1] in 0..1 (v down from the image top);
* each sprite is a square of sizePx CSS px times its scale, times the
* NearFarScalar [nearM, nearScale, farM, farScale] when given.
* NearFarScalar [nearM, nearScale, farM, farScale] when given (Cesium's
* interpolation: over the squared camera distance, eased by ^0.2).
* @property {{ sizePx: number, outlinePx?: number, scaleByDistance?: number[] }} [point]
* A filled disc of sizePx CSS px diameter with a dark outline.
* @property {{ url: string, minimumPixelSize?: number, maximumScale?: number,
@@ -348,6 +349,9 @@
* requests a render; nothing else draws a frame.
* @property {() => Float64Array} positionsView x, y, z of the drawn
* instances (a view on the set's own array; read-only).
* @property {() => boolean} ready Whether what the set draws has loaded: a
* model set's glTF (Cesium: every instance's model), a sprite set's atlas.
* A layer keeps its fallback visual until then.
* @property {() => void} destroy
*
* @typedef {object} GlobeMovers
+14 -6
View File
@@ -42,14 +42,22 @@ const segmentT = (p, a, b) => {
: 0;
};
// scaleByDistance [nearM, nearScale, farM, farScale] at `distance`, linear
// between near and far as the Navara mover shader draws it.
function distanceScale(scaleByDistance, distance) {
// scaleByDistance [nearM, nearScale, farM, farScale] at `distance`, as
// Cesium's NearFarScalar and the Navara mover shader draw it: over the
// squared distance, eased by ^0.2.
export function distanceScale(scaleByDistance, distance) {
if (!scaleByDistance) return 1;
const [near, nearScale, far, farScale] = scaleByDistance;
const t = Math.min(
1,
Math.max(0, (distance - near) / Math.max(far - near, 1e-6)),
const t = Math.pow(
Math.min(
1,
Math.max(
0,
(distance * distance - near * near) /
Math.max(far * far - near * near, 1e-6),
),
),
0.2,
);
return nearScale + (farScale - nearScale) * t;
}
+23 -3
View File
@@ -46,8 +46,28 @@ test('the CPU pick radius follows scaleByDistance', () => {
const shrink = [1_000, 1, 10_000, 0.25];
assert.equal(pickAt(22, shrink), 0);
assert.equal(pickAt(7, shrink), 1);
// Halfway between near and far: linear, as the Navara shader draws it.
// Halfway between near and far: Cesium's NearFarScalar (squared distance,
// eased by ^0.2): t = 0.25^0.2 = 0.758, scale 0.621, radius 12.4 + 3 px.
const half = [0, 1, 20_000, 0.5];
assert.equal(pickAt(17, half), 1, '15 + 3 px');
assert.equal(pickAt(19, half), 0);
assert.equal(pickAt(15, half), 1, '12.4 + 3 px');
assert.equal(pickAt(16, half), 0);
});
test('distanceScale is Cesium.NearFarScalar interpolation', async () => {
const Cesium = await import('cesium');
const { distanceScale } = await import('./cpuPicks.js');
const scalar = [1000, 3.0, 8_000_000, 0.5];
// Cesium's czm_nearFarScalar on the CPU (focusDeemphasis ports it too).
const { nearFarScalarValueAtDistance } =
await import('../data/focusDeemphasis.js');
for (const distance of [0, 1000, 5000, 1e5, 2e6, 8e6, 2e7]) {
const expected = nearFarScalarValueAtDistance(
new Cesium.NearFarScalar(...scalar),
distance,
);
assert.ok(
Math.abs(distanceScale(scalar, distance) - expected) < 1e-12,
`${distance} m`,
);
}
});
+7 -1
View File
@@ -309,7 +309,10 @@ export function createMoverStore(options) {
* destroy. commit() hands the engine the changed range and cleans the store;
* a destroyed set refuses every call but destroy().
*/
export function createMoverSet(store, { id, commit, destroy }) {
export function createMoverSet(
store,
{ id, commit, destroy, ready = () => true },
) {
let destroyed = false;
const alive = () => {
if (destroyed) throw new Error('Mover set was destroyed');
@@ -332,6 +335,8 @@ export function createMoverSet(store, { id, commit, destroy }) {
store.clean();
},
positionsView: () => store.positionsView(),
// Whether what it draws has loaded (a model's glTF, an atlas image).
ready: () => !destroyed && ready() === true,
destroy() {
if (destroyed) return;
destroyed = true;
@@ -367,6 +372,7 @@ export function createMoverRegistry(build) {
sets.delete(options.id);
part.destroy();
},
ready: () => (part.ready ? part.ready() : true),
});
sets.set(options.id, entry);
return entry.set;
+29
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
MOVER_KINDS,
createMoverRegistry,
createMoverSet,
createMoverStore,
normalizeMoverSetOptions,
@@ -207,3 +208,31 @@ test('a destroyed set releases its instance arrays', () => {
assert.equal(set.positionsView().length, 0);
assert.equal(store.dirty(), null);
});
test('a set is ready() once what it draws has loaded, never once destroyed', () => {
let loaded = false;
const registry = createMoverRegistry(() => ({
commit() {},
destroy() {},
ready: () => loaded,
}));
const set = registry.movers.createSet({
id: 'r',
kind: 'point',
capacity: 1,
point: { sizePx: 4 },
});
assert.equal(set.ready(), false);
loaded = true;
assert.equal(set.ready(), true);
set.destroy();
assert.equal(set.ready(), false);
const plain = createMoverRegistry(() => ({ commit() {}, destroy() {} }));
assert.equal(
plain.movers
.createSet({ id: 'p', kind: 'point', capacity: 1, point: { sizePx: 4 } })
.ready(),
true,
'an engine part without ready() draws at once',
);
});
+2
View File
@@ -129,6 +129,7 @@ export function createNavaraMovers(view, { overlay }) {
}
overlay.add(mover.mesh, () => updateMover(view, overlay.ctx, mover));
return {
ready: mover.ready,
commit() {
mover.commit(store.dirty());
request();
@@ -158,6 +159,7 @@ export function createNavaraMovers(view, { overlay }) {
},
});
return {
ready: mover.ready,
commit() {
mover.commit(store.dirty());
request();
+1
View File
@@ -176,6 +176,7 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
return {
group,
uniforms,
ready: () => loaded,
origin: () => origin,
commit(range) {
if (!origin && store.count > 0)
+14 -3
View File
@@ -50,12 +50,15 @@ vec4 mvCenter = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
float gevScale = gevStyle.y;
float gevDistance = length(mvCenter.xyz);
if (uGevHasScaleByDistance > 0.5) {
// Cesium's czm_nearFarScalar: over the squared distance, eased by ^0.2.
float gevNearSq = uGevScaleByDistance.x * uGevScaleByDistance.x;
float gevFarSq = uGevScaleByDistance.z * uGevScaleByDistance.z;
float t = clamp(
(gevDistance - uGevScaleByDistance.x) /
max(uGevScaleByDistance.z - uGevScaleByDistance.x, 1e-6),
(gevDistance * gevDistance - gevNearSq) / max(gevFarSq - gevNearSq, 1e-6),
0.0,
1.0
);
t = pow(t, 0.2);
gevScale *= mix(uGevScaleByDistance.y, uGevScaleByDistance.w, t);
}
float gevSizePx = uGevSizePx * gevScale;
@@ -213,8 +216,15 @@ export function createMoverMesh(
tGevSceneDepth: overlayUniforms.tGevSceneDepth,
uGevResolution: overlayUniforms.uGevResolution,
};
// A canvas atlas is there at once; a URL one once its image loads.
let textureReady = point || Boolean(options.sprite.atlas.canvas);
const material = new MeshBasicMaterial({
map: point ? null : atlasTexture(options.sprite.atlas, onTextureLoad),
map: point
? null
: atlasTexture(options.sprite.atlas, () => {
textureReady = true;
onTextureLoad();
}),
transparent: true,
alphaTest: 0.02,
// Drawn after tone mapping, with no depth buffer: the shader tests the
@@ -260,6 +270,7 @@ export function createMoverMesh(
return {
mesh,
uniforms,
ready: () => textureReady,
origin: () => origin,
/** Writes the changed instances; the origin starts at the first one. */
commit(range) {
+1
View File
@@ -1210,6 +1210,7 @@ export function createContractSuite(
return count;
};
await until(() => modelPixels() > 150, 'the model drawn');
ensure(models.ready(), 'a drawn model set is not ready()');
} finally {
for (const set of sets) set.destroy();
}