From 4dfcba8989f9d20e3f50ef3727f025ecfa886007 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 17 Oct 2026 01:16:59 +0400 Subject: [PATCH] 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. --- src/globe/cesium/movers.js | 7 +++++++ src/globe/contract.js | 6 +++++- src/globe/cpuPicks.js | 20 ++++++++++++------ src/globe/cpuPicks.test.mjs | 26 +++++++++++++++++++++--- src/globe/movers.js | 8 +++++++- src/globe/movers.test.mjs | 29 +++++++++++++++++++++++++++ src/globe/navara/movers/index.js | 2 ++ src/globe/navara/movers/modelMover.js | 1 + src/globe/navara/movers/moverMesh.js | 17 +++++++++++++--- src/globe/testing/contractSuite.mjs | 1 + 10 files changed, 103 insertions(+), 14 deletions(-) diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js index eef7b9972..9d81c15c6 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.js @@ -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(); diff --git a/src/globe/contract.js b/src/globe/contract.js index 59f51ae7a..5f787b813 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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 diff --git a/src/globe/cpuPicks.js b/src/globe/cpuPicks.js index bc37dd434..fed3ced4f 100644 --- a/src/globe/cpuPicks.js +++ b/src/globe/cpuPicks.js @@ -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; } diff --git a/src/globe/cpuPicks.test.mjs b/src/globe/cpuPicks.test.mjs index a95030f4f..b1ef95b22 100644 --- a/src/globe/cpuPicks.test.mjs +++ b/src/globe/cpuPicks.test.mjs @@ -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`, + ); + } }); diff --git a/src/globe/movers.js b/src/globe/movers.js index 2fc65e009..711fedbe7 100644 --- a/src/globe/movers.js +++ b/src/globe/movers.js @@ -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; diff --git a/src/globe/movers.test.mjs b/src/globe/movers.test.mjs index aeb083ddf..38b1af9e8 100644 --- a/src/globe/movers.test.mjs +++ b/src/globe/movers.test.mjs @@ -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', + ); +}); diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index 63857d5bf..4eddd4dec 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -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(); diff --git a/src/globe/navara/movers/modelMover.js b/src/globe/navara/movers/modelMover.js index 510c0a724..e27c6b2f5 100644 --- a/src/globe/navara/movers/modelMover.js +++ b/src/globe/navara/movers/modelMover.js @@ -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) diff --git a/src/globe/navara/movers/moverMesh.js b/src/globe/navara/movers/moverMesh.js index fa647356d..1c7761592 100644 --- a/src/globe/navara/movers/moverMesh.js +++ b/src/globe/navara/movers/moverMesh.js @@ -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) { diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 851a4cc3d..d6b6761df 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -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(); }