mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat(globe): instanced movers on both engines
engine.movers.createSet({ id, kind: sprite|point|model, capacity, order,
depthTest, sprite|point|model, pickable }) → a MoverSet: set(index,
fields) stores an instance (position, screen rotation, orientation, rgba,
scale, atlas cell, show, pickId) in flat arrays, commit() uploads the
changed range once and requests one render, positionsView() reads the
positions, destroy() removes it; movers.list() for QA. The options, the
store and the registry are engine-neutral (src/globe/movers.js); the fake
engine keeps them for layer tests.
- Cesium: one primitive per set in an engine-owned PrimitiveCollection
added with the first set, in `order`: sprites a BillboardCollection
(one image per atlas cell, NearFarScalar, disableDepthTestDistance),
points a PointPrimitiveCollection, models a Model per instance.
- Navara: a public MeshDesc per set. Sprites and points are one
InstancedMesh of a quad, faced to the screen and sized in CSS pixels in
the vertex shader from uniforms (the pick pass draws the same
footprint), atlas uv, rotation, colour and scale per instance, drawn in
the transparent pass unlit; offsets from an origin that follows the
camera past max(1 km, 10 % of its height). Models: the glTF once, one
InstancedMesh per mesh, minimumPixelSize/maximumScale on the CPU per
drawn frame, colorBlend in the shader. Pickable sets register a
PickableInstancedMeshWrapper; pickIds.js maps batch ids back to
instances (used by Task 4).
- The contract suite draws three 24 px sprites on both engines: centred
within 2 px of worldToWindow, hidden by show:false, recoloured, gone on
destroy, and no frames added once the commit has drawn.
- qa-engine-bench --scenario=movers: 10 000 synthetic aircraft
(src/bench/syntheticFlights.js, moved from the Navara spike with its
test), every instance moved and committed every frame. p95 Navara
(look=minimal) / Cesium: global 5.2/7.0 ms, city 5.6/9.9, orbit 6.0/7.4
(0.74, 0.57, 0.81; gate 1.1). Cesium is measured without its 60 fps cap.
Main chunk 2,860,302 bytes (+9,706, +0.34 %: the Cesium movers).
This commit is contained in:
1 parent
4623c8b616
commit
0109097440
24 files changed
+2735
-3
No files matched your search
@@ -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"
|
||||
]
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<string, number[]> },
|
||||
* 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,
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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<string, object>, 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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 <begin_vertex>',
|
||||
'#include <begin_vertex>\nvGevColor = gevColor;',
|
||||
);
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace(
|
||||
'void main() {',
|
||||
'uniform float uGevBlend;\nvarying vec4 vGevColor;\nvoid main() {',
|
||||
)
|
||||
.replace(
|
||||
'#include <map_fragment>',
|
||||
'#include <map_fragment>\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?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
@@ -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 <uv_vertex>
|
||||
#ifdef USE_MAP
|
||||
vMapUv = mix(gevUv.xy, gevUv.zw, uv);
|
||||
#endif
|
||||
`;
|
||||
|
||||
const VERTEX_DEPTH = /* glsl */ `
|
||||
#include <logdepthbuf_vertex>
|
||||
#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 <color_fragment>
|
||||
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 <color_fragment>
|
||||
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 <project_vertex>', VERTEX_PROJECT);
|
||||
vertex = replace(vertex, '#include <uv_vertex>', VERTEX_UV);
|
||||
vertex = replace(vertex, '#include <logdepthbuf_vertex>', VERTEX_DEPTH);
|
||||
let fragment = replace(
|
||||
shader.fragmentShader,
|
||||
'void main() {',
|
||||
`${FRAGMENT_HEAD}\nvoid main() {`,
|
||||
);
|
||||
fragment = replace(
|
||||
fragment,
|
||||
'#include <color_fragment>',
|
||||
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<import('../../movers.js').normalizeMoverSetOptions>} options
|
||||
* @param {ReturnType<import('../../movers.js').createMoverStore>} 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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 <project_vertex>/);
|
||||
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();
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -70,14 +70,24 @@ const along = (origin, direction, distance) =>
|
||||
/**
|
||||
* @param {import('../contract.js').GlobeEngine} engine
|
||||
* @param {{ waitFrames: (count: number) => Promise<void>, 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<void> }[]}
|
||||
*/
|
||||
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 () => {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user