diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7b68b1841..0c83359ac 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1067,5 +1067,12 @@ "src/tooling/mainChunkComposition.test.mjs", "scripts/qa-w1-baseline.mjs", "src/tooling/initialChunkGuard.test.mjs", - "src/tooling/w1Baseline.test.mjs" + "src/tooling/w1Baseline.test.mjs", + "src/globe/movers.test.mjs", + "src/globe/cesium/movers.test.mjs", + "src/globe/navara/movers/pack.test.mjs", + "src/globe/navara/movers/pickIds.test.mjs", + "src/globe/navara/movers/moverMesh.test.mjs", + "src/globe/navara/movers/modelMover.test.mjs", + "src/bench/syntheticFlights.test.mjs" ] diff --git a/src/bench/syntheticFlights.js b/src/bench/syntheticFlights.js new file mode 100644 index 000000000..4ea3f7a06 --- /dev/null +++ b/src/bench/syntheticFlights.js @@ -0,0 +1,110 @@ +// src/bench/syntheticFlights.js +const EARTH_RADIUS_M = 6_371_000; +const RADIANS = Math.PI / 180; + +/** Small deterministic PRNG so both engines draw the same fleet. */ +function mulberry32(seed) { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const wrapLongitude = (lon) => ((((lon + 180) % 360) + 360) % 360) - 180; + +function checkNear(near, count) { + if (!Number.isInteger(near.count) || near.count < 0 || near.count > count) + throw new RangeError('near.count must be an integer in [0, count]'); + if (!(near.radiusM >= 0) || !Number.isFinite(near.radiusM)) + throw new RangeError('near.radiusM must be a non-negative number'); + if (!Number.isFinite(near.lat) || !Number.isFinite(near.lng)) + throw new RangeError('near.lat and near.lng must be finite'); + const bands = nearBands(near); + if ( + !(bands.minAltM <= bands.maxAltM) || + !(bands.minSpeedMps <= bands.maxSpeedMps) + ) + throw new RangeError('near altitude and speed bands must be ordered'); +} + +const nearBands = ({ + minAltM = 300, + maxAltM = 3_000, + minSpeedMps = 10, + maxSpeedMps = 40, +}) => ({ minAltM, maxAltM, minSpeedMps, maxSpeedMps }); + +/** + * Deterministic synthetic aircraft for engine benchmarks. Each aircraft + * flies a great circle at constant speed on a spherical Earth. + * + * With `near: { lat, lng, radiusM, count }` the first `count` aircraft start + * uniformly over the disc of `radiusM` around that point, at `minAltM` to + * `maxAltM` (default 300–3000 m) and `minSpeedMps` to `maxSpeedMps` (default + * 10–40 m/s, slow enough to stay near the point for a bench run); the rest + * spread over the globe as without `near`. + */ +export function createSyntheticFlights({ count, seed = 1, near = null }) { + if (!Number.isInteger(count) || count < 0) + throw new RangeError('count must be a non-negative integer'); + if (near) checkNear(near, count); + const random = mulberry32(seed); + const lat = new Float64Array(count); + const lon = new Float64Array(count); + const alt = new Float64Array(count); + const heading = new Float64Array(count); + const speed = new Float64Array(count); + const nearCount = near ? near.count : 0; + const bands = near ? nearBands(near) : null; + for (let i = 0; i < nearCount; i++) { + // Uniform over the disc: r = R·sqrt(u), then the spherical destination. + const distance = (near.radiusM * Math.sqrt(random())) / EARTH_RADIUS_M; + const bearing = random() * 2 * Math.PI; + const phi1 = near.lat * RADIANS; + const sinPhi2 = + Math.sin(phi1) * Math.cos(distance) + + Math.cos(phi1) * Math.sin(distance) * Math.cos(bearing); + const phi2 = Math.asin(sinPhi2); + const dLambda = Math.atan2( + Math.sin(bearing) * Math.sin(distance) * Math.cos(phi1), + Math.cos(distance) - Math.sin(phi1) * sinPhi2, + ); + lat[i] = phi2 / RADIANS; + lon[i] = wrapLongitude(near.lng + dLambda / RADIANS); + alt[i] = bands.minAltM + random() * (bands.maxAltM - bands.minAltM); + heading[i] = random() * 360; + speed[i] = + bands.minSpeedMps + random() * (bands.maxSpeedMps - bands.minSpeedMps); + } + for (let i = nearCount; i < count; i++) { + lat[i] = Math.asin(2 * random() - 1) / RADIANS; + lon[i] = random() * 360 - 180; + alt[i] = 1_000 + random() * 11_000; + heading[i] = random() * 360; + speed[i] = 120 + random() * 130; + } + + function advance(dtSeconds) { + for (let i = 0; i < count; i++) { + const distance = (speed[i] * dtSeconds) / EARTH_RADIUS_M; + const phi1 = lat[i] * RADIANS; + const theta = heading[i] * RADIANS; + const sinPhi2 = + Math.sin(phi1) * Math.cos(distance) + + Math.cos(phi1) * Math.sin(distance) * Math.cos(theta); + const phi2 = Math.asin(sinPhi2); + const dLambda = Math.atan2( + Math.sin(theta) * Math.sin(distance) * Math.cos(phi1), + Math.cos(distance) - Math.sin(phi1) * sinPhi2, + ); + lat[i] = phi2 / RADIANS; + lon[i] = wrapLongitude(lon[i] + dLambda / RADIANS); + } + } + + return { count, lat, lon, alt, heading, speed, advance }; +} diff --git a/src/bench/syntheticFlights.test.mjs b/src/bench/syntheticFlights.test.mjs new file mode 100644 index 000000000..810e5d034 --- /dev/null +++ b/src/bench/syntheticFlights.test.mjs @@ -0,0 +1,198 @@ +// src/bench/syntheticFlights.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSyntheticFlights } from './syntheticFlights.js'; + +const EARTH_RADIUS_M = 6_371_000; +const toDegrees = (radians) => (radians * 180) / Math.PI; + +test('the same seed gives the same fleet, another seed a different one', () => { + const a = createSyntheticFlights({ count: 100, seed: 3 }); + const b = createSyntheticFlights({ count: 100, seed: 3 }); + const c = createSyntheticFlights({ count: 100, seed: 4 }); + assert.deepEqual(a.lat, b.lat); + assert.deepEqual(a.lon, b.lon); + assert.notDeepEqual(a.lat, c.lat); +}); + +test('positions, altitudes and speeds stay in plausible ranges', () => { + const fleet = createSyntheticFlights({ count: 1000, seed: 1 }); + for (let i = 0; i < fleet.count; i++) { + assert.ok(fleet.lat[i] >= -90 && fleet.lat[i] <= 90); + assert.ok(fleet.lon[i] >= -180 && fleet.lon[i] < 180); + assert.ok(fleet.alt[i] >= 1_000 && fleet.alt[i] <= 12_000); + assert.ok(fleet.speed[i] >= 120 && fleet.speed[i] <= 250); + assert.ok(fleet.heading[i] >= 0 && fleet.heading[i] < 360); + } +}); + +test('rejects a negative or fractional count', () => { + assert.throws(() => createSyntheticFlights({ count: -1 }), RangeError); + assert.throws(() => createSyntheticFlights({ count: 1.5 }), RangeError); +}); + +test('an eastbound aircraft on the equator moves east by speed × time', () => { + const fleet = createSyntheticFlights({ count: 1 }); + fleet.lat[0] = 0; + fleet.lon[0] = 0; + fleet.heading[0] = 90; + fleet.speed[0] = 100; + fleet.advance(10); + assert.ok(Math.abs(fleet.lat[0]) < 1e-12); + assert.ok(Math.abs(fleet.lon[0] - toDegrees(1_000 / EARTH_RADIUS_M)) < 1e-9); +}); + +test('a northbound aircraft on the equator moves north', () => { + const fleet = createSyntheticFlights({ count: 1 }); + fleet.lat[0] = 0; + fleet.lon[0] = 0; + fleet.heading[0] = 0; + fleet.speed[0] = 100; + fleet.advance(10); + assert.ok(Math.abs(fleet.lat[0] - toDegrees(1_000 / EARTH_RADIUS_M)) < 1e-9); + assert.ok(Math.abs(fleet.lon[0]) < 1e-12); +}); + +test('longitude wraps across the antimeridian', () => { + const fleet = createSyntheticFlights({ count: 1 }); + fleet.lat[0] = 0; + fleet.lon[0] = 179.995; + fleet.heading[0] = 90; + fleet.speed[0] = 250; + fleet.advance(10); + assert.ok(fleet.lon[0] < -179.9 && fleet.lon[0] >= -180); +}); + +const NEAR = Object.freeze({ + lat: 40.7069, + lng: -74.0128, + radiusM: 3_000, + count: 50, +}); + +/** Great-circle distance in metres on the fleet's spherical Earth. */ +function haversineM(lat1, lon1, lat2, lon2) { + const rad = Math.PI / 180; + const dLat = (lat2 - lat1) * rad; + const dLon = (lon2 - lon1) * rad; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLon / 2) ** 2; + return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a)); +} + +test('the first near.count aircraft start within radiusM of the near point', () => { + const fleet = createSyntheticFlights({ count: 1_000, seed: 7, near: NEAR }); + let farthest = 0; + for (let i = 0; i < NEAR.count; i++) { + const d = haversineM(NEAR.lat, NEAR.lng, fleet.lat[i], fleet.lon[i]); + farthest = Math.max(farthest, d); + assert.ok(d <= NEAR.radiusM + 1e-6, `aircraft ${i} is ${d} m away`); + assert.ok( + fleet.alt[i] >= 300 && fleet.alt[i] <= 3_000, + `alt ${fleet.alt[i]}`, + ); + assert.ok(fleet.heading[i] >= 0 && fleet.heading[i] < 360); + } + // Uniform over the disc, not bunched at the centre. + assert.ok(farthest > NEAR.radiusM * 0.8, `farthest ${farthest}`); +}); + +test('near aircraft are spread over the disc area', () => { + const near = { ...NEAR, count: 2_000 }; + const fleet = createSyntheticFlights({ count: 2_000, seed: 2, near }); + let inner = 0; + for (let i = 0; i < near.count; i++) { + const d = haversineM(near.lat, near.lng, fleet.lat[i], fleet.lon[i]); + if (d <= near.radiusM / Math.SQRT2) inner++; + } + // Half the area lies inside radius / sqrt(2). + assert.ok( + Math.abs(inner / near.count - 0.5) < 0.05, + `inner share ${inner / near.count}`, + ); +}); + +test('the rest of a fleet with near aircraft keeps the global ranges', () => { + const fleet = createSyntheticFlights({ count: 1_000, seed: 7, near: NEAR }); + let far = 0; + for (let i = NEAR.count; i < fleet.count; i++) { + assert.ok(fleet.alt[i] >= 1_000 && fleet.alt[i] <= 12_000); + assert.ok(fleet.speed[i] >= 120 && fleet.speed[i] <= 250); + if (haversineM(NEAR.lat, NEAR.lng, fleet.lat[i], fleet.lon[i]) > 100_000) + far++; + } + assert.ok(far > 900, `only ${far} aircraft spread over the globe`); +}); + +test('a fleet with near aircraft is deterministic for its seed', () => { + const a = createSyntheticFlights({ count: 200, seed: 5, near: NEAR }); + const b = createSyntheticFlights({ count: 200, seed: 5, near: NEAR }); + const c = createSyntheticFlights({ count: 200, seed: 6, near: NEAR }); + assert.deepEqual(a.lat, b.lat); + assert.deepEqual(a.lon, b.lon); + assert.deepEqual(a.alt, b.alt); + assert.notDeepEqual(a.lat, c.lat); +}); + +test('near aircraft fly slowly enough to stay in a city view during a run', () => { + const fleet = createSyntheticFlights({ count: 100, seed: 3, near: NEAR }); + for (let i = 0; i < NEAR.count; i++) + assert.ok( + fleet.speed[i] >= 10 && fleet.speed[i] <= 40, + `speed ${fleet.speed[i]}`, + ); +}); + +test('a near block can override its altitude and speed bands', () => { + const near = { + ...NEAR, + minAltM: 60, + maxAltM: 190, + minSpeedMps: 1, + maxSpeedMps: 5, + }; + const fleet = createSyntheticFlights({ count: 300, seed: 9, near }); + for (let i = 0; i < near.count; i++) { + assert.ok(fleet.alt[i] >= 60 && fleet.alt[i] <= 190, `alt ${fleet.alt[i]}`); + assert.ok( + fleet.speed[i] >= 1 && fleet.speed[i] <= 5, + `speed ${fleet.speed[i]}`, + ); + } + assert.throws( + () => + createSyntheticFlights({ + count: 10, + near: { ...NEAR, count: 1, minAltM: 5, maxAltM: 4 }, + }), + RangeError, + ); +}); + +test('rejects a near block larger than the fleet or with a bad radius', () => { + assert.throws( + () => createSyntheticFlights({ count: 10, near: { ...NEAR, count: 11 } }), + RangeError, + ); + assert.throws( + () => createSyntheticFlights({ count: 10, near: { ...NEAR, count: 1.5 } }), + RangeError, + ); + assert.throws( + () => + createSyntheticFlights({ + count: 10, + near: { ...NEAR, count: 5, radiusM: -1 }, + }), + RangeError, + ); +}); + +test('a near block of zero aircraft changes nothing but the stream position', () => { + const fleet = createSyntheticFlights({ + count: 0, + near: { ...NEAR, count: 0 }, + }); + assert.equal(fleet.count, 0); +}); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index 69a06e43d..f49ecae44 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -11,6 +11,7 @@ import { createCesiumCredits } from './credits.js'; import { createCesiumFrames } from './frames.js'; import { createCesiumInput } from './input.js'; import { createCesiumMaps } from './maps.js'; +import { createCesiumMovers } from './movers.js'; import { createCesiumPicker } from './picker.js'; import { createCesiumProjector } from './projector.js'; import { createCesiumRender } from './render.js'; @@ -45,6 +46,10 @@ export function createCesiumEngine(viewer, options = {}) { projector: createCesiumProjector(viewer), frames: createCesiumFrames(viewer), clock: createCesiumClock(viewer, options), + movers: createCesiumMovers(viewer, { + requestRender: (reason) => render.render.request(reason), + ...(options.moverFactories ? { factories: options.moverFactories } : {}), + }), render, surface: createCesiumSurface(viewer), input: createCesiumInput(viewer, options), diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js new file mode 100644 index 000000000..88e89fdfc --- /dev/null +++ b/src/globe/cesium/movers.js @@ -0,0 +1,319 @@ +// engine.movers on Cesium: one primitive per set inside one engine-owned +// PrimitiveCollection (added to the scene with the first set, in `order`): +// sprites are a BillboardCollection (screen-aligned, rotation in screen +// space, an image per atlas cell), points a PointPrimitiveCollection, +// models one Model per instance in a PrimitiveCollection. commit() writes +// the changed instances and requests a render; nothing else renders. + +import * as Cesium from 'cesium'; +import { createMoverRegistry } from '../movers.js'; + +const scratchPosition = new Cesium.Cartesian3(); +const scratchColor = new Cesium.Color(); +const scratchQuaternion = new Cesium.Quaternion(); +const scratchRotation = new Cesium.Matrix3(); + +/** The atlas cells as canvases, by key (a DOM canvas or image source). */ +async function cropAtlasCells(atlas, keys) { + let image = atlas.canvas; + if (!image) { + image = new Image(); + image.crossOrigin = 'anonymous'; + image.src = atlas.url; + await image.decode(); + } + const width = image.width; + const height = image.height; + const cells = new Map(); + for (const key of keys) { + const [u0, v0, u1, v1] = atlas.cells[key]; + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round((u1 - u0) * width)); + canvas.height = Math.max(1, Math.round((v1 - v0) * height)); + canvas + .getContext('2d') + .drawImage( + image, + u0 * width, + v0 * height, + canvas.width, + canvas.height, + 0, + 0, + canvas.width, + canvas.height, + ); + cells.set(key, canvas); + } + return cells; +} + +const DEFAULT_FACTORIES = { + billboards: (scene) => new Cesium.BillboardCollection({ scene }), + points: () => new Cesium.PointPrimitiveCollection(), + models: () => new Cesium.PrimitiveCollection({ destroyPrimitives: true }), + loadModel: (options) => Cesium.Model.fromGltfAsync(options), + cropCells: cropAtlasCells, +}; + +const depthTestDistance = (depthTest) => + depthTest === 'always' + ? Number.POSITIVE_INFINITY + : depthTest === 'normal' + ? 0 + : depthTest.disableWithinM; + +const nearFar = (value) => + value ? new Cesium.NearFarScalar(...value) : undefined; + +function readPosition(store, index) { + const { positions } = store; + return Cesium.Cartesian3.fromElements( + positions[index * 3], + positions[index * 3 + 1], + positions[index * 3 + 2], + scratchPosition, + ); +} + +function readColor(store, index) { + const { colors } = store; + scratchColor.red = colors[index * 4]; + scratchColor.green = colors[index * 4 + 1]; + scratchColor.blue = colors[index * 4 + 2]; + scratchColor.alpha = colors[index * 4 + 3]; + return scratchColor; +} + +function spritePart(scene, options, store, factories, requestRender) { + const collection = factories.billboards(scene); + const { sprite, depthTest, id } = options; + const style = { + width: sprite.sizePx, + height: sprite.sizePx, + scaleByDistance: nearFar(sprite.scaleByDistance), + disableDepthTestDistance: depthTestDistance(depthTest), + }; + const billboards = []; + const shownCell = []; + let cells = null; + let destroyed = false; + const apply = (from, to) => { + for (let index = from; index < to; index++) { + let billboard = billboards[index]; + if (index >= store.count) { + if (billboard) billboard.show = false; + continue; + } + if (!billboard) { + billboard = collection.add({ ...style }); + billboards[index] = billboard; + shownCell[index] = -1; + } + billboard.position = readPosition(store, index); + billboard.rotation = store.rotations[index]; + billboard.scale = store.scales[index]; + billboard.color = readColor(store, index); + billboard.show = store.shows[index] === 1; + billboard.id = store.pickIds[index]; + const cell = store.cells[index]; + if (shownCell[index] !== cell) { + const key = store.cellKeys[cell]; + billboard.setImage(`${id}:${key}`, cells.get(key)); + shownCell[index] = cell; + } + } + }; + // Instances wait for the atlas; then everything drawn so far is applied. + factories + .cropCells(sprite.atlas, store.cellKeys) + .then((ready) => { + if (destroyed) return; + cells = ready; + apply(0, Math.max(store.count, billboards.length)); + requestRender('movers'); + }) + .catch((error) => console.warn(`[movers] ${id}: atlas failed`, error)); + return { + primitive: collection, + commit(range) { + if (cells && range) apply(range.from, range.to); + requestRender('movers'); + }, + destroy() { + destroyed = true; + }, + }; +} + +function pointPart(options, store, factories, requestRender) { + const collection = factories.points(); + const { point, depthTest } = options; + const style = { + outlineWidth: point.outlinePx, + outlineColor: Cesium.Color.BLACK, + scaleByDistance: nearFar(point.scaleByDistance), + disableDepthTestDistance: depthTestDistance(depthTest), + }; + const points = []; + return { + primitive: collection, + commit(range) { + if (range) + for (let index = range.from; index < range.to; index++) { + let item = points[index]; + if (index >= store.count) { + if (item) item.show = false; + continue; + } + if (!item) { + item = collection.add({ ...style }); + points[index] = item; + } + item.position = readPosition(store, index); + item.pixelSize = point.sizePx * store.scales[index]; + item.color = readColor(store, index); + item.show = store.shows[index] === 1; + item.id = store.pickIds[index]; + } + requestRender('movers'); + }, + destroy() {}, + }; +} + +function modelPart(options, store, factories, requestRender) { + const collection = factories.models(); + const { model } = options; + const blendMode = + model.colorBlend?.mode === 'replace' + ? Cesium.ColorBlendMode.REPLACE + : Cesium.ColorBlendMode.MIX; + const models = []; + const loading = []; + let destroyed = false; + const place = (item, index) => { + const { orientations } = store; + scratchQuaternion.x = orientations[index * 4]; + scratchQuaternion.y = orientations[index * 4 + 1]; + scratchQuaternion.z = orientations[index * 4 + 2]; + scratchQuaternion.w = orientations[index * 4 + 3]; + item.modelMatrix = Cesium.Matrix4.fromRotationTranslation( + Cesium.Matrix3.fromQuaternion(scratchQuaternion, scratchRotation), + readPosition(store, index), + new Cesium.Matrix4(), + ); + item.scale = store.scales[index]; + item.show = index < store.count && store.shows[index] === 1; + item.id = store.pickIds[index]; + if (model.colorBlend) + item.color = Cesium.Color.clone(readColor(store, index)); + }; + const ensure = (index) => { + if (models[index] || loading[index]) return; + loading[index] = true; + factories + .loadModel({ + url: model.url, + minimumPixelSize: model.minimumPixelSize, + maximumScale: Number.isFinite(model.maximumScale) + ? model.maximumScale + : undefined, + colorBlendMode: blendMode, + colorBlendAmount: model.colorBlend?.amount ?? 0.5, + ...(model.unlit + ? { + customShader: new Cesium.CustomShader({ + lightingModel: Cesium.LightingModel.UNLIT, + }), + } + : {}), + }) + .then((item) => { + if (destroyed) { + item.destroy?.(); + return; + } + models[index] = item; + place(item, index); + collection.add(item); + requestRender('movers'); + }) + .catch((error) => + console.warn(`[movers] ${options.id}: model failed`, error), + ); + }; + return { + primitive: collection, + commit(range) { + if (range) + for (let index = range.from; index < range.to; index++) { + if (index < store.count) ensure(index); + if (models[index]) place(models[index], index); + } + requestRender('movers'); + }, + destroy() { + destroyed = true; + }, + }; +} + +/** + * @param {Cesium.Viewer} viewer + * @param {{ requestRender?: (reason: string) => void, factories?: object }} [options] + * `factories` (collections, model loading, atlas cropping) is a test seam. + * @returns {{ movers: import('../contract.js').GlobeMovers, destroy: () => void }} + */ +export function createCesiumMovers( + viewer, + { requestRender = () => viewer.scene.requestRender(), factories = {} } = {}, +) { + const make = { ...DEFAULT_FACTORIES, ...factories }; + const { scene } = viewer; + let root = null; + const ordered = []; + const rootCollection = () => { + if (!root) { + root = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); + scene.primitives.add(root); + } + return root; + }; + const registry = createMoverRegistry((options, store) => { + const part = + options.kind === 'sprite' + ? spritePart(scene, options, store, make, requestRender) + : options.kind === 'point' + ? pointPart(options, store, make, requestRender) + : modelPart(options, store, make, requestRender); + const { primitive } = part; + primitive.gevMoverSet = options.id; + // Later (higher) order draws later: after every set of lower or equal order. + const at = ordered.filter((entry) => entry.order <= options.order).length; + ordered.splice(at, 0, { order: options.order, primitive }); + rootCollection().add(primitive, at); + return { + commit: () => part.commit(store.dirty()), + destroy() { + part.destroy(); + ordered.splice( + ordered.findIndex((entry) => entry.primitive === primitive), + 1, + ); + root.remove(primitive); + requestRender('movers'); + }, + }; + }); + return { + movers: registry.movers, + destroy() { + registry.destroyAll(); + if (root) { + scene.primitives.remove(root); + root = null; + } + }, + }; +} diff --git a/src/globe/cesium/movers.test.mjs b/src/globe/cesium/movers.test.mjs new file mode 100644 index 000000000..a6ef03ec5 --- /dev/null +++ b/src/globe/cesium/movers.test.mjs @@ -0,0 +1,223 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createCesiumMovers } from './movers.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +// Collections that keep what the engine gives them (Cesium's own need a +// WebGL scene to add billboards). +function fakeFactories() { + const made = []; + const collection = (kind) => { + const items = []; + const value = { + kind, + items, + show: true, + add(options) { + // Cesium's billboards and points copy positions and colours. + let position; + let color; + const item = { + ...options, + get position() { + return position; + }, + set position(value) { + position = Cesium.Cartesian3.clone(value); + }, + get color() { + return color; + }, + set color(value) { + color = Cesium.Color.clone(value); + }, + images: [], + setImage(id, image) { + this.images.push(id); + this.image = image; + }, + }; + items.push(item); + return item; + }, + remove(item) { + items.splice(items.indexOf(item), 1); + return true; + }, + get length() { + return items.length; + }, + isDestroyed: () => false, + destroy() { + value.destroyed = true; + }, + }; + made.push(value); + return value; + }; + return { + made, + factories: { + billboards: () => collection('billboards'), + points: () => collection('points'), + models: () => collection('models'), + loadModel: async (options) => ({ ...options, loaded: true }), + cropCells: async (atlas, keys) => + new Map(keys.map((key) => [key, { cell: key }])), + }, + }; +} + +const atlas = { + canvas: { width: 64, height: 32 }, + cells: { jet: [0, 0, 0.5, 1], heli: [0.5, 0, 1, 1] }, +}; +const at = (x) => ({ x, y: 0, z: 6_400_000 }); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('creating the movers changes nothing; the first set adds one root collection', () => { + const viewer = createFakeViewer(); + const { factories } = fakeFactories(); + const { movers, destroy } = createCesiumMovers(viewer, { factories }); + assert.equal(viewer.scene.primitives.length, 0); + movers.createSet({ + id: 'a', + kind: 'point', + capacity: 2, + point: { sizePx: 4 }, + }); + assert.equal(viewer.scene.primitives.length, 1); + destroy(); + assert.equal(viewer.scene.primitives.length, 0); +}); + +test('sprites become billboards with the set style once the atlas cells are ready', async () => { + const viewer = createFakeViewer(); + const { factories, made } = fakeFactories(); + const requests = []; + const { movers } = createCesiumMovers(viewer, { + factories, + requestRender: (reason) => requests.push(reason), + }); + const set = movers.createSet({ + id: 'flights', + kind: 'sprite', + capacity: 3, + depthTest: 'always', + sprite: { atlas, sizePx: 20, scaleByDistance: [1e3, 1, 1e6, 0.5] }, + }); + set.set(0, { position: at(1), rotation: 0.25, cell: 'heli', pickId: 'x1' }); + set.set(1, { position: at(2), color: [1, 0, 0, 0.5], scale: 2, show: false }); + set.setCount(2); + set.commit(); + const billboards = made.find( + (collection) => collection.kind === 'billboards', + ); + assert.equal(billboards.items.length, 0, 'waits for the atlas'); + await settle(); + assert.equal(billboards.items.length, 2); + const [first, second] = billboards.items; + assert.ok(first.position instanceof Cesium.Cartesian3); + assert.equal(first.position.x, 1); + assert.equal(first.width, 20); + assert.equal(first.height, 20); + assert.equal(first.rotation, 0.25); + assert.equal(first.id, 'x1'); + assert.equal(first.disableDepthTestDistance, Number.POSITIVE_INFINITY); + assert.ok(first.scaleByDistance instanceof Cesium.NearFarScalar); + assert.deepEqual(first.images, ['flights:heli']); + assert.equal(first.show, true); + assert.equal(second.show, false); + assert.equal(second.scale, 2); + assert.ok(Cesium.Color.equals(second.color, new Cesium.Color(1, 0, 0, 0.5))); + assert.ok(requests.length >= 1); + // Lowering the count hides the instances past it. + set.setCount(1); + set.commit(); + assert.equal(second.show, false); + set.set(0, { cell: 'heli' }); + set.commit(); + assert.deepEqual( + first.images, + ['flights:heli'], + 'the same cell is not re-sent', + ); +}); + +test('points take the size, outline and depth test of the set', () => { + const viewer = createFakeViewer(); + const { factories, made } = fakeFactories(); + const { movers } = createCesiumMovers(viewer, { factories }); + const set = movers.createSet({ + id: 'sats', + kind: 'point', + capacity: 2, + depthTest: { disableWithinM: 5000 }, + point: { sizePx: 4, outlinePx: 1 }, + }); + set.set(0, { position: at(3), scale: 1.5, color: [0, 1, 0, 1] }); + set.setCount(1); + set.commit(); + const [point] = made.find((c) => c.kind === 'points').items; + assert.equal(point.pixelSize, 6); + assert.equal(point.outlineWidth, 1); + assert.equal(point.disableDepthTestDistance, 5000); + assert.ok(Cesium.Color.equals(point.color, new Cesium.Color(0, 1, 0, 1))); +}); + +test('sets are ordered in the root by their order', () => { + const viewer = createFakeViewer(); + const { factories } = fakeFactories(); + const { movers } = createCesiumMovers(viewer, { factories }); + const make = (id, order) => + movers.createSet({ + id, + kind: 'point', + capacity: 1, + order, + point: { sizePx: 2 }, + }); + make('top', 5); + make('bottom', -1); + make('middle', 0); + const root = viewer.scene.primitives.get(0); + assert.deepEqual( + [0, 1, 2].map((index) => root.get(index).gevMoverSet), + ['bottom', 'middle', 'top'], + ); +}); + +test('models load per instance and follow position and orientation', async () => { + const viewer = createFakeViewer(); + const { factories, made } = fakeFactories(); + const { movers } = createCesiumMovers(viewer, { factories }); + const set = movers.createSet({ + id: 'models', + kind: 'model', + capacity: 2, + model: { + url: '/models/airplane.glb', + minimumPixelSize: 24, + colorBlend: { mode: 'mix', amount: 0.4 }, + }, + }); + set.set(0, { + position: at(4), + orientation: { x: 0, y: 0, z: 0, w: 1 }, + color: [1, 0.5, 0, 1], + }); + set.setCount(1); + set.commit(); + await settle(); + const models = made.find((c) => c.kind === 'models'); + assert.equal(models.items.length, 1); + const [model] = models.items; + assert.equal(model.url, '/models/airplane.glb'); + assert.equal(model.minimumPixelSize, 24); + assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX); + assert.equal(model.colorBlendAmount, 0.4); + assert.equal(model.modelMatrix[12], 4); + set.destroy(); + assert.equal(models.destroyed, true); +}); diff --git a/src/globe/contract.js b/src/globe/contract.js index 69926e2d0..83130c8be 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -243,6 +243,59 @@ * not); Navara on the view's preUpdate, which it emits on every turn of * its loop. * + * @typedef {object} MoverSetOptions + * A set of moving objects drawn as one batch (src/globe/movers.js checks + * and defaults these): + * @property {string} id Unique among the engine's live sets. + * @property {'sprite'|'point'|'model'} kind + * @property {number} capacity The most instances the set holds. + * @property {number} [order] Draw order among sets (higher draws later, on + * top); 0 by default. + * @property {'normal'|'always'|{ disableWithinM: number }} [depthTest] + * 'always' draws over the scene; { disableWithinM } skips the depth test for + * instances closer to the camera than that (Cesium's + * disableDepthTestDistance). 'normal' by default. + * @property {{ atlas: { url?: string, canvas?: object, cells: Object }, + * 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. + * @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, + * colorBlend?: { mode: 'mix'|'replace', amount: number }, unlit?: boolean }} [model] + * A glTF drawn per instance in metres, at least minimumPixelSize px. + * @property {boolean} [pickable] + * + * @typedef {object} MoverFields + * One instance; omitted fields keep their value (defaults: white, scale 1, + * rotation 0, the first atlas cell, shown, identity orientation). + * @property {Cartesian3} [position] + * @property {number} [rotation] Sprites: screen angle in radians, + * counter-clockwise from screen up (Cesium's Billboard.rotation). + * @property {{ x: number, y: number, z: number, w: number }} [orientation] + * Models: the model frame's rotation into Earth-fixed axes. + * @property {number[]} [color] rgba in 0..1; multiplies the sprite's texels. + * @property {number} [scale] + * @property {string} [cell] An atlas cell key. + * @property {boolean} [show] + * @property {string|number} [pickId] What a pick of this instance returns. + * + * @typedef {object} MoverSet + * @property {(index: number, fields: MoverFields) => void} set Stored only; + * drawn after commit(). + * @property {(count: number) => void} setCount Instances [0, count) draw. + * @property {() => number} count + * @property {() => void} commit Uploads what changed (one buffer write) and + * 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 {() => void} destroy + * + * @typedef {object} GlobeMovers + * @property {(options: MoverSetOptions) => MoverSet} createSet + * @property {() => { id: string, kind: string, count: number, capacity: number }[]} list + * * @typedef {object} GlobeEngine * @property {'cesium'|'navara'} kind * @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host @@ -263,6 +316,7 @@ * @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits * @property {GlobeMaps} maps * @property {GlobeClock} clock + * @property {GlobeMovers} movers * @property {() => void} destroy * @property {() => boolean} isDestroyed True once destroy() has run. */ @@ -442,6 +496,7 @@ const SHAPE = { input: { on: isFunction, setCameraInputs: isFunction }, credits: { addStatic: isFunction }, clock: { now: isFunction, frameTime: isFunction, ticked: isEngineEvent }, + movers: { createSet: isFunction, list: isFunction }, maps: { setBaseImagery: isFunction, setTerrain: isFunction, diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index 190412acc..20ca77778 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -332,3 +332,14 @@ test('the contract carries the app clock', () => { delete missing.clock; assert.throws(() => assertGlobeEngine(missing), /\bclock\b/); }); + +test('the contract carries instanced movers', () => { + for (const member of ['movers.createSet', 'movers.list']) + assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); + const engine = createFakeEngine(); + const broken = { ...engine, movers: { createSet: 1 } }; + assert.throws( + () => assertGlobeEngine(broken), + /movers\.createSet.*movers\.list/, + ); +}); diff --git a/src/globe/movers.js b/src/globe/movers.js new file mode 100644 index 000000000..5cbc9b45e --- /dev/null +++ b/src/globe/movers.js @@ -0,0 +1,347 @@ +// engine.movers, the engine-neutral part: the options of a set of moving +// objects (sprites, points, models) and the flat per-instance store every +// engine draws from. Layers write instances with set(); an engine uploads +// what changed on commit(). Positions are src/geo Cartesian3 values (ECEF +// metres), colours rgba in 0..1, rotations radians. No engine imports. + +export const MOVER_KINDS = Object.freeze(['sprite', 'point', 'model']); +export const MOVER_DEPTH_TESTS = Object.freeze(['normal', 'always']); + +const isFiniteNumber = (value) => + typeof value === 'number' && Number.isFinite(value); +const isObject = (value) => value != null && typeof value === 'object'; + +function fail(message) { + throw new TypeError(`Mover set: ${message}`); +} + +function positive(value, label) { + if (!(isFiniteNumber(value) && value > 0)) + fail(`${label} must be a positive number: ${value}`); + return value; +} + +function nonNegative(value, label, fallback) { + if (value === undefined) return fallback; + if (!(isFiniteNumber(value) && value >= 0)) + fail(`${label} must be a number >= 0: ${value}`); + return value; +} + +function depthTestOf(value = 'normal') { + if (MOVER_DEPTH_TESTS.includes(value)) return value; + if (isObject(value) && isFiniteNumber(value.disableWithinM)) + return { + disableWithinM: nonNegative(value.disableWithinM, 'disableWithinM'), + }; + return fail(`depthTest must be 'normal', 'always' or { disableWithinM }`); +} + +function scaleByDistanceOf(value) { + if (value == null) return null; + if ( + !Array.isArray(value) || + value.length !== 4 || + !value.every(isFiniteNumber) + ) + fail('scaleByDistance must be [nearM, nearScale, farM, farScale]'); + return Object.freeze([...value]); +} + +function atlasOf(atlas) { + if (!isObject(atlas) || !(atlas.url || atlas.canvas)) + fail('sprite.atlas needs a url or a canvas'); + if (!isObject(atlas.cells) || !Object.keys(atlas.cells).length) + fail('sprite.atlas.cells must name at least one cell'); + for (const [key, rect] of Object.entries(atlas.cells)) + if ( + !Array.isArray(rect) || + rect.length !== 4 || + !rect.every((value) => isFiniteNumber(value) && value >= 0 && value <= 1) + ) + fail(`atlas cell ${key} must be [u0, v0, u1, v1] in 0..1`); + return atlas; +} + +/** + * The createSet options with defaults filled in, or a TypeError. + * @returns {{ id: string, kind: string, capacity: number, order: number, + * depthTest: 'normal'|'always'|{ disableWithinM: number }, pickable: boolean, + * cellKeys: string[], sprite: object | null, point: object | null, + * model: object | null }} + */ +export function normalizeMoverSetOptions(options) { + if (!isObject(options)) fail('options must be an object'); + const { id, kind, capacity } = options; + if (typeof id !== 'string' || !id) fail('id must be a non-empty string'); + if (!MOVER_KINDS.includes(kind)) + fail(`kind must be one of ${MOVER_KINDS.join(', ')}: ${kind}`); + if (!(Number.isInteger(capacity) && capacity > 0)) + fail(`capacity must be a positive integer: ${capacity}`); + const order = options.order ?? 0; + if (!isFiniteNumber(order)) fail(`order must be a number: ${order}`); + const normalized = { + id, + kind, + capacity, + order, + depthTest: depthTestOf(options.depthTest), + pickable: options.pickable === true, + cellKeys: [], + sprite: null, + point: null, + model: null, + }; + if (kind === 'sprite') { + const sprite = options.sprite; + if (!isObject(sprite)) fail('a sprite set needs sprite options'); + normalized.sprite = { + atlas: atlasOf(sprite.atlas), + sizePx: positive(sprite.sizePx, 'sprite.sizePx'), + scaleByDistance: scaleByDistanceOf(sprite.scaleByDistance), + }; + normalized.cellKeys = Object.keys(sprite.atlas.cells); + } else if (kind === 'point') { + const point = options.point; + if (!isObject(point)) fail('a point set needs point options'); + normalized.point = { + sizePx: positive(point.sizePx, 'point.sizePx'), + outlinePx: nonNegative(point.outlinePx, 'point.outlinePx', 0), + scaleByDistance: scaleByDistanceOf(point.scaleByDistance), + }; + } else { + const model = options.model; + if (!isObject(model) || typeof model.url !== 'string' || !model.url) + fail('a model set needs model.url'); + const blend = model.colorBlend ?? null; + if ( + blend !== null && + !( + isObject(blend) && + ['mix', 'replace'].includes(blend.mode) && + isFiniteNumber(blend.amount ?? 0.5) + ) + ) + fail("model.colorBlend must be { mode: 'mix'|'replace', amount }"); + normalized.model = { + url: model.url, + minimumPixelSize: nonNegative( + model.minimumPixelSize, + 'model.minimumPixelSize', + 0, + ), + maximumScale: + model.maximumScale === undefined + ? Infinity + : positive(model.maximumScale, 'model.maximumScale'), + colorBlend: blend + ? { mode: blend.mode, amount: blend.amount ?? 0.5 } + : null, + unlit: model.unlit === true, + }; + } + return normalized; +} + +/** + * The per-instance store of one set: flat typed arrays the engines read, + * and the range of indices changed since the last clean(). + */ +export function createMoverStore(options) { + const { capacity, cellKeys, kind } = options; + const cellIndex = new Map(cellKeys.map((key, index) => [key, index])); + const positions = new Float64Array(capacity * 3); + const orientations = new Float64Array(capacity * 4); + const colors = new Float32Array(capacity * 4).fill(1); + const rotations = new Float32Array(capacity); + const scales = new Float32Array(capacity).fill(1); + const cells = new Uint16Array(capacity); + const shows = new Uint8Array(capacity).fill(1); + const pickIds = new Array(capacity); + for (let index = 0; index < capacity; index++) + orientations[index * 4 + 3] = 1; + let count = 0; + let dirtyFrom = Infinity; + let dirtyTo = -Infinity; + const touch = (from, to) => { + if (from < dirtyFrom) dirtyFrom = from; + if (to > dirtyTo) dirtyTo = to; + }; + + // Validates before writing anything, so a refused set() changes nothing. + function check(index, fields) { + if (!(Number.isInteger(index) && index >= 0 && index < capacity)) + throw new TypeError(`Mover index ${index} is outside [0, ${capacity})`); + if (!isObject(fields)) + throw new TypeError('Mover fields must be an object'); + const { position, orientation, color, rotation, scale, cell } = fields; + if ( + position !== undefined && + !( + isObject(position) && + isFiniteNumber(position.x) && + isFiniteNumber(position.y) && + isFiniteNumber(position.z) + ) + ) + throw new TypeError('Mover position must be a finite Cartesian3'); + if ( + orientation !== undefined && + !( + isObject(orientation) && + ['x', 'y', 'z', 'w'].every((axis) => isFiniteNumber(orientation[axis])) + ) + ) + throw new TypeError('Mover orientation must be a quaternion'); + if ( + color !== undefined && + !( + (Array.isArray(color) || ArrayBuffer.isView(color)) && + color.length === 4 && + Array.prototype.every.call(color, isFiniteNumber) + ) + ) + throw new TypeError('Mover color must be [r, g, b, a]'); + if (rotation !== undefined && !isFiniteNumber(rotation)) + throw new TypeError('Mover rotation must be a number'); + if (scale !== undefined && !(isFiniteNumber(scale) && scale >= 0)) + throw new TypeError('Mover scale must be a number >= 0'); + if (cell !== undefined && !cellIndex.has(cell)) + throw new TypeError(`Mover cell ${cell} is not in the ${kind} atlas`); + } + + return { + capacity, + positions, + orientations, + colors, + rotations, + scales, + cells, + shows, + pickIds, + cellKeys, + get count() { + return count; + }, + set(index, fields) { + check(index, fields); + const { position, orientation, color, rotation, scale, cell, show } = + fields; + if (position) { + positions[index * 3] = position.x; + positions[index * 3 + 1] = position.y; + positions[index * 3 + 2] = position.z; + } + if (orientation) { + orientations[index * 4] = orientation.x; + orientations[index * 4 + 1] = orientation.y; + orientations[index * 4 + 2] = orientation.z; + orientations[index * 4 + 3] = orientation.w; + } + if (color) + for (let channel = 0; channel < 4; channel++) + colors[index * 4 + channel] = color[channel]; + if (rotation !== undefined) rotations[index] = rotation; + if (scale !== undefined) scales[index] = scale; + if (cell !== undefined) cells[index] = cellIndex.get(cell); + if (show !== undefined) shows[index] = show ? 1 : 0; + if ('pickId' in fields) pickIds[index] = fields.pickId; + touch(index, index + 1); + }, + setCount(next) { + if (!(Number.isInteger(next) && next >= 0 && next <= capacity)) + throw new RangeError(`Mover count ${next} is outside [0, ${capacity}]`); + if (next !== count) touch(Math.min(count, next), Math.max(count, next)); + count = next; + }, + positionsView: () => positions.subarray(0, count * 3), + /** Indices changed since clean(), as [from, to), or null. */ + dirty: () => + dirtyFrom === Infinity ? null : { from: dirtyFrom, to: dirtyTo }, + clean() { + dirtyFrom = Infinity; + dirtyTo = -Infinity; + }, + }; +} + +/** + * The MoverSet a layer holds, over a store and the engine's own commit and + * destroy. commit() hands the engine the changed range and cleans the store; + * a destroyed set refuses every call but destroy(). + */ +export function createMoverSet(store, { commit, destroy }) { + let destroyed = false; + const alive = () => { + if (destroyed) throw new Error('Mover set was destroyed'); + }; + return { + set(index, fields) { + alive(); + store.set(index, fields); + }, + setCount(count) { + alive(); + store.setCount(count); + }, + count: () => store.count, + commit() { + alive(); + commit(); + store.clean(); + }, + positionsView: () => store.positionsView(), + destroy() { + if (destroyed) return; + destroyed = true; + destroy(); + }, + isDestroyed: () => destroyed, + }; +} + +/** + * engine.movers over an engine's own drawing: `build(options, store)` makes + * the engine part of one set ({ commit, destroy }, both called with the + * set's store already holding the instances). Set ids are unique while the + * set lives. destroyAll() is for the engine's destroy. + * @returns {{ movers: { createSet: Function, list: Function }, + * sets: Map, destroyAll: () => void }} + */ +export function createMoverRegistry(build) { + const sets = new Map(); + const movers = { + createSet(input) { + const options = normalizeMoverSetOptions(input); + if (sets.has(options.id)) + throw new Error(`Mover set ${options.id} already exists`); + const store = createMoverStore(options); + const part = build(options, store); + const entry = { options, store, part, set: null }; + entry.set = createMoverSet(store, { + commit: () => part.commit(), + destroy: () => { + sets.delete(options.id); + part.destroy(); + }, + }); + sets.set(options.id, entry); + return entry.set; + }, + list: () => + [...sets.values()].map(({ options, store }) => ({ + id: options.id, + kind: options.kind, + count: store.count, + capacity: store.capacity, + })), + }; + return { + movers, + sets, + destroyAll() { + for (const entry of [...sets.values()]) entry.set.destroy(); + }, + }; +} diff --git a/src/globe/movers.test.mjs b/src/globe/movers.test.mjs new file mode 100644 index 000000000..0f1f76f3a --- /dev/null +++ b/src/globe/movers.test.mjs @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MOVER_KINDS, + createMoverSet, + createMoverStore, + normalizeMoverSetOptions, +} from './movers.js'; + +const atlas = { + canvas: { width: 64, height: 32 }, + cells: { jet: [0, 0, 0.5, 1], heli: [0.5, 0, 1, 1] }, +}; + +test('set options are normalised with the contract defaults', () => { + assert.deepEqual(MOVER_KINDS, ['sprite', 'point', 'model']); + const sprite = normalizeMoverSetOptions({ + id: 'flights', + kind: 'sprite', + capacity: 10, + sprite: { atlas, sizePx: 24 }, + }); + assert.equal(sprite.id, 'flights'); + assert.equal(sprite.order, 0); + assert.equal(sprite.depthTest, 'normal'); + assert.equal(sprite.pickable, false); + assert.deepEqual(sprite.cellKeys, ['jet', 'heli']); + assert.equal(sprite.sprite.scaleByDistance, null); + const point = normalizeMoverSetOptions({ + id: 'sats', + kind: 'point', + capacity: 3, + depthTest: { disableWithinM: 5000 }, + point: { sizePx: 4 }, + }); + assert.equal(point.point.outlinePx, 0); + assert.deepEqual(point.depthTest, { disableWithinM: 5000 }); + const model = normalizeMoverSetOptions({ + id: 'models', + kind: 'model', + capacity: 2, + model: { url: '/models/airplane.glb' }, + }); + assert.equal(model.model.minimumPixelSize, 0); + assert.equal(model.model.maximumScale, Infinity); + assert.equal(model.model.colorBlend, null); +}); + +test('bad set options are refused with a TypeError', () => { + for (const options of [ + {}, + { id: 'a', kind: 'cube', capacity: 1 }, + { id: 'a', kind: 'sprite', capacity: 0, sprite: { atlas, sizePx: 2 } }, + { id: 'a', kind: 'sprite', capacity: 1.5, sprite: { atlas, sizePx: 2 } }, + { id: 'a', kind: 'sprite', capacity: 1 }, + { id: 'a', kind: 'sprite', capacity: 1, sprite: { atlas, sizePx: -1 } }, + { id: 'a', kind: 'point', capacity: 1, point: {} }, + { id: 'a', kind: 'model', capacity: 1, model: {} }, + { + id: 'a', + kind: 'point', + capacity: 1, + point: { sizePx: 2 }, + depthTest: 'sometimes', + }, + { + id: 'a', + kind: 'sprite', + capacity: 1, + sprite: { atlas, sizePx: 2, scaleByDistance: [1, 2] }, + }, + ]) + assert.throws(() => normalizeMoverSetOptions(options), TypeError); +}); + +test('the store keeps each instance in flat arrays with defaults', () => { + const store = createMoverStore( + normalizeMoverSetOptions({ + id: 'f', + kind: 'sprite', + capacity: 3, + sprite: { atlas, sizePx: 16 }, + }), + ); + assert.equal(store.capacity, 3); + assert.equal(store.count, 0); + store.set(1, { + position: { x: 1, y: 2, z: 3 }, + rotation: 0.5, + color: [1, 0, 0, 0.5], + scale: 2, + cell: 'heli', + show: false, + pickId: 'abc', + }); + store.set(0, { position: { x: 4, y: 5, z: 6 } }); + assert.deepEqual([...store.positions.subarray(0, 6)], [4, 5, 6, 1, 2, 3]); + assert.deepEqual( + [...store.colors.subarray(0, 8)], + [1, 1, 1, 1, 1, 0, 0, 0.5], + ); + assert.equal(store.rotations[1], 0.5); + assert.equal(store.scales[0], 1); + assert.equal(store.scales[1], 2); + assert.equal(store.cells[0], 0); + assert.equal(store.cells[1], 1); + assert.equal(store.shows[0], 1); + assert.equal(store.shows[1], 0); + assert.equal(store.pickIds[1], 'abc'); + assert.equal(store.pickIds[0], undefined); + // set() without a field keeps the stored value. + store.set(1, { position: { x: 7, y: 8, z: 9 } }); + assert.equal(store.scales[1], 2); + assert.equal(store.shows[1], 0); + store.setCount(2); + assert.equal(store.count, 2); + assert.deepEqual([...store.positionsView()], [4, 5, 6, 7, 8, 9]); + assert.deepEqual(store.dirty(), { from: 0, to: 2 }); + store.clean(); + assert.equal(store.dirty(), null); + store.set(2, { position: { x: 0, y: 0, z: 1 } }); + assert.deepEqual(store.dirty(), { from: 2, to: 3 }); +}); + +test('bad instances are refused and leave the store unchanged', () => { + const store = createMoverStore( + normalizeMoverSetOptions({ + id: 'f', + kind: 'point', + capacity: 2, + point: { sizePx: 4 }, + }), + ); + for (const [index, fields] of [ + [2, { position: { x: 0, y: 0, z: 0 } }], + [-1, { position: { x: 0, y: 0, z: 0 } }], + [0, { position: { x: Number.NaN, y: 0, z: 0 } }], + [0, { color: [1, 1, 1] }], + [0, { cell: 'jet' }], + [0, { scale: -1 }], + ]) + assert.throws(() => store.set(index, fields), TypeError); + assert.throws(() => store.setCount(3), RangeError); + assert.equal(store.dirty(), null); +}); + +test('models take an orientation quaternion', () => { + const store = createMoverStore( + normalizeMoverSetOptions({ + id: 'm', + kind: 'model', + capacity: 1, + model: { url: '/m.glb' }, + }), + ); + assert.deepEqual([...store.orientations], [0, 0, 0, 1]); + store.set(0, { orientation: { x: 0, y: 0, z: 1, w: 0 } }); + assert.deepEqual([...store.orientations], [0, 0, 1, 0]); + assert.throws(() => store.set(0, { orientation: { x: 0, y: 0 } }), TypeError); +}); + +test('the set facade forwards to the store and an engine commit, once destroyed it refuses', () => { + const store = createMoverStore( + normalizeMoverSetOptions({ + id: 'f', + kind: 'point', + capacity: 2, + point: { sizePx: 4 }, + }), + ); + const calls = []; + const set = createMoverSet(store, { + commit: () => calls.push(['commit', store.dirty()]), + destroy: () => calls.push(['destroy']), + }); + set.set(0, { position: { x: 1, y: 2, z: 3 } }); + set.setCount(1); + assert.equal(set.count(), 1); + assert.deepEqual([...set.positionsView()], [1, 2, 3]); + set.commit(); + assert.deepEqual(calls, [['commit', { from: 0, to: 1 }]]); + assert.equal(store.dirty(), null, 'commit cleans the store'); + set.destroy(); + set.destroy(); + assert.deepEqual(calls.at(-1), ['destroy']); + assert.equal(calls.length, 2); + assert.throws(() => set.set(0, {}), /destroyed/); + assert.throws(() => set.commit(), /destroyed/); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index ecaf70d5e..387f84aa8 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -23,6 +23,14 @@ import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js'; const { cartesian3, math } = geo; +// engine.movers without the view's three runtime (Node tests of the engine). +const NO_MOVERS = Object.freeze({ + createSet() { + throw new Error("Navara movers need the view's three runtime (movers/)"); + }, + list: () => [], +}); + // Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in // Earth-fixed metres): matrixWorld columns are right, up, back, position. function readCamera(raw) { @@ -75,6 +83,8 @@ function rayRaisedEllipsoid({ origin, direction }, height) { * canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without * it the engine only keeps the state) and the engine destroys it; * `appClock` (the engine clock's time) defaults to the page's app clock; + * `movers` is engine.movers on the view's three runtime (movers/index.js; + * without it createSet throws) and the engine destroys it; * `fetch` and `clock` (the camera model's timers) are test seams. * @returns {import('../contract.js').GlobeEngine} */ @@ -86,6 +96,7 @@ export function createNavaraEngine( fetch, clock, appClock, + movers: moversPort = null, sharpen: sharpenPort = null, } = {}, ) { @@ -234,6 +245,7 @@ export function createNavaraEngine( credits: credits.credits, maps: maps.maps, clock: engineClock.clock, + movers: moversPort?.movers ?? NO_MOVERS, destroy() { if (destroyed) return; destroyed = true; @@ -244,6 +256,7 @@ export function createNavaraEngine( credits.destroy(); maps.destroy(); sharpenPort?.destroy(); + moversPort?.destroy(); }, isDestroyed: () => destroyed, // Navara only, not contract: the ground the floor last sampled under diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index cac80ced0..0e78dea95 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -9,6 +9,7 @@ import { createNavaraEngine } from './createNavaraEngine.js'; import { forEachLuminanceUniform, navaraHooks } from './internals.js'; import { buildNavaraLook } from './look.js'; import { createNavaraSharpen } from './sharpen.js'; +import { createNavaraMovers } from './movers/index.js'; import { NAVARA_CONTEXT_ATTRIBUTES, navaraScenePlan, @@ -115,6 +116,7 @@ export async function createNavaraGlobe({ container, ...(appClock ? { appClock } : {}), sharpen: createNavaraSharpen(view), + movers: createNavaraMovers(view), }); const camera = engine.camera; let ground = Number.NaN; diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js new file mode 100644 index 000000000..54bc871c3 --- /dev/null +++ b/src/globe/navara/movers/index.js @@ -0,0 +1,143 @@ +// engine.movers on Navara: each set is one Navara mesh descriptor (public +// MeshDesc API) holding its instanced meshes (moverMesh.js, modelMover.js). +// Pickable sets register a PickableInstancedMeshWrapper so Navara's pick +// pass sees each instance; pickIds.js turns its batch ids back into +// instances. commit() uploads and asks for one frame (view.forceUpdate): a +// descriptor's update() runs only on frames the view draws anyway (S5), so +// an idle view stays idle. + +import { + MeshDesc, + PickableInstancedMeshWrapper, + createGltfLoader, +} from '@navaramap/three'; +import { Group } from 'three'; +import { createMoverRegistry } from '../../movers.js'; +import { createModelMover } from './modelMover.js'; +import { createMoverMesh } from './moverMesh.js'; +import { createPickIndex } from './pickIds.js'; + +const DESC_NAME = 'gevMoverSet'; +const registered = new WeakSet(); +const EARTH_RADIUS_M = 6_371_000; + +class GevMoverSetDesc extends MeshDesc { + constructor(view, ctx, config) { + super(view, ctx, config); + this.gev = config[DESC_NAME]; + this.wrappers = []; + } + + // Sprites and points are screen overlays: the transparent pass, unlit. + getPassKey() { + return this.gev?.kind === 'model' ? super.getPassKey() : 'transparent'; + } + + createMesh() { + const { mover, pickable, onPickable, kind } = this.gev; + const register = (mesh, key) => { + if (!pickable) return; + const wrapper = new PickableInstancedMeshWrapper( + mesh, + mesh.instanceMatrix.count, + this.ctx, + ); + this.ctx.registerPickableMesh(key, wrapper); + this.wrappers.push(key); + onPickable(wrapper); + }; + if (kind !== 'model') { + register(mover.mesh, this.id); + return mover.mesh; + } + const group = new Group(); + group.frustumCulled = false; + // Meshes arrive with the glTF; they join the group (and the pick pass). + mover.group.ready.then(() => { + if (this.destroyedByGev) return; + mover.group.meshes.forEach((mesh, index) => { + group.add(mesh); + register(mesh, `${this.id}#${index}`); + }); + this.view.forceUpdate(); + }); + return group; + } + + update() { + const raw = this.view.camera.raw; + const height = this.ctx.getRenderer().domElement.clientHeight; + if (!raw || !(height > 0)) return; + raw.updateMatrixWorld(); + const e = raw.matrixWorld.elements; + const cameraPosition = { x: e[12], y: e[13], z: e[14] }; + const cameraHeight = + Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z) - + EARTH_RADIUS_M; + this.gev.mover.update({ + cameraPosition, + cameraHeight, + fovDeg: raw.fov, + viewportHeightPx: height, + }); + } + + onDestroy() { + this.destroyedByGev = true; + for (const key of this.wrappers) this.ctx.unregisterPickableMesh(key); + this.gev.mover.dispose(); + super.onDestroy(); + } +} + +/** + * @param {object} view an initialised Navara ThreeView + * @returns {{ movers: import('../../contract.js').GlobeMovers, + * resolvePick: (batchId: number) => ({ set: string, instance: number, pickId: any } | null), + * destroy: () => void }} + */ +export function createNavaraMovers(view) { + if (!registered.has(view)) { + view.registerMesh(DESC_NAME, GevMoverSetDesc); + registered.add(view); + } + const pickIndex = createPickIndex(); + let loader = null; + const loadGltf = (url) => { + loader ??= createGltfLoader(); + return loader.loadAsync(url); + }; + const request = () => view.forceUpdate(); + const registry = createMoverRegistry((options, store) => { + const mover = + options.kind === 'model' + ? createModelMover(options, store, { load: loadGltf, onLoad: request }) + : createMoverMesh(options, store, { onTextureLoad: request }); + const handle = view.addMesh({ + ...(options.kind === 'model' ? {} : { lit: false }), + [DESC_NAME]: { + mover, + kind: options.kind, + pickable: options.pickable, + onPickable: (wrapper) => + pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]), + }, + }); + return { + commit() { + mover.commit(store.dirty()); + request(); + }, + destroy() { + pickIndex.remove(options.id); + handle.delete(); + request(); + }, + }; + }); + return { + movers: registry.movers, + resolvePick: (batchId) => pickIndex.resolve(batchId), + destroy: () => registry.destroyAll(), + }; +} diff --git a/src/globe/navara/movers/modelMover.js b/src/globe/navara/movers/modelMover.js new file mode 100644 index 000000000..510c0a724 --- /dev/null +++ b/src/globe/navara/movers/modelMover.js @@ -0,0 +1,209 @@ +// The three side of a Navara model set: the glTF loaded once, one +// InstancedMesh per mesh in it (all instances share the model's meshes), +// each instance placed by position, orientation and scale relative to an +// origin near the camera. Cesium's minimumPixelSize/maximumScale are kept on +// the CPU once per drawn frame (models are few: tracked and proximity +// aircraft), and the instance colour is mixed into the surface colour +// (colorBlend), or the model drawn unlit. + +import { + Box3, + DynamicDrawUsage, + InstancedBufferAttribute, + InstancedMesh, + Matrix4, + MeshBasicMaterial, + Quaternion, + Sphere, + Vector3, +} from 'three'; +import { footprintPerDepth, originFor } from './pack.js'; + +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. */ +function blendInstanceColor(material, uniforms) { + material.onBeforeCompile = (shader) => { + Object.assign(shader.uniforms, uniforms); + shader.vertexShader = shader.vertexShader + .replace( + 'void main() {', + 'attribute vec4 gevColor;\nvarying vec4 vGevColor;\nvoid main() {', + ) + .replace( + '#include ', + '#include \nvGevColor = gevColor;', + ); + shader.fragmentShader = shader.fragmentShader + .replace( + 'void main() {', + 'uniform float uGevBlend;\nvarying vec4 vGevColor;\nvoid main() {', + ) + .replace( + '#include ', + '#include \ndiffuseColor.rgb = mix(diffuseColor.rgb, vGevColor.rgb, uGevBlend * vGevColor.a);', + ); + }; + material.customProgramCacheKey = () => 'gev-mover-model-blend'; +} + +/** + * @param {object} options normalized model set options + * @param {object} store the set's mover store + * @param {{ load: (url: string) => Promise<{ scene: object }>, onLoad?: () => void }} ports + */ +export function createModelMover(options, store, { load, onLoad = () => {} }) { + const { capacity, model } = options; + const uniforms = { + uGevBlend: { + value: model.colorBlend + ? model.colorBlend.mode === 'replace' + ? 1 + : model.colorBlend.amount + : 0, + }, + }; + const parts = []; // { mesh, local: Matrix4 } + let radius = 1; + let origin = null; + let footprint = 0; + let cameraPosition = null; + let loaded = false; + let disposed = false; + const colorAttributes = []; + + const write = (from, to) => { + if (!loaded || !origin) return; + const { positions, orientations, scales, shows, colors } = store; + for (let index = from; index < to; index++) { + let scale = shows[index] === 1 ? scales[index] : 0; + if (scale > 0 && cameraPosition && footprint > 0) { + 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); + const pixels = (2 * radius * scale) / (footprint * distance); + if (pixels < model.minimumPixelSize) + scale *= model.minimumPixelSize / pixels; + scale = Math.min(scale, model.maximumScale); + } + scratchTranslation.set( + positions[index * 3] - origin.x, + positions[index * 3 + 1] - origin.y, + positions[index * 3 + 2] - origin.z, + ); + scratchQuaternion.set( + orientations[index * 4], + orientations[index * 4 + 1], + orientations[index * 4 + 2], + orientations[index * 4 + 3], + ); + scratchScale.setScalar(scale); + for (const { mesh, local } of parts) { + scratchMatrix + .compose(scratchTranslation, scratchQuaternion, scratchScale) + .multiply(local); + mesh.setMatrixAt(index, scratchMatrix); + } + for (const attribute of colorAttributes) + attribute.array.set( + colors.subarray(index * 4, index * 4 + 4), + index * 4, + ); + } + for (const { mesh } of parts) { + mesh.count = store.count; + mesh.instanceMatrix.needsUpdate = true; + } + for (const attribute of colorAttributes) attribute.needsUpdate = true; + }; + + const group = { meshes: [], ready: null }; + group.ready = load(model.url).then((gltf) => { + if (disposed) return; + const root = gltf.scene; + root.updateMatrixWorld(true); + const sphere = new Box3() + .setFromObject(root) + .getBoundingSphere(new Sphere()); + radius = Math.max(sphere.radius, 1e-3); + root.traverse((child) => { + if (!child.isMesh) return; + let material = child.material; + if (model.unlit) + material = new MeshBasicMaterial({ + map: material.map ?? null, + color: material.color, + }); + else material = material.clone(); + blendInstanceColor(material, uniforms); + const geometry = child.geometry.clone(); + const color = new InstancedBufferAttribute( + new Float32Array(capacity * 4).fill(1), + 4, + ); + color.setUsage(DynamicDrawUsage); + geometry.setAttribute('gevColor', color); + colorAttributes.push(color); + const mesh = new InstancedMesh(geometry, material, capacity); + mesh.instanceMatrix.setUsage(DynamicDrawUsage); + mesh.frustumCulled = false; + mesh.renderOrder = options.order; + mesh.count = 0; + parts.push({ mesh, local: child.matrixWorld.clone() }); + group.meshes.push(mesh); + }); + loaded = true; + if (origin) + for (const { mesh } of parts) + mesh.position.set(origin.x, origin.y, origin.z); + write(0, store.count); + onLoad(group.meshes); + }); + + const placeAt = (next) => { + origin = { x: next.x, y: next.y, z: next.z }; + for (const { mesh } of parts) { + mesh.position.set(origin.x, origin.y, origin.z); + mesh.updateMatrix(); + mesh.updateMatrixWorld(true); + } + }; + + return { + group, + uniforms, + origin: () => origin, + commit(range) { + if (!origin && store.count > 0) + placeAt({ + x: store.positions[0], + y: store.positions[1], + z: store.positions[2], + }); + if (range) write(range.from, Math.min(range.to, store.count)); + for (const { mesh } of parts) mesh.count = store.count; + }, + /** Each drawn frame: pixel limits need every instance rewritten. */ + update({ cameraPosition: camera, cameraHeight, fovDeg, viewportHeightPx }) { + footprint = footprintPerDepth(fovDeg, viewportHeightPx); + cameraPosition = camera; + if (!origin || store.count === 0) return false; + const next = originFor(camera, origin, cameraHeight); + if (next !== origin) placeAt(next); + write(0, store.count); + return true; + }, + dispose() { + disposed = true; + for (const { mesh } of parts) { + mesh.geometry.dispose(); + mesh.material.dispose(); + mesh.dispose?.(); + } + }, + }; +} diff --git a/src/globe/navara/movers/modelMover.test.mjs b/src/globe/navara/movers/modelMover.test.mjs new file mode 100644 index 000000000..cf2d88f82 --- /dev/null +++ b/src/globe/navara/movers/modelMover.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + BoxGeometry, + Group, + Matrix4, + Mesh, + MeshStandardMaterial, + Vector3, +} from 'three'; +import { createMoverStore, normalizeMoverSetOptions } from '../../movers.js'; +import { createModelMover } from './modelMover.js'; + +const options = normalizeMoverSetOptions({ + id: 'm', + kind: 'model', + capacity: 2, + model: { + url: '/models/box.glb', + minimumPixelSize: 40, + colorBlend: { mode: 'mix', amount: 0.3 }, + }, +}); + +function fakeGltf() { + const scene = new Group(); + const box = new Mesh(new BoxGeometry(2, 2, 2), new MeshStandardMaterial()); + box.position.set(0, 1, 0); + scene.add(box); + return { scene }; +} + +test('instances share the model meshes, placed from the origin', 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 } }); + store.set(1, { + position: { x: 6_378_147, y: 0, z: 0 }, + orientation: { x: 0, y: 0, z: Math.SQRT1_2, w: Math.SQRT1_2 }, + color: [1, 0, 0, 1], + }); + store.setCount(2); + mover.commit(store.dirty()); + await mover.group.ready; + const [mesh] = mover.group.meshes; + assert.equal(mesh.count, 2); + assert.equal(mesh.position.x, 6_378_137); + assert.equal(mover.uniforms.uGevBlend.value, 0.3); + const matrix = new Matrix4(); + mesh.getMatrixAt(1, matrix); + const translation = new Vector3().setFromMatrixPosition(matrix); + // Offset 10 m east of the origin plus the box's local +1 y turned 90° about z. + assert.ok(Math.abs(translation.x - 9) < 1e-9, translation.x); + assert.ok(Math.abs(translation.y) < 1e-9); + const color = mesh.geometry.getAttribute('gevColor'); + assert.deepEqual([...color.array.subarray(4, 8)], [1, 0, 0, 1]); +}); + +test('a far model keeps its minimum pixel size', 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 } }); + store.setCount(1); + mover.commit(store.dirty()); + await mover.group.ready; + mover.update({ + cameraPosition: { x: 6_378_137 + 100_000, y: 0, z: 0 }, + cameraHeight: 100_000, + fovDeg: 60, + viewportHeightPx: 800, + }); + 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. + const footprint = (2 * Math.tan(Math.PI / 6)) / 800; + const expected = (40 * footprint * 100_000) / (2 * Math.sqrt(3)); + assert.ok( + Math.abs(scale - expected) / expected < 1e-6, + `${scale} vs ${expected}`, + ); +}); diff --git a/src/globe/navara/movers/moverMesh.js b/src/globe/navara/movers/moverMesh.js new file mode 100644 index 000000000..1483b5954 --- /dev/null +++ b/src/globe/navara/movers/moverMesh.js @@ -0,0 +1,280 @@ +// The three side of a Navara sprite or point set: one InstancedMesh of a +// unit quad, turned to face the screen and sized in CSS pixels in the +// vertex shader (the size comes from uniforms, not the projection, so the +// pick pass draws the same footprint), rotated by the instance's screen +// angle, textured from the atlas cell (sprites) or drawn as a disc with an +// outline (points), coloured per instance. Instance data lives in +// InstancedBufferAttributes written on commit; hidden instances get scale 0. + +import { + CanvasTexture, + DoubleSide, + DynamicDrawUsage, + InstancedBufferAttribute, + InstancedMesh, + MeshBasicMaterial, + PlaneGeometry, + SRGBColorSpace, + Texture, +} from 'three'; +import { + atlasUvRects, + footprintPerDepth, + originFor, + packInstances, +} from './pack.js'; + +const VERTEX_HEAD = /* glsl */ ` +attribute vec4 gevStyle; +attribute vec4 gevColor; +attribute vec4 gevUv; +uniform float uGevSizePx; +uniform float uGevFootprint; +uniform vec4 uGevScaleByDistance; +uniform float uGevHasScaleByDistance; +uniform float uGevDisableWithinM; +uniform float uGevOutlinePx; +varying vec4 vGevColor; +varying vec2 vGevLocal; +varying float vGevOutline; +`; + +const VERTEX_PROJECT = /* glsl */ ` +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) { + float t = clamp( + (gevDistance - uGevScaleByDistance.x) / + max(uGevScaleByDistance.z - uGevScaleByDistance.x, 1e-6), + 0.0, + 1.0 + ); + gevScale *= mix(uGevScaleByDistance.y, uGevScaleByDistance.w, t); +} +float gevSizePx = uGevSizePx * gevScale; +float gevCos = cos(gevStyle.x); +float gevSin = sin(gevStyle.x); +vec2 gevCorner = position.xy; +vec2 gevTurned = vec2( + gevCorner.x * gevCos - gevCorner.y * gevSin, + gevCorner.x * gevSin + gevCorner.y * gevCos +); +vec4 mvPosition = mvCenter; +mvPosition.xy += gevTurned * gevSizePx * uGevFootprint * max(-mvCenter.z, 0.0); +gl_Position = projectionMatrix * mvPosition; +vGevColor = gevColor; +vGevLocal = gevCorner; +vGevOutline = uGevOutlinePx / max(gevSizePx, 1e-3); +`; + +const VERTEX_UV = /* glsl */ ` +#include +#ifdef USE_MAP +vMapUv = mix(gevUv.xy, gevUv.zw, uv); +#endif +`; + +const VERTEX_DEPTH = /* glsl */ ` +#include +#ifdef USE_LOGARITHMIC_DEPTH_BUFFER +if (gevDistance < uGevDisableWithinM) vFragDepth = 1.0; +#endif +`; + +const FRAGMENT_HEAD = /* glsl */ ` +varying vec4 vGevColor; +varying vec2 vGevLocal; +varying float vGevOutline; +`; + +const fragmentColor = (point) => + point + ? /* glsl */ ` +#include +float gevRadius = length(vGevLocal) * 2.0; +if (gevRadius > 1.0) discard; +diffuseColor *= vGevColor; +if (gevRadius > 1.0 - vGevOutline) diffuseColor.rgb = vec3(0.0); +` + : /* glsl */ ` +#include +diffuseColor *= vGevColor; +`; + +/** Patches three's MeshBasicMaterial shaders into the screen-space quad. */ +export function patchMoverShader(shader, uniforms, { point = false } = {}) { + Object.assign(shader.uniforms, uniforms); + const replace = (source, from, to) => { + if (!source.includes(from)) + throw new Error(`Mover shader: three has no ${from}`); + return source.replace(from, to); + }; + let vertex = replace( + shader.vertexShader, + 'void main() {', + `${VERTEX_HEAD}\nvoid main() {`, + ); + vertex = replace(vertex, '#include ', VERTEX_PROJECT); + vertex = replace(vertex, '#include ', VERTEX_UV); + vertex = replace(vertex, '#include ', VERTEX_DEPTH); + let fragment = replace( + shader.fragmentShader, + 'void main() {', + `${FRAGMENT_HEAD}\nvoid main() {`, + ); + fragment = replace( + fragment, + '#include ', + fragmentColor(point), + ); + shader.vertexShader = vertex; + shader.fragmentShader = fragment; + return shader; +} + +/** The atlas as a three texture (a canvas at once, a URL once loaded). */ +function atlasTexture(atlas, onLoad) { + if (atlas.canvas) { + const texture = new CanvasTexture(atlas.canvas); + texture.colorSpace = SRGBColorSpace; + return texture; + } + const texture = new Texture(); + texture.colorSpace = SRGBColorSpace; + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => { + texture.image = image; + texture.needsUpdate = true; + onLoad(); + }; + image.src = atlas.url; + return texture; +} + +/** + * @param {ReturnType} options + * @param {ReturnType} store + * @param {{ onTextureLoad?: () => void }} [hooks] + */ +export function createMoverMesh( + options, + store, + { onTextureLoad = () => {} } = {}, +) { + const { capacity, kind, depthTest } = options; + const point = kind === 'point'; + const style = point ? options.point : options.sprite; + const geometry = new PlaneGeometry(1, 1); + const attribute = (name, size) => { + const value = new InstancedBufferAttribute( + new Float32Array(capacity * size), + size, + ); + value.setUsage(DynamicDrawUsage); + geometry.setAttribute(name, value); + return value; + }; + const styleAttribute = attribute('gevStyle', 4); + const colorAttribute = attribute('gevColor', 4); + const uvAttribute = attribute('gevUv', 4); + const uvRects = point + ? null + : atlasUvRects(options.sprite.atlas.cells, options.cellKeys); + const sbd = style.scaleByDistance; + const uniforms = { + uGevSizePx: { value: style.sizePx }, + uGevFootprint: { value: 0 }, + uGevScaleByDistance: { value: sbd ? [...sbd] : [0, 1, 1, 1] }, + uGevHasScaleByDistance: { value: sbd ? 1 : 0 }, + uGevDisableWithinM: { + value: typeof depthTest === 'object' ? depthTest.disableWithinM : -1, + }, + uGevOutlinePx: { value: point ? options.point.outlinePx : 0 }, + }; + const material = new MeshBasicMaterial({ + map: point ? null : atlasTexture(options.sprite.atlas, onTextureLoad), + transparent: true, + alphaTest: 0.02, + depthTest: depthTest !== 'always', + depthWrite: false, + side: DoubleSide, + }); + material.onBeforeCompile = (shader) => + patchMoverShader(shader, uniforms, { point }); + material.customProgramCacheKey = () => `gev-mover-${kind}`; + const mesh = new InstancedMesh(geometry, material, capacity); + mesh.instanceMatrix.setUsage(DynamicDrawUsage); + mesh.frustumCulled = false; + mesh.renderOrder = options.order; + mesh.count = 0; + let origin = null; + const out = { + matrices: mesh.instanceMatrix.array, + style: styleAttribute.array, + colors: colorAttribute.array, + uvs: uvAttribute.array, + }; + const upload = (from, to) => { + packInstances(store, from, to, origin, uvRects, out); + for (const [value, size] of [ + [mesh.instanceMatrix, 16], + [styleAttribute, 4], + [colorAttribute, 4], + [uvAttribute, 4], + ]) { + value.clearUpdateRanges(); + value.addUpdateRange(from * size, (to - from) * size); + value.needsUpdate = true; + } + }; + const placeAt = (next) => { + origin = { x: next.x, y: next.y, z: next.z }; + mesh.position.set(origin.x, origin.y, origin.z); + mesh.updateMatrix(); + mesh.updateMatrixWorld(true); + }; + return { + mesh, + uniforms, + origin: () => origin, + /** Writes the changed instances; the origin starts at the first one. */ + commit(range) { + if (!origin && store.count > 0) + placeAt({ + x: store.positions[0], + y: store.positions[1], + z: store.positions[2], + }); + mesh.count = store.count; + if (range && origin) { + const to = Math.min(range.to, store.count); + if (to > range.from) upload(range.from, to); + } + }, + /** + * Once per drawn frame: the pixel footprint from the camera, and the + * origin moved to the camera when it has gone too far (a full rewrite). + * Returns true when it rewrote the instances. + */ + update({ cameraPosition, cameraHeight, fovDeg, viewportHeightPx }) { + uniforms.uGevFootprint.value = footprintPerDepth( + fovDeg, + viewportHeightPx, + ); + if (!origin || store.count === 0) return false; + const next = originFor(cameraPosition, origin, cameraHeight); + if (next === origin) return false; + placeAt(next); + upload(0, store.count); + return true; + }, + dispose() { + geometry.dispose(); + material.map?.dispose(); + material.dispose(); + mesh.dispose?.(); + }, + }; +} diff --git a/src/globe/navara/movers/moverMesh.test.mjs b/src/globe/navara/movers/moverMesh.test.mjs new file mode 100644 index 000000000..7f7bbd84b --- /dev/null +++ b/src/globe/navara/movers/moverMesh.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ShaderLib, UniformsUtils } from 'three'; +import { createMoverStore, normalizeMoverSetOptions } from '../../movers.js'; +import { createMoverMesh, patchMoverShader } from './moverMesh.js'; + +const spriteOptions = normalizeMoverSetOptions({ + id: 'flights', + kind: 'sprite', + capacity: 4, + order: 3, + depthTest: { disableWithinM: 2000 }, + sprite: { + atlas: { canvas: { width: 8, height: 8 }, cells: { a: [0, 0, 1, 1] } }, + sizePx: 24, + scaleByDistance: [1000, 1, 100000, 0.5], + }, +}); + +const basicShader = () => ({ + uniforms: UniformsUtils.clone(ShaderLib.basic.uniforms), + vertexShader: ShaderLib.basic.vertexShader, + fragmentShader: ShaderLib.basic.fragmentShader, +}); + +test('the shader patch sizes the quad in pixels and colours it per instance', () => { + const shader = patchMoverShader(basicShader(), { uGevSizePx: { value: 9 } }); + assert.equal(shader.uniforms.uGevSizePx.value, 9); + assert.match(shader.vertexShader, /attribute vec4 gevStyle;/); + assert.match( + shader.vertexShader, + /uGevFootprint \* max\(-mvCenter\.z, 0\.0\)/, + ); + assert.match(shader.vertexShader, /vMapUv = mix\(gevUv\.xy, gevUv\.zw, uv\)/); + assert.match(shader.vertexShader, /vFragDepth = 1\.0;/); + assert.doesNotMatch(shader.vertexShader, /#include /); + assert.match(shader.fragmentShader, /diffuseColor \*= vGevColor;/); + assert.doesNotMatch(shader.fragmentShader, /discard/); + const point = patchMoverShader(basicShader(), {}, { point: true }); + assert.match(point.fragmentShader, /if \(gevRadius > 1\.0\) discard;/); +}); + +test('a sprite mesh uploads committed instances relative to its origin', () => { + const store = createMoverStore(spriteOptions); + const mover = createMoverMesh(spriteOptions, store); + const { mesh, uniforms } = mover; + assert.equal(mesh.renderOrder, 3); + assert.equal(mesh.count, 0); + assert.equal(mesh.material.depthTest, true); + assert.equal(uniforms.uGevDisableWithinM.value, 2000); + assert.deepEqual(uniforms.uGevScaleByDistance.value, [1000, 1, 100000, 0.5]); + store.set(0, { position: { x: 6_378_137, y: 10, z: 20 } }); + store.set(1, { + position: { x: 6_378_147, y: 10, z: 20 }, + color: [1, 0, 0, 1], + }); + store.setCount(2); + mover.commit(store.dirty()); + assert.equal(mesh.count, 2); + assert.deepEqual(mover.origin(), { x: 6_378_137, y: 10, z: 20 }); + assert.equal(mesh.position.x, 6_378_137); + assert.deepEqual([...mesh.instanceMatrix.array.subarray(28, 31)], [10, 0, 0]); + const color = mesh.geometry.getAttribute('gevColor'); + assert.deepEqual([...color.array.subarray(4, 8)], [1, 0, 0, 1]); + assert.ok(color.version > 0); +}); + +test('update sets the pixel footprint and moves the origin to a far camera', () => { + const store = createMoverStore(spriteOptions); + const mover = createMoverMesh(spriteOptions, store); + store.set(0, { position: { x: 6_378_137, y: 0, z: 0 } }); + store.setCount(1); + mover.commit(store.dirty()); + const camera = { + cameraPosition: { x: 6_378_137 + 300, y: 0, z: 0 }, + cameraHeight: 300, + fovDeg: 90, + viewportHeightPx: 800, + }; + assert.equal(mover.update(camera), false, 'close: the origin stays'); + assert.ok(Math.abs(mover.uniforms.uGevFootprint.value - 2 / 800) < 1e-12); + camera.cameraPosition = { x: 6_378_137 + 5_000, y: 0, z: 0 }; + assert.equal(mover.update(camera), true); + assert.equal(mover.origin().x, 6_378_137 + 5_000); + assert.equal(mesh(mover).instanceMatrix.array[12], -5_000); +}); + +const mesh = (mover) => mover.mesh; + +test('points have no atlas and draw a disc', () => { + const options = normalizeMoverSetOptions({ + id: 'p', + kind: 'point', + capacity: 1, + depthTest: 'always', + point: { sizePx: 6, outlinePx: 1 }, + }); + const mover = createMoverMesh(options, createMoverStore(options)); + assert.equal(mover.mesh.material.map, null); + assert.equal(mover.mesh.material.depthTest, false); + assert.equal(mover.uniforms.uGevOutlinePx.value, 1); + mover.dispose(); +}); diff --git a/src/globe/navara/movers/pack.js b/src/globe/navara/movers/pack.js new file mode 100644 index 000000000..35bedb178 --- /dev/null +++ b/src/globe/navara/movers/pack.js @@ -0,0 +1,86 @@ +// Pure parts of Navara movers: what the instanced meshes upload. Offsets are +// relative to an origin near the camera (the mesh sits at the origin, which +// three keeps in doubles), so float32 instance data keeps centimetres where +// it matters; the origin follows the camera past a distance that grows with +// height (no jitter close in, few rewrites from space). + +const RADIANS = Math.PI / 180; + +/** Metres one CSS pixel spans per metre of view depth. */ +export function footprintPerDepth(verticalFovDeg, viewportHeightPx) { + if (!(viewportHeightPx > 0)) return 0; + return (2 * Math.tan((verticalFovDeg * RADIANS) / 2)) / viewportHeightPx; +} + +/** Atlas cells [u0, v0, u1, v1] (v down from the image top) as three uvs (v up). */ +export function atlasUvRects(cells, keys) { + const rects = new Float32Array(keys.length * 4); + keys.forEach((key, index) => { + const [u0, v0, u1, v1] = cells[key]; + rects.set([u0, 1 - v1, u1, 1 - v0], index * 4); + }); + return rects; +} + +/** The camera distance from the origin past which the origin moves. */ +export const recentreDistance = (cameraHeight) => + Math.max(1_000, 0.1 * Math.abs(cameraHeight)); + +/** + * The origin to draw from: the current one while the camera stays within + * recentreDistance(height) of it, else the camera position. + */ +export function originFor(cameraPosition, origin, cameraHeight) { + if (!origin) return cameraPosition; + const dx = cameraPosition.x - origin.x; + const dy = cameraPosition.y - origin.y; + const dz = cameraPosition.z - origin.z; + const limit = recentreDistance(cameraHeight); + return dx * dx + dy * dy + dz * dz > limit * limit ? cameraPosition : origin; +} + +/** + * Writes instances [from, to) of a mover store into the upload arrays: + * `matrices` (identity with the offset from `origin` as translation), + * `style` (rotation, scale or 0 when hidden, two spare lanes), `colors` + * (rgba) and `uvs` (the instance's atlas rect, from `uvRects`). + */ +export function packInstances(store, from, to, origin, uvRects, out) { + const { positions, rotations, scales, shows, colors, cells } = store; + const { matrices, style, colors: colorOut, uvs } = out; + for (let index = from; index < to; index++) { + const m = index * 16; + matrices[m] = 1; + matrices[m + 1] = 0; + matrices[m + 2] = 0; + matrices[m + 3] = 0; + matrices[m + 4] = 0; + matrices[m + 5] = 1; + matrices[m + 6] = 0; + matrices[m + 7] = 0; + matrices[m + 8] = 0; + matrices[m + 9] = 0; + matrices[m + 10] = 1; + matrices[m + 11] = 0; + matrices[m + 12] = positions[index * 3] - origin.x; + matrices[m + 13] = positions[index * 3 + 1] - origin.y; + matrices[m + 14] = positions[index * 3 + 2] - origin.z; + matrices[m + 15] = 1; + const s = index * 4; + style[s] = rotations[index]; + style[s + 1] = shows[index] === 1 ? scales[index] : 0; + style[s + 2] = 0; + style[s + 3] = 0; + colorOut[s] = colors[s]; + colorOut[s + 1] = colors[s + 1]; + colorOut[s + 2] = colors[s + 2]; + colorOut[s + 3] = colors[s + 3]; + if (uvs && uvRects) { + const r = cells[index] * 4; + uvs[s] = uvRects[r]; + uvs[s + 1] = uvRects[r + 1]; + uvs[s + 2] = uvRects[r + 2]; + uvs[s + 3] = uvRects[r + 3]; + } + } +} diff --git a/src/globe/navara/movers/pack.test.mjs b/src/globe/navara/movers/pack.test.mjs new file mode 100644 index 000000000..10edf7257 --- /dev/null +++ b/src/globe/navara/movers/pack.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMoverStore, normalizeMoverSetOptions } from '../../movers.js'; +import { + atlasUvRects, + footprintPerDepth, + originFor, + packInstances, +} from './pack.js'; + +test('the footprint of a CSS pixel per metre of depth follows the vertical fov', () => { + // 90° fov over 800 px: 2·tan(45°)/800. + assert.ok(Math.abs(footprintPerDepth(90, 800) - 2 / 800) < 1e-12); + assert.equal(footprintPerDepth(60, 0), 0); +}); + +test('atlas cells become three uv rects (v up from the image bottom)', () => { + const rects = atlasUvRects({ a: [0, 0, 0.5, 0.25], b: [0.5, 0.5, 1, 1] }, [ + 'a', + 'b', + ]); + assert.deepEqual([...rects], [0, 0.75, 0.5, 1, 0.5, 0, 1, 0.5]); +}); + +test('the origin follows the camera only past a distance that grows with height', () => { + const origin = { x: 6_378_137, y: 0, z: 0 }; + const near = { x: 6_378_137 + 500, y: 0, z: 0 }; + assert.equal(originFor(near, origin, 150), origin, 'within 1 km at 150 m'); + const far = { x: 6_378_137 + 2_000, y: 0, z: 0 }; + assert.deepEqual(originFor(far, origin, 150), far); + assert.equal(originFor(far, origin, 1e6), origin, 'within 100 km at 1000 km'); + assert.deepEqual(originFor(far, null, 1e6), far, 'no origin yet'); +}); + +test('instances pack as offsets from the origin, style, colour and uv', () => { + const options = normalizeMoverSetOptions({ + id: 's', + kind: 'sprite', + capacity: 3, + sprite: { + atlas: { canvas: {}, cells: { a: [0, 0, 1, 1], b: [0, 0, 0.5, 0.5] } }, + sizePx: 10, + }, + }); + const store = createMoverStore(options); + store.set(0, { + position: { x: 1000.5, y: 2000, z: 3000 }, + rotation: 1, + scale: 2, + }); + store.set(1, { + position: { x: 1001, y: 2001, z: 3001 }, + show: false, + cell: 'b', + color: [1, 0, 0, 0.5], + }); + store.setCount(2); + const out = { + matrices: new Float32Array(48), + style: new Float32Array(12), + colors: new Float32Array(12), + uvs: new Float32Array(12), + }; + const uvRects = atlasUvRects(options.sprite.atlas.cells, options.cellKeys); + packInstances(store, 0, 2, { x: 1000, y: 2000, z: 3000 }, uvRects, out); + // Identity matrix with the offset as translation. + assert.deepEqual( + [...out.matrices.subarray(0, 16)], + [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.5, 0, 0, 1], + ); + assert.deepEqual([...out.matrices.subarray(28, 31)], [1, 1, 1]); + // rotation, scale (0 when hidden), and two spare lanes. + assert.deepEqual([...out.style.subarray(0, 4)], [1, 2, 0, 0]); + assert.deepEqual([...out.style.subarray(4, 8)], [0, 0, 0, 0]); + assert.deepEqual([...out.colors.subarray(4, 8)], [1, 0, 0, 0.5]); + assert.deepEqual([...out.uvs.subarray(0, 4)], [0, 0, 1, 1]); + assert.deepEqual([...out.uvs.subarray(4, 8)], [0, 0.5, 0.5, 1]); +}); diff --git a/src/globe/navara/movers/pickIds.js b/src/globe/navara/movers/pickIds.js new file mode 100644 index 000000000..6394a71ae --- /dev/null +++ b/src/globe/navara/movers/pickIds.js @@ -0,0 +1,28 @@ +// Navara picks report a batchId (PickableInstancedMeshWrapper, one per +// instance); this resolves it to the mover set, the instance and the pickId +// the layer gave it. Pure: wrappers are anything with a batchIds array. + +export function createPickIndex() { + const entries = new Map(); + return { + /** `wrapper.batchIds[i]` is instance i's batch id. */ + add(set, wrapper, pickIdOf) { + const list = entries.get(set) ?? []; + list.push({ wrapper, pickIdOf }); + entries.set(set, list); + }, + remove(set) { + entries.delete(set); + }, + /** { set, instance, pickId } for a batch id, or null. */ + resolve(batchId) { + for (const [set, list] of entries) + for (const { wrapper, pickIdOf } of list) { + const instance = wrapper.batchIds.indexOf(batchId); + if (instance >= 0) + return { set, instance, pickId: pickIdOf(instance) }; + } + return null; + }, + }; +} diff --git a/src/globe/navara/movers/pickIds.test.mjs b/src/globe/navara/movers/pickIds.test.mjs new file mode 100644 index 000000000..c9dbe5498 --- /dev/null +++ b/src/globe/navara/movers/pickIds.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createPickIndex } from './pickIds.js'; + +test('a batch id resolves to its set, instance and pick id', () => { + const index = createPickIndex(); + const pickIds = ['a', 'b', 'c']; + index.add('flights', { batchIds: [11, 12, 13] }, (i) => pickIds[i]); + index.add('sats', { batchIds: [21] }, () => 99); + assert.deepEqual(index.resolve(12), { + set: 'flights', + instance: 1, + pickId: 'b', + }); + assert.deepEqual(index.resolve(21), { set: 'sats', instance: 0, pickId: 99 }); + assert.equal(index.resolve(5), null); + index.remove('flights'); + assert.equal(index.resolve(12), null); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 3ed1f25a5..371c4d19b 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -70,14 +70,24 @@ const along = (origin, direction, distance) => /** * @param {import('../contract.js').GlobeEngine} engine * @param {{ waitFrames: (count: number) => Promise, maxWaitFrames?: number, - * mapSources?: { imagery: object[], photoreal: object } }} options + * mapSources?: { imagery: object[], photoreal: object }, + * readRegion?: (x: number, y: number, width: number, height: number) => Uint8ClampedArray, + * makeCanvas?: (width: number, height: number) => object }} options * `mapSources` (two imagery sources and a photoreal one the engine can * show) adds the maps checks; they leave photoreal tiles hidden. + * `readRegion` (RGBA bytes of the last drawn frame in a CSS-pixel box) and + * `makeCanvas` (a 2D canvas) add the pixel checks of drawn objects. * @returns {{ name: string, run: () => Promise }[]} */ export function createContractSuite( engine, - { waitFrames, maxWaitFrames = 240, mapSources = null }, + { + waitFrames, + maxWaitFrames = 240, + mapSources = null, + readRegion = null, + makeCanvas = null, + }, ) { if (typeof waitFrames !== 'function') throw new TypeError('createContractSuite needs a waitFrames(count) helper'); @@ -430,6 +440,157 @@ export function createContractSuite( } }, ], + [ + 'movers: three sprites draw at their projections, hide, recolour and go', + async () => { + await nadirView(1e6); + const atlasCanvas = makeCanvas + ? makeCanvas(16, 16) + : { width: 16, height: 16 }; + if (makeCanvas) { + const context = atlasCanvas.getContext('2d'); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, 16, 16); + } + const places = [ + [20, 10], + [21.5, 10], + [20, 11.5], + ].map(([lon, lat]) => cartesian3.fromDegrees(lon, lat, 1000)); + const set = engine.movers.createSet({ + id: 'contract-sprites', + kind: 'sprite', + capacity: 3, + order: 100, + depthTest: 'always', + sprite: { + atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } }, + sizePx: 24, + }, + }); + const MAGENTA = [1, 0, 1, 1]; + // Frames drawn in a window, with nothing asked of the engine. + const framesIn = async (ms) => { + let frames = 0; + const remove = engine.frames.postRender.addEventListener( + () => frames++, + ); + await new Promise((resolve) => setTimeout(resolve, ms)); + remove(); + return frames; + }; + const idleFrames = readRegion ? await framesIn(2000) : 0; + try { + places.forEach((position, index) => + set.set(index, { position, color: MAGENTA, cell: 'box' }), + ); + set.setCount(3); + set.commit(); + ensure(set.count() === 3, `count ${set.count()}`); + const view = set.positionsView(); + ensure( + view.length === 9 && view[3] === places[1].x, + 'positionsView does not hold the positions', + ); + ensure( + engine.movers + .list() + .some( + ({ id, count }) => id === 'contract-sprites' && count === 3, + ), + 'movers.list() does not show the set', + ); + if (!readRegion) return; + const screens = places.map((position) => + engine.projector.worldToWindow(position), + ); + // Pixels close to a colour in a 64 px box around a point: their + // count and centroid. + // A pure colour as drawn through either engine's tone mapping and + // grading (Navara's cinematic look desaturates): every full + // channel above 170 and at least 60 over every empty one. + const like = (colour, data, i) => { + let low = 0; + let high = 255; + for (let k = 0; k < 3; k++) + if (colour[k] > 0.5) high = Math.min(high, data[i + k]); + else low = Math.max(low, data[i + k]); + return high > 170 && high - low >= 60; + }; + const find = (screen, colour) => { + const half = 32; + const x0 = Math.round(screen.x) - half; + const y0 = Math.round(screen.y) - half; + const data = readRegion(x0, y0, 2 * half, 2 * half); + let count = 0; + let sx = 0; + let sy = 0; + for (let y = 0; y < 2 * half; y++) + for (let x = 0; x < 2 * half; x++) { + const i = (y * 2 * half + x) * 4; + if (like(colour, data, i)) { + count++; + sx += x0 + x + 0.5; + sy += y0 + y + 0.5; + } + } + return count + ? { count, x: sx / count, y: sy / count } + : { count, x: NaN, y: NaN }; + }; + // The atlas and the upload may take a few frames. + await until( + () => find(screens[0], MAGENTA).count > 100, + 'the first sprite drawn', + ); + screens.forEach((screen, index) => { + const seen = find(screen, MAGENTA); + ensure( + seen.count > 300, + `sprite ${index}: ${seen.count} magenta pixels, a 24 px square has 576`, + ); + ensure( + Math.hypot(seen.x - screen.x, seen.y - screen.y) <= 2, + `sprite ${index} centred at ${seen.x.toFixed(1)}, ${seen.y.toFixed(1)}, projected ${screen.x.toFixed(1)}, ${screen.y.toFixed(1)}`, + ); + }); + set.set(1, { show: false }); + set.set(0, { color: [0, 1, 0, 1] }); + set.commit(); + await until( + () => find(screens[1], MAGENTA).count < 10, + 'the hidden sprite gone', + ); + ensure( + find(screens[0], [0, 1, 0, 1]).count > 300, + 'the recoloured sprite is not green', + ); + ensure( + find(screens[2], MAGENTA).count > 300, + 'an untouched sprite changed', + ); + // Idle: once a commit has drawn, the set adds no frames (the + // page may draw for its own reasons: the app's other layers). + await settle(2); + const framesAfter = await framesIn(2000); + ensure( + framesAfter <= idleFrames + 1, + `${framesAfter} frames in 2 s after a commit, ${idleFrames} before the set`, + ); + set.destroy(); + await until( + () => find(screens[2], MAGENTA).count < 10, + 'the destroyed set gone', + ); + } finally { + set.destroy(); + } + ensure( + !engine.movers.list().some(({ id }) => id === 'contract-sprites'), + 'a destroyed set is still listed', + ); + }, + ], [ 'render: continuous mode toggles', async () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 7b75bf1c5..541e55995 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -26,6 +26,7 @@ import { mergeSharpen, } from '../contract.js'; import { createAppClock } from '../../time/clock.js'; +import { createMoverRegistry } from '../movers.js'; const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; const WGS84 = ellipsoid.WGS84; @@ -260,6 +261,18 @@ export function createFakeEngine(options = {}) { let wallMs = options.nowMs ?? FAKE_EPOCH_MS; const appClock = options.appClock ?? createAppClock({ now: () => wallMs }); const ownsWall = !options.appClock; + // Movers keep their store for tests; commit counts as a render request. + const moverRegistry = createMoverRegistry(() => { + const part = { + commits: 0, + commit() { + part.commits++; + fake.renderRequests++; + }, + destroy() {}, + }; + return part; + }); const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()])); const livePosition = cartesian3.create(); let lastBaseImagery = null; @@ -793,6 +806,7 @@ export function createFakeEngine(options = {}) { viewProjection, horizonVisible, }, + movers: moverRegistry.movers, clock: { now: () => appClock.now(), frameTime: () => appClock.frameTime(), @@ -907,6 +921,7 @@ export function createFakeEngine(options = {}) { destroy() { record('destroy', []); cancelCurrentFlight(); + moverRegistry.destroyAll(); fake.destroyed = true; }, isDestroyed: () => fake.destroyed, @@ -967,6 +982,17 @@ export function createFakeEngine(options = {}) { scripts.delete(name); }, lookAtTarget: () => (lookAtTarget ? copy3(lookAtTarget) : undefined), + /** A live mover set's options, store and commit count, by id. */ + moverSet(id) { + const entry = moverRegistry.sets.get(id); + return entry + ? { + options: entry.options, + store: entry.store, + commits: entry.part.commits, + } + : undefined; + }, /** The app clock the engine clock reads (freeze/offset/rate for tests). */ appClock, /** Moves the fake wall clock without a frame (own app clock only). */ diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index 87faeb080..44275e3eb 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -440,3 +440,37 @@ test('a fake engine can share an app clock', async () => { engine.fake.frame(); assert.equal(appClock.frameTime(), 6000); }); + +test('fake movers keep their sets and instances for layer tests', () => { + const engine = createFakeEngine(); + const set = engine.movers.createSet({ + id: 'flights', + kind: 'point', + capacity: 4, + point: { sizePx: 6 }, + }); + set.set(0, { position: { x: 1, y: 2, z: 3 }, color: [1, 0, 0, 1] }); + set.setCount(1); + assert.deepEqual(engine.movers.list(), [ + { id: 'flights', kind: 'point', count: 1, capacity: 4 }, + ]); + const before = engine.fake.renderRequests; + set.commit(); + assert.equal(engine.fake.renderRequests, before + 1); + const stored = engine.fake.moverSet('flights'); + assert.equal(stored.commits, 1); + assert.deepEqual([...stored.store.colors.subarray(0, 4)], [1, 0, 0, 1]); + assert.throws( + () => + engine.movers.createSet({ + id: 'flights', + kind: 'point', + capacity: 1, + point: { sizePx: 1 }, + }), + /already/, + ); + set.destroy(); + assert.deepEqual(engine.movers.list(), []); + assert.equal(engine.fake.moverSet('flights'), undefined); +});