Add columnar icon sets drawn by an instanced Cesium primitive

Icon rows live in typed-array columns: one packed motion segment (hold,
lerp or arc, with a course glide), style, colour and pick ids per row.
The primitive draws every row in one instanced call, dead-reckons in the
vertex shader, matches the billboard treatment the fleet layers use, and
picks and drill-picks like a billboard. Cyber sonar covers icon sets.
This commit is contained in:
Sameh Khamis committed 2026-11-03 18:35:12 -08:00
1 parent 7204d66f0b
commit 5f36eb0e0d
8 files changed
+1982 -5

No files matched your search

+4
View File
@@ -2259,6 +2259,10 @@
"src/hudLayoutPolicy.js",
"src/ui/radioPanelPlacement.js",
"src/cyberSonarGpu.js",
"src/data/iconSetPrimitive.js",
"src/data/iconSet.js",
"src/data/iconSegment.js",
"src/data/iconAtlas.js",
"src/scenes/nepalEvidencePack.js",
"src/data/approxDistance.js",
"src/data/bhoteKoshiEvent.js",
+14 -5
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { IconSetPrimitive } from './data/iconSetPrimitive.js';
// Narrow, version-tested engine adapter. Cesium has no public custom shader
// option for native point/billboard collections. Do not patch its prototypes or
@@ -64,9 +65,15 @@ export function sonarVertexSource(source, kind) {
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
const lowAttribute =
kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation';
// Icon sets move rows on the GPU, so their shader publishes the anchor itself.
const anchorSource =
kind === 'icon'
? 'iconAnchorClip'
: `czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz)`;
if (
!text.includes(attribute) ||
!text.includes(lowAttribute) ||
(kind === 'icon'
? !text.includes('iconAnchorClip')
: !text.includes(attribute) || !text.includes(lowAttribute)) ||
!text.includes('v_color') ||
!/void\s+main\s*\(\s*\)/.test(text)
)
@@ -83,7 +90,7 @@ void main() {
gev_sonar_main();
// All corners/glyphs share the contact anchor, before native screen offsets.
// Leave gl_Position untouched so appearance never changes geometry or picks.
vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz);
vec4 anchor = ${anchorSource};
if (anchor.w <= 0.0) return;
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
float floorValue = u_gevSonar.z;
@@ -108,7 +115,7 @@ void main() {
v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
// Apply before native fragment pass classification, never after main().
v_color.a *= factor;
${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
${kind === 'point' ? 'v_outlineColor.a *= factor;' : kind === 'icon' ? '' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
}
`,
];
@@ -208,7 +215,9 @@ export function createCyberSonarGpu(scene, readFrame) {
? 'point'
: native.owner instanceof Cesium.BillboardCollection
? 'billboard'
: null;
: native.owner instanceof IconSetPrimitive
? 'icon'
: null;
if (!kind || !native.shaderProgram || !native.uniformMap) continue;
const source = native.shaderProgram;
// Label backgrounds are non-SDF billboard batches. The version-tested
+150
View File
@@ -0,0 +1,150 @@
/**
* @module iconAtlas
* @description The images icon sets draw, packed into one canvas. A layer asks for a
* slot by image URL and raster size and gets a number at once; the image loads and is
* packed later, and until then its slot is empty and rows using it draw nothing (as a
* Cesium billboard waits for its image).
*
* The renderer uploads {@link iconAtlas}.canvas whenever `version` changes and reads
* each slot's texture rectangle from `rects`.
*/
/** Slots a renderer can address (one vec4 uniform each). */
export const MAX_ICON_SLOTS = 64;
const PADDING_PX = 2;
const START_SIZE_PX = 1024;
const MAX_SIZE_PX = 4096;
function createAtlas() {
const slotOfKey = new Map();
const entries = [];
const atlas = {
/** Bumped whenever the canvas or a slot rectangle changes. */
version: 0,
canvas: null,
width: 0,
height: 0,
/** Per slot `[u0, v0, u1, v1]`, zero until the image is packed. */
rects: new Float32Array(MAX_ICON_SLOTS * 4),
get size() {
return entries.length;
},
/**
* The slot for `url` rasterised at `sizePx` square.
* @param {string} url
* @param {number} sizePx
* @returns {number}
*/
slot(url, sizePx) {
const key = `${sizePx}|${url}`;
const known = slotOfKey.get(key);
if (known !== undefined) return known;
if (entries.length >= MAX_ICON_SLOTS)
throw new RangeError(
`The icon atlas holds at most ${MAX_ICON_SLOTS} images`,
);
const slot = entries.length;
const entry = { url, sizePx, image: null, x: 0, y: 0 };
entries.push(entry);
slotOfKey.set(key, slot);
load(entry);
return slot;
},
/** Whether a slot's image is packed and drawable. */
ready(slot) {
return atlas.rects[slot * 4 + 2] > 0;
},
};
function load(entry) {
if (typeof Image === 'undefined') return; // Node: slots only
const image = new Image(entry.sizePx, entry.sizePx);
image.decoding = 'async';
// A cross-origin image would taint the canvas and every texture upload.
image.crossOrigin = 'anonymous';
image.onload = () => {
entry.image = image;
if (!pack()) {
entry.image = null; // does not fit: leave its slot empty, keep the rest
entry.failed = true;
console.warn(
`[iconAtlas] No room for ${entry.sizePx} px icon; slot stays empty`,
);
pack();
}
};
image.onerror = () => {
entry.failed = true;
console.warn(
`[iconAtlas] Icon failed to load: ${entry.url.slice(0, 80)}`,
);
};
image.src = entry.url;
}
// Shelf packing over every loaded image, largest first, growing the canvas
// until they fit. Rebuilt in full: the set is a few dozen images. Returns false,
// changing nothing, when they cannot fit at the largest size.
function pack() {
const loaded = entries.filter((entry) => entry.image);
loaded.sort((a, b) => b.sizePx - a.sizePx);
let size = Math.max(atlas.width, START_SIZE_PX);
while (!place(loaded, size)) {
size *= 2;
if (size > MAX_SIZE_PX) return false;
}
if (!atlas.canvas || atlas.width !== size) {
atlas.canvas =
typeof document !== 'undefined'
? Object.assign(document.createElement('canvas'), {
width: size,
height: size,
})
: new OffscreenCanvas(size, size);
atlas.width = size;
atlas.height = size;
}
const g = atlas.canvas.getContext('2d');
g.clearRect(0, 0, size, size);
atlas.rects.fill(0);
for (const entry of loaded) {
g.drawImage(entry.image, entry.x, entry.y, entry.sizePx, entry.sizePx);
const slot = entries.indexOf(entry);
const o = slot * 4;
atlas.rects[o] = entry.x / size;
atlas.rects[o + 1] = entry.y / size;
atlas.rects[o + 2] = (entry.x + entry.sizePx) / size;
atlas.rects[o + 3] = (entry.y + entry.sizePx) / size;
}
atlas.version++;
return true;
}
function place(loaded, size) {
let x = 0;
let y = 0;
let shelf = 0;
for (const entry of loaded) {
const cell = entry.sizePx + PADDING_PX * 2;
if (x + cell > size) {
x = 0;
y += shelf;
shelf = 0;
}
if (y + cell > size) return false;
entry.x = x + PADDING_PX;
entry.y = y + PADDING_PX;
x += cell;
shelf = Math.max(shelf, cell);
}
return true;
}
return atlas;
}
/** The one atlas every icon set draws from. */
export const iconAtlas = createAtlas();
+320
View File
@@ -0,0 +1,320 @@
/**
* @module iconSegment
* @description The motion an icon row follows between CPU updates: one segment, packed
* into a row of floats that the CPU ({@link evaluateSegment}) and the GPU
* ({@link ICON_SEGMENT_GLSL}) evaluate the same way at a render time.
*
* A segment is `position(τ) = base + U·e(τ) + V·n(τ)` plus a course:
* - hold: stays at `base`, course fixed;
* - lerp: straight from `base` to `base + U` between two fix times (the interpolation
* between two known fixes), course affine in the fraction;
* - arc: a constant-rate turn from a fix in its east/north plane (`U`, `V`), with the
* elapsed time clamped (warm-up before the first fix, coast after the last).
*
* The arc uses `cos a − cos(a+b) = 2 sin(b/2) sin(a+b/2)`, so its offset is
* `v·t·sinc(ωt/2)·(sin, cos)(a + ωt/2)`: no cancellation in float32 and no branch
* between straight and turning motion. Turn rates under 1e-4 rad/s fly straight,
* as `arcOffsetEnu` does.
*
* Times are seconds since the owning set's epoch. `base` is stored as high and low
* float32 parts (Cesium's EncodedCartesian3) so the GPU can draw it relative to the eye.
*/
import * as Cesium from 'cesium';
import { norm180, norm360 } from './motionModel.js';
/** Floats per packed segment. */
export const SEGMENT_STRIDE = 24;
export const SEGMENT_HOLD = 0;
export const SEGMENT_LERP = 1;
export const SEGMENT_ARC = 2;
// Offsets inside one packed segment. The GPU reads them as six vec4s in this order.
const BASE_HIGH = 0;
const BASE_LOW = 3;
const AXIS_U = 6;
const AXIS_V = 9;
const KIND = 12;
const T0 = 13;
const P1 = 14; // lerp: span (s); arc: smallest elapsed time (s)
const P2 = 15; // arc: largest elapsed time (s)
const SPEED = 16; // m/s
const TRACK = 17; // rad, clockwise from north
const OMEGA = 18; // rad/s
const COURSE0 = 19; // deg
const COURSE_SLOPE = 20; // lerp: deg per unit fraction
const SLEW_OFFSET = 21; // deg still to glide at SLEW_T0
const SLEW_T0 = 22; // s
const SLEW_RATE = 23; // deg/s
const DEG = Math.PI / 180;
const STRAIGHT_BELOW_RAD_PER_SEC = 1e-4;
const _encoded = new Cesium.EncodedCartesian3();
const _enu = new Cesium.Matrix4();
function writeBase(out, o, position) {
Cesium.EncodedCartesian3.fromCartesian(position, _encoded);
out[o + BASE_HIGH] = _encoded.high.x;
out[o + BASE_HIGH + 1] = _encoded.high.y;
out[o + BASE_HIGH + 2] = _encoded.high.z;
out[o + BASE_LOW] = _encoded.low.x;
out[o + BASE_LOW + 1] = _encoded.low.y;
out[o + BASE_LOW + 2] = _encoded.low.z;
}
function clearMotion(out, o) {
out.fill(0, o + AXIS_U, o + SEGMENT_STRIDE);
}
/**
* Stay at `position` facing `courseDeg`.
* @param {Float32Array} out
* @param {number} row
* @param {Cesium.Cartesian3} position
* @param {number} courseDeg
*/
export function writeHoldSegment(out, row, position, courseDeg) {
const o = row * SEGMENT_STRIDE;
writeBase(out, o, position);
clearMotion(out, o);
out[o + KIND] = SEGMENT_HOLD;
out[o + COURSE0] = norm360(courseDeg || 0);
}
/**
* Move straight from `from` (at `t0`) to `to` (at `t1`), holding at either end.
* The course runs from `course0Deg` by `courseSlopeDeg` over the segment.
* @param {Float32Array} out
* @param {number} row
* @param {{from: Cesium.Cartesian3, to: Cesium.Cartesian3, t0: number, t1: number,
* course0Deg: number, courseSlopeDeg: number}} segment
*/
export function writeLerpSegment(out, row, segment) {
const o = row * SEGMENT_STRIDE;
const { from, to } = segment;
writeBase(out, o, from);
clearMotion(out, o);
out[o + AXIS_U] = to.x - from.x;
out[o + AXIS_U + 1] = to.y - from.y;
out[o + AXIS_U + 2] = to.z - from.z;
out[o + KIND] = SEGMENT_LERP;
out[o + T0] = segment.t0;
out[o + P1] = Math.max(0, segment.t1 - segment.t0);
out[o + COURSE0] = norm360(segment.course0Deg || 0);
out[o + COURSE_SLOPE] = segment.courseSlopeDeg || 0;
}
/**
* Turn at a constant rate from `fix` (at `t0`) in its east/north plane, with the
* elapsed time clamped to `[minDt, maxDt]`.
* @param {Float32Array} out
* @param {number} row
* @param {{fix: Cesium.Cartesian3, t0: number, minDt: number, maxDt: number,
* speedMps: number, trackDeg: number, turnRateDps: number}} segment
*/
export function writeArcSegment(out, row, segment) {
const o = row * SEGMENT_STRIDE;
const { fix } = segment;
writeBase(out, o, fix);
clearMotion(out, o);
Cesium.Transforms.eastNorthUpToFixedFrame(fix, Cesium.Ellipsoid.WGS84, _enu);
// Column-major: columns 0 and 1 are the east and north unit vectors.
out[o + AXIS_U] = _enu[0];
out[o + AXIS_U + 1] = _enu[1];
out[o + AXIS_U + 2] = _enu[2];
out[o + AXIS_V] = _enu[4];
out[o + AXIS_V + 1] = _enu[5];
out[o + AXIS_V + 2] = _enu[6];
const omega = (segment.turnRateDps || 0) * DEG;
out[o + KIND] = SEGMENT_ARC;
out[o + T0] = segment.t0;
out[o + P1] = segment.minDt;
out[o + P2] = segment.maxDt;
out[o + SPEED] = segment.speedMps || 0;
out[o + TRACK] = (segment.trackDeg || 0) * DEG;
out[o + OMEGA] = Math.abs(omega) < STRAIGHT_BELOW_RAD_PER_SEC ? 0 : omega;
out[o + COURSE0] = norm360(segment.trackDeg || 0);
}
/**
* Glide the displayed course from `fromCourseDeg` onto the segment's own course,
* at most `rateDps`, starting at `t0` (the rate limit at a segment boundary).
* Call after writing the segment.
* @param {Float32Array} out
* @param {number} row
* @param {number} fromCourseDeg
* @param {number} t0
* @param {number} rateDps
*/
export function writeCourseGlide(out, row, fromCourseDeg, t0, rateDps) {
const o = row * SEGMENT_STRIDE;
out[o + SLEW_OFFSET] = 0;
const target = targetCourse(out, o, t0);
out[o + SLEW_OFFSET] = norm180(fromCourseDeg - target);
out[o + SLEW_T0] = t0;
out[o + SLEW_RATE] = Math.max(0, rateDps);
}
/**
* Move a row's stored times by `deltaSec` (the epoch moved by `-deltaSec`).
* @param {Float32Array} out
* @param {number} row
* @param {number} deltaSec
*/
export function shiftSegmentTimes(out, row, deltaSec) {
const o = row * SEGMENT_STRIDE;
out[o + T0] += deltaSec;
out[o + SLEW_T0] += deltaSec;
}
/** Elapsed fraction (lerp) or clamped elapsed seconds (arc) at `tau`. */
function segmentParameter(seg, o, tau) {
const kind = seg[o + KIND];
if (kind === SEGMENT_LERP) {
const span = seg[o + P1];
return span > 0 ? Math.min(1, Math.max(0, (tau - seg[o + T0]) / span)) : 1;
}
if (kind === SEGMENT_ARC)
return Math.min(seg[o + P2], Math.max(seg[o + P1], tau - seg[o + T0]));
return 0;
}
function sinc(x) {
return Math.abs(x) < 1e-4 ? 1 - (x * x) / 6 : Math.sin(x) / x;
}
/**
* The displayed course at `tau`, in degrees clockwise from north.
* @param {Float32Array} seg
* @param {number} row
* @param {number} tau
* @returns {number}
*/
export function segmentCourse(seg, row, tau) {
const o = row * SEGMENT_STRIDE;
const course = targetCourse(seg, o, tau);
const offset = seg[o + SLEW_OFFSET];
if (offset === 0) return norm360(course);
// limitCourseStep: the displayed course turns at the rate toward the segment's
// own (possibly turning) course until it reaches it, then follows it. The
// segment's course is linear and unwrapped, so the gap needs no modulo.
const t0 = seg[o + SLEW_T0];
const toward = -Math.sign(offset);
const start = targetCourse(seg, o, t0);
const turned = seg[o + SLEW_RATE] * Math.max(0, tau - t0);
const gap = toward * (course - start - offset);
return norm360(turned < gap ? start + offset + toward * turned : course);
}
/** The segment's own course at `tau`, before any glide. */
function targetCourse(seg, o, tau) {
const kind = seg[o + KIND];
const p = segmentParameter(seg, o, tau);
let course = seg[o + COURSE0];
if (kind === SEGMENT_LERP) course += seg[o + COURSE_SLOPE] * p;
else if (kind === SEGMENT_ARC) course += (seg[o + OMEGA] / DEG) * p;
return course;
}
/**
* Where the row is at `tau`, in ECEF.
* @param {Float32Array} seg
* @param {number} row
* @param {number} tau
* @param {Cesium.Cartesian3} [result]
* @returns {Cesium.Cartesian3}
*/
export function segmentPosition(
seg,
row,
tau,
result = new Cesium.Cartesian3(),
) {
const o = row * SEGMENT_STRIDE;
let x = seg[o + BASE_HIGH] + seg[o + BASE_LOW];
let y = seg[o + BASE_HIGH + 1] + seg[o + BASE_LOW + 1];
let z = seg[o + BASE_HIGH + 2] + seg[o + BASE_LOW + 2];
const kind = seg[o + KIND];
if (kind === SEGMENT_LERP) {
const f = segmentParameter(seg, o, tau);
x += seg[o + AXIS_U] * f;
y += seg[o + AXIS_U + 1] * f;
z += seg[o + AXIS_U + 2] * f;
} else if (kind === SEGMENT_ARC) {
const dt = segmentParameter(seg, o, tau);
const half = seg[o + OMEGA] * dt * 0.5;
const reach = seg[o + SPEED] * dt * sinc(half);
const heading = seg[o + TRACK] + half;
const e = reach * Math.sin(heading);
const n = reach * Math.cos(heading);
x += seg[o + AXIS_U] * e + seg[o + AXIS_V] * n;
y += seg[o + AXIS_U + 1] * e + seg[o + AXIS_V + 1] * n;
z += seg[o + AXIS_U + 2] * e + seg[o + AXIS_V + 2] * n;
}
result.x = x;
result.y = y;
result.z = z;
return result;
}
/**
* GLSL for the same evaluation. Expects the six segment vec4s as `seg0`…`seg5`
* (floats 0–3, 4–7, … of the packed row) and returns the eye-relative position in
* `positionRte` and the course in degrees.
*/
export const ICON_SEGMENT_GLSL = /* glsl */ `
float iconSinc(float x) {
return abs(x) < 1e-4 ? 1.0 - x * x / 6.0 : sin(x) / x;
}
float iconSegmentParameter(vec4 seg3, vec4 seg4, float tau) {
float kind = seg3.x;
if (kind == 1.0) {
float span = seg3.z;
return span > 0.0 ? clamp((tau - seg3.y) / span, 0.0, 1.0) : 1.0;
}
if (kind == 2.0) return clamp(tau - seg3.y, seg3.z, seg3.w);
return 0.0;
}
vec3 iconSegmentPosition(vec4 seg0, vec4 seg1, vec4 seg2, vec4 seg3, vec4 seg4, float tau) {
vec3 high = seg0.xyz;
vec3 low = vec3(seg0.w, seg1.xy);
vec3 axisU = vec3(seg1.zw, seg2.x);
vec3 axisV = seg2.yzw;
vec4 rte = czm_translateRelativeToEye(high, low);
vec3 p = rte.xyz;
float kind = seg3.x;
float t = iconSegmentParameter(seg3, seg4, tau);
if (kind == 1.0) {
p += axisU * t;
} else if (kind == 2.0) {
float half_ = seg4.z * t * 0.5;
float reach = seg4.x * t * iconSinc(half_);
float heading = seg4.y + half_;
p += axisU * (reach * sin(heading)) + axisV * (reach * cos(heading));
}
return p;
}
float iconTargetCourse(vec4 seg3, vec4 seg4, vec4 seg5, float tau) {
float kind = seg3.x;
float t = iconSegmentParameter(seg3, seg4, tau);
float course = seg4.w;
if (kind == 1.0) course += seg5.x * t;
else if (kind == 2.0) course += degrees(seg4.z) * t;
return course;
}
float iconSegmentCourse(vec4 seg3, vec4 seg4, vec4 seg5, float tau) {
float course = iconTargetCourse(seg3, seg4, seg5, tau);
float offset = seg5.y;
if (offset == 0.0) return course;
float toward = -sign(offset);
float start = iconTargetCourse(seg3, seg4, seg5, seg5.z);
float turned = seg5.w * max(0.0, tau - seg5.z);
float gap = toward * (course - start - offset);
return turned < gap ? start + offset + toward * turned : course;
}
`;
+215
View File
@@ -0,0 +1,215 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
SEGMENT_STRIDE,
segmentCourse,
segmentPosition,
writeArcSegment,
writeCourseGlide,
writeHoldSegment,
writeLerpSegment,
} from './iconSegment.js';
import {
arcOffsetEnu,
lerpAngleDeg,
limitCourseStep,
norm180,
} from './motionModel.js';
const fix = Cesium.Cartesian3.fromDegrees(-97.04, 32.9, 10_500);
/** What the flights layer's _extrapolateFix draws for the same fix and dt. */
function productionArc(position, speed, trackDeg, turnRateDps, dt) {
const arc = arcOffsetEnu(speed, trackDeg, turnRateDps, dt, {});
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
const offset = new Cesium.Cartesian3(arc.east, arc.north, 0);
return {
position: Cesium.Matrix4.multiplyByPoint(
enu,
offset,
new Cesium.Cartesian3(),
),
course: arc.endCourseDeg,
};
}
function courseGap(a, b) {
return Math.abs(norm180(a - b));
}
test('a hold stays put to float32 encoding precision', () => {
const seg = new Float32Array(SEGMENT_STRIDE * 2);
writeHoldSegment(seg, 1, fix, 271);
const at = segmentPosition(seg, 1, 1234);
assert.ok(Cesium.Cartesian3.distance(at, fix) < 0.005);
assert.equal(segmentCourse(seg, 1, 99), 271);
});
test('a lerp matches Cesium.Cartesian3.lerp between two fixes and holds at both ends', () => {
const seg = new Float32Array(SEGMENT_STRIDE);
const to = Cesium.Cartesian3.fromDegrees(-97.0, 32.93, 10_650);
writeLerpSegment(seg, 0, {
from: fix,
to,
t0: 100,
t1: 110,
course0Deg: 40,
courseSlopeDeg: 6,
});
for (const tau of [100, 102.5, 107, 110]) {
const want = Cesium.Cartesian3.lerp(
fix,
to,
(tau - 100) / 10,
new Cesium.Cartesian3(),
);
assert.ok(
Cesium.Cartesian3.distance(segmentPosition(seg, 0, tau), want) < 0.01,
`τ=${tau}`,
);
}
assert.ok(
Cesium.Cartesian3.distance(segmentPosition(seg, 0, 50), fix) < 0.01,
);
assert.ok(
Cesium.Cartesian3.distance(segmentPosition(seg, 0, 500), to) < 0.01,
);
assert.ok(courseGap(segmentCourse(seg, 0, 105), 43) < 1e-4);
});
test('a lerp course reproduces the blended track-and-chord course across the segment', () => {
// _deadReckon: lerpAngle(lerpAngle(trackFrom, trackTo, t), chord, w).
const trackFrom = 350;
const trackTo = 20;
const chord = 10;
const w = 0.6;
const at = (t) => lerpAngleDeg(lerpAngleDeg(trackFrom, trackTo, t), chord, w);
const course0 = at(0);
const slope = norm180(trackTo - trackFrom) * (1 - w);
const seg = new Float32Array(SEGMENT_STRIDE);
const to = Cesium.Cartesian3.fromDegrees(-97.0, 32.93, 10_650);
writeLerpSegment(seg, 0, {
from: fix,
to,
t0: 0,
t1: 10,
course0Deg: course0,
courseSlopeDeg: slope,
});
for (const t of [0, 0.25, 0.5, 0.9, 1])
assert.ok(courseGap(segmentCourse(seg, 0, t * 10), at(t)) < 1e-3, `t=${t}`);
});
test('an arc matches the production turn for straight, slow, standard and tight turns', () => {
const seg = new Float32Array(SEGMENT_STRIDE);
for (const turnRateDps of [0, 0.004, 0.01, 0.5, 3, -3, 12]) {
for (const dt of [0.08, 1, 30, 120, 300]) {
writeArcSegment(seg, 0, {
fix,
t0: 1000,
minDt: -60,
maxDt: 300,
speedMps: 240,
trackDeg: 305,
turnRateDps,
});
const want = productionArc(fix, 240, 305, turnRateDps, dt);
const got = segmentPosition(seg, 0, 1000 + dt);
assert.ok(
Cesium.Cartesian3.distance(got, want.position) < 0.05,
`ω=${turnRateDps}°/s dt=${dt}s off by ${Cesium.Cartesian3.distance(got, want.position)} m`,
);
assert.ok(
courseGap(segmentCourse(seg, 0, 1000 + dt), want.course) < 1e-3,
);
}
}
});
test('an arc extrapolates backward for warm-up and clamps both ends', () => {
const seg = new Float32Array(SEGMENT_STRIDE);
writeArcSegment(seg, 0, {
fix,
t0: 500,
minDt: -60,
maxDt: 0,
speedMps: 200,
trackDeg: 90,
turnRateDps: 1.5,
});
const back = productionArc(fix, 200, 90, 1.5, -20);
assert.ok(
Cesium.Cartesian3.distance(segmentPosition(seg, 0, 480), back.position) <
0.05,
);
const earliest = productionArc(fix, 200, 90, 1.5, -60);
assert.ok(
Cesium.Cartesian3.distance(segmentPosition(seg, 0, 0), earliest.position) <
0.05,
);
assert.ok(
Cesium.Cartesian3.distance(segmentPosition(seg, 0, 900), fix) < 0.01,
);
});
test('the course glide follows limitCourseStep onto a steady target', () => {
const seg = new Float32Array(SEGMENT_STRIDE);
writeHoldSegment(seg, 0, fix, 10);
writeCourseGlide(seg, 0, 300, 50, 24);
let prev = 300;
for (let tau = 50; tau <= 56; tau += 0.08) {
assert.ok(courseGap(segmentCourse(seg, 0, tau), prev) < 1e-3, `τ=${tau}`);
prev = limitCourseStep(prev, 10, 24, 0.08);
}
assert.equal(segmentCourse(seg, 0, 80), 10);
});
test('writing a segment clears the previous one', () => {
const seg = new Float32Array(SEGMENT_STRIDE);
writeArcSegment(seg, 0, {
fix,
t0: 0,
minDt: 0,
maxDt: 300,
speedMps: 250,
trackDeg: 0,
turnRateDps: 3,
});
writeCourseGlide(seg, 0, 90, 0, 30);
writeHoldSegment(seg, 0, fix, 5);
assert.ok(
Cesium.Cartesian3.distance(segmentPosition(seg, 0, 100), fix) < 0.005,
);
assert.equal(segmentCourse(seg, 0, 0), 5);
});
test('the course glide never outruns the rate while its target turns', () => {
const seg = new Float32Array(SEGMENT_STRIDE);
writeArcSegment(seg, 0, {
fix,
t0: 0,
minDt: 0,
maxDt: 300,
speedMps: 200,
trackDeg: 90,
turnRateDps: 3,
});
writeCourseGlide(seg, 0, 60, 0, 24);
let prev = 60;
for (let tau = 0; tau <= 4; tau += 0.01) {
assert.ok(
courseGap(segmentCourse(seg, 0, tau), prev) < 0.05,
`τ=${tau.toFixed(2)}`,
);
prev = limitCourseStep(prev, 90 + 3 * (tau + 0.01), 24, 0.01);
}
assert.ok(
courseGap(segmentCourse(seg, 0, 1), 84) < 0.05,
'24° in the first second, not 27°',
);
assert.ok(
courseGap(segmentCourse(seg, 0, 10), 120) < 1e-3,
'then it follows the turn',
);
});
+468
View File
@@ -0,0 +1,468 @@
/**
* @module iconSet
* @description A set of icons Core draws, held as typed-array columns: the only copy of
* what was pushed. Each row is one icon, keyed by a string id and carrying an opaque
* `meta` that comes back on picks. A row moves along its segment ({@link module:iconSegment})
* on the renderer's clock, so the CPU writes a row only when its motion or style changes.
*
* Rows are packed: removing one moves the last row into its place, so the renderer
* draws `count` instances and the CPU walks rows by index. Writes mark rows dirty per
* column ({@link DirtyRows}) for the renderer to upload.
*
* Segment times are float32 seconds since the set's epoch. The renderer calls
* {@link rebase} once a frame so they stay small (sub-millisecond steps) however
* long the session runs.
*/
import * as Cesium from 'cesium';
import {
SEGMENT_STRIDE,
segmentCourse,
segmentPosition,
shiftSegmentTimes,
} from './iconSegment.js';
/** Floats per row of style: size, scale, alpha, atlas slots, flags, focus, near/far scaler. */
export const STYLE_STRIDE = 12;
const WIDTH = 0;
const HEIGHT = 1;
const SCALE = 2;
const ALPHA = 3;
const IMAGE_FAR = 4;
const IMAGE_NEAR = 5;
const FLAGS = 6;
const FOCUS = 7;
const NEAR_DISTANCE = 8;
const NEAR_VALUE = 9;
const FAR_DISTANCE = 10;
const FAR_VALUE = 11;
/** Row flags, combined in the style's flags float. */
export const ICON_SHOWN = 1;
/** Rotate the icon along its course on screen (otherwise upright). */
export const ICON_ROTATES = 2;
/** Fade and shrink toward the globe's limb (aircraft recession). */
export const ICON_RECEDES = 4;
/** Lift the row 12 m along the normal before the horizon test (fixes near the ground). */
export const ICON_CULL_LIFT = 8;
/** Move the epoch forward once render times pass this many seconds. */
const REBASE_AFTER_SEC = 1800;
/**
* Rows written since the last upload, for one column. Uploads go out as runs of
* nearby rows, so a removal near the top and a write near the bottom send two
* small runs rather than the whole column.
*/
export class DirtyRows {
constructor(capacity) {
this._marked = new Uint8Array(capacity);
this._rows = [];
this._all = false;
}
mark(row) {
if (this._all || this._marked[row]) return;
this._marked[row] = 1;
this._rows.push(row);
}
markAll() {
this._all = true;
}
get empty() {
return !this._all && this._rows.length === 0;
}
/**
* Coalesced `[from, to]` runs of dirty rows below `count`, merging rows closer
* than `gap`; everything when marked all or when most rows are dirty.
* @param {number} count
* @param {number} [gap]
* @returns {Array<[number, number]>}
*/
runs(count, gap = 32) {
if (count === 0) return [];
if (this._all || this._rows.length * 4 > count) return [[0, count - 1]];
const rows = this._rows.filter((row) => row < count).sort((a, b) => a - b);
const runs = [];
for (const row of rows) {
const last = runs[runs.length - 1];
if (last && row - last[1] <= gap) last[1] = row;
else runs.push([row, row]);
}
return runs;
}
clear() {
for (const row of this._rows) this._marked[row] = 0;
this._rows.length = 0;
this._all = false;
}
grow(capacity) {
const marked = new Uint8Array(capacity);
marked.set(this._marked);
this._marked = marked;
this.markAll();
}
}
/**
* @param {{ capacity?: number, epoch?: Cesium.JulianDate }} [options]
*/
export function createIconSet({ capacity = 1024, epoch } = {}) {
capacity = Math.max(1, capacity);
const rowOfId = new Map();
let ids = [];
let metas = [];
let handles = [];
let segments = new Float32Array(capacity * SEGMENT_STRIDE);
let style = new Float32Array(capacity * STYLE_STRIDE);
let colors = new Uint8Array(capacity * 4);
// CPU only: until when (seconds since the epoch) the row's segment holds.
let expires = new Float64Array(capacity);
let count = 0;
const epochTime = Cesium.JulianDate.clone(epoch ?? Cesium.JulianDate.now());
const set = {
/** Bumped when the columns are reallocated; the renderer rebuilds its buffers. */
generation: 0,
dirty: {
segments: new DirtyRows(capacity),
style: new DirtyRows(capacity),
colors: new DirtyRows(capacity),
},
show: true,
/** Called with a row's renderer handle when the row goes away. */
onRelease: null,
get count() {
return count;
},
get capacity() {
return capacity;
},
get segments() {
return segments;
},
get style() {
return style;
},
get colors() {
return colors;
},
get epoch() {
return epochTime;
},
/** Seconds since the set's epoch: the clock segments are written in. */
seconds(julianDate) {
return Cesium.JulianDate.secondsDifference(julianDate, epochTime);
},
/**
* Move the epoch to `julianDate` once it is more than half an hour ahead,
* shifting every stored time. Call between frames, never while a caller holds
* times in the old base.
* @returns {boolean} Whether it moved.
*/
rebase(julianDate) {
const shift = set.seconds(julianDate);
if (shift < REBASE_AFTER_SEC) return false;
for (let row = 0; row < count; row++) {
shiftSegmentTimes(segments, row, -shift);
expires[row] -= shift;
}
Cesium.JulianDate.clone(julianDate, epochTime);
set.dirty.segments.markAll();
return true;
},
has(id) {
return rowOfId.has(id);
},
row(id) {
return rowOfId.get(id) ?? -1;
},
idAt(row) {
return ids[row];
},
metaAt(row) {
return metas[row];
},
handleAt(row) {
return handles[row];
},
setHandle(row, handle) {
handles[row] = handle;
},
/**
* Add a row (hidden, no image) or return the existing row for `id`.
* @param {string} id
* @param {*} [meta]
* @returns {number} The row.
*/
add(id, meta) {
const existing = rowOfId.get(id);
if (existing !== undefined) {
metas[existing] = meta;
return existing;
}
if (count === capacity) grow(capacity * 2);
const row = count++;
rowOfId.set(id, row);
ids[row] = id;
metas[row] = meta;
handles[row] = undefined;
segments.fill(0, row * SEGMENT_STRIDE, (row + 1) * SEGMENT_STRIDE);
style.fill(0, row * STYLE_STRIDE, (row + 1) * STYLE_STRIDE);
const o = row * STYLE_STRIDE;
style[o + SCALE] = 1;
style[o + ALPHA] = 1;
style[o + FOCUS] = 1;
style[o + IMAGE_FAR] = -1;
style[o + IMAGE_NEAR] = -1;
colors.fill(255, row * 4, row * 4 + 4);
expires[row] = -Infinity;
markRow(row);
return row;
},
/** Remove `id`'s row; the last row moves into its place. */
remove(id) {
const row = rowOfId.get(id);
if (row === undefined) return false;
rowOfId.delete(id);
const handle = handles[row];
const last = --count;
if (row !== last) {
segments.copyWithin(
row * SEGMENT_STRIDE,
last * SEGMENT_STRIDE,
(last + 1) * SEGMENT_STRIDE,
);
style.copyWithin(
row * STYLE_STRIDE,
last * STYLE_STRIDE,
(last + 1) * STYLE_STRIDE,
);
colors.copyWithin(row * 4, last * 4, last * 4 + 4);
expires[row] = expires[last];
ids[row] = ids[last];
metas[row] = metas[last];
handles[row] = handles[last];
rowOfId.set(ids[row], row);
markRow(row);
}
ids[last] = undefined;
metas[last] = undefined;
handles[last] = undefined;
if (handle !== undefined) set.onRelease?.(handle);
return true;
},
clear() {
for (let row = 0; row < count; row++)
if (handles[row] !== undefined) set.onRelease?.(handles[row]);
rowOfId.clear();
ids = [];
metas = [];
handles = [];
count = 0;
},
/** Mark a row's segment written (after a write*Segment call on `segments`). */
segmentWritten(row, expiresAt = Infinity) {
expires[row] = expiresAt;
set.dirty.segments.mark(row);
},
/** Until when (seconds since the epoch) the row's segment holds. */
expires(row) {
return expires[row];
},
setShown(row, shown) {
setFlag(row, ICON_SHOWN, shown);
},
shown(row) {
return (style[row * STYLE_STRIDE + FLAGS] & ICON_SHOWN) !== 0;
},
setFlag(row, flag, on) {
setFlag(row, flag, on);
},
flags(row) {
return style[row * STYLE_STRIDE + FLAGS];
},
setSize(row, width, height) {
const o = row * STYLE_STRIDE;
if (
style[o + WIDTH] === Math.fround(width) &&
style[o + HEIGHT] === Math.fround(height)
)
return;
style[o + WIDTH] = width;
style[o + HEIGHT] = height;
set.dirty.style.mark(row);
},
width(row) {
return style[row * STYLE_STRIDE + WIDTH];
},
height(row) {
return style[row * STYLE_STRIDE + HEIGHT];
},
setScale(row, scale) {
writeStyle(row, SCALE, scale);
},
scale(row) {
return style[row * STYLE_STRIDE + SCALE];
},
/** The row's own alpha; the renderer multiplies in recession and focus. */
setAlpha(row, alpha) {
writeStyle(row, ALPHA, alpha);
},
alpha(row) {
return style[row * STYLE_STRIDE + ALPHA];
},
/** Focus de-emphasis factor, 1 when not de-emphasised. */
setFocus(row, factor) {
writeStyle(row, FOCUS, factor);
},
focus(row) {
return style[row * STYLE_STRIDE + FOCUS];
},
/** Atlas slots: `far` for small on-screen sizes, `near` once the icon is large. */
setImage(row, far, near = far) {
const o = row * STYLE_STRIDE;
if (style[o + IMAGE_FAR] === far && style[o + IMAGE_NEAR] === near)
return;
style[o + IMAGE_FAR] = far;
style[o + IMAGE_NEAR] = near;
set.dirty.style.mark(row);
},
imageFar(row) {
return style[row * STYLE_STRIDE + IMAGE_FAR];
},
imageNear(row) {
return style[row * STYLE_STRIDE + IMAGE_NEAR];
},
/** Size by camera distance, as Cesium's NearFarScalar (unset: 1 everywhere). */
setScaleByDistance(row, scalar) {
const o = row * STYLE_STRIDE;
const near = scalar?.near ?? 0;
const nearValue = scalar?.nearValue ?? 1;
const far = scalar?.far ?? 0;
const farValue = scalar?.farValue ?? 1;
if (
style[o + NEAR_DISTANCE] === Math.fround(near) &&
style[o + NEAR_VALUE] === Math.fround(nearValue) &&
style[o + FAR_DISTANCE] === Math.fround(far) &&
style[o + FAR_VALUE] === Math.fround(farValue)
)
return;
style[o + NEAR_DISTANCE] = near;
style[o + NEAR_VALUE] = nearValue;
style[o + FAR_DISTANCE] = far;
style[o + FAR_VALUE] = farValue;
set.dirty.style.mark(row);
},
scaleByDistance(row, result = new Cesium.NearFarScalar()) {
const o = row * STYLE_STRIDE;
result.near = style[o + NEAR_DISTANCE];
result.nearValue = style[o + NEAR_VALUE];
result.far = style[o + FAR_DISTANCE];
result.farValue = style[o + FAR_VALUE];
return result;
},
/** Tint as a Cesium.Color; its alpha multiplies the row's own alpha. */
setColor(row, color) {
const o = row * 4;
const r = Cesium.Color.floatToByte(color.red);
const g = Cesium.Color.floatToByte(color.green);
const b = Cesium.Color.floatToByte(color.blue);
const a = Cesium.Color.floatToByte(color.alpha);
if (
colors[o] === r &&
colors[o + 1] === g &&
colors[o + 2] === b &&
colors[o + 3] === a
)
return;
colors[o] = r;
colors[o + 1] = g;
colors[o + 2] = b;
colors[o + 3] = a;
set.dirty.colors.mark(row);
},
color(row, result = new Cesium.Color()) {
const o = row * 4;
return Cesium.Color.fromBytes(
colors[o],
colors[o + 1],
colors[o + 2],
colors[o + 3],
result,
);
},
/** Where the row is at `tau` (seconds since the epoch). */
position(row, tau, result) {
return segmentPosition(segments, row, tau, result);
},
/** The row's displayed course at `tau`, degrees clockwise from north. */
course(row, tau) {
return segmentCourse(segments, row, tau);
},
};
function markRow(row) {
set.dirty.segments.mark(row);
set.dirty.style.mark(row);
set.dirty.colors.mark(row);
}
function writeStyle(row, field, value) {
const o = row * STYLE_STRIDE + field;
if (style[o] === Math.fround(value)) return;
style[o] = value;
set.dirty.style.mark(row);
}
function setFlag(row, flag, on) {
const o = row * STYLE_STRIDE + FLAGS;
const flags = style[o];
const next = on ? flags | flag : flags & ~flag;
if (next === flags) return;
style[o] = next;
set.dirty.style.mark(row);
}
function grow(next) {
const s = new Float32Array(next * SEGMENT_STRIDE);
s.set(segments);
const st = new Float32Array(next * STYLE_STRIDE);
st.set(style);
const c = new Uint8Array(next * 4);
c.set(colors);
const e = new Float64Array(next);
e.set(expires);
segments = s;
style = st;
colors = c;
expires = e;
capacity = next;
set.generation++;
for (const rows of Object.values(set.dirty)) rows.grow(next);
}
return set;
}
+227
View File
@@ -0,0 +1,227 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
createIconSet,
DirtyRows,
ICON_ROTATES,
ICON_SHOWN,
} from './iconSet.js';
import {
writeArcSegment,
writeCourseGlide,
writeHoldSegment,
} from './iconSegment.js';
function place(set, id, lon, course = 0) {
const row = set.add(id, { id });
writeHoldSegment(
set.segments,
row,
Cesium.Cartesian3.fromDegrees(lon, 10, 1000),
course,
);
set.segmentWritten(row);
return row;
}
function settle(set) {
for (const rows of Object.values(set.dirty)) rows.clear();
}
test('rows are added once per id and carry their meta', () => {
const set = createIconSet({ capacity: 4 });
assert.equal(set.add('a', 1), 0);
assert.equal(set.add('b', 2), 1);
assert.equal(set.add('a', 3), 0, 'same id, same row');
assert.equal(set.metaAt(0), 3);
assert.equal(set.count, 2);
assert.equal(set.row('missing'), -1);
});
test('a new row starts hidden, unscaled, opaque white and unfocused', () => {
const set = createIconSet();
const row = set.add('a');
assert.equal(set.shown(row), false);
assert.equal(set.scale(row), 1);
assert.equal(set.alpha(row), 1);
assert.equal(set.focus(row), 1);
assert.ok(Cesium.Color.equals(set.color(row), Cesium.Color.WHITE));
});
test('removing a row moves the last row into its place with all its columns', () => {
const set = createIconSet({ capacity: 4 });
place(set, 'a', 1);
place(set, 'b', 2);
const c = place(set, 'c', 3, 120);
set.setShown(c, true);
set.setFlag(c, ICON_ROTATES, true);
set.setSize(c, 20, 24);
set.setScale(c, 1.3);
set.setColor(c, Cesium.Color.fromCssColorString('#FFB800'));
set.setHandle(c, 'handle-c');
settle(set);
assert.ok(set.remove('a'));
assert.equal(set.count, 2);
assert.equal(set.row('c'), 0);
assert.equal(set.idAt(0), 'c');
assert.deepEqual(set.metaAt(0), { id: 'c' });
assert.equal(set.handleAt(0), 'handle-c');
assert.equal(set.flags(0), ICON_SHOWN | ICON_ROTATES);
assert.equal(set.width(0), 20);
assert.equal(set.height(0), 24);
assert.ok(Math.abs(set.scale(0) - 1.3) < 1e-6);
assert.deepEqual([...set.colors.subarray(0, 4)], [255, 184, 0, 255]);
const at = set.position(0, 0);
assert.ok(
Cesium.Cartesian3.distance(at, Cesium.Cartesian3.fromDegrees(3, 10, 1000)) <
0.01,
);
assert.equal(set.course(0, 0), 120);
assert.equal(
set.dirty.segments.runs(set.count)[0][0],
0,
'the moved row uploads',
);
assert.equal(set.remove('a'), false);
});
test('removing the last row moves nothing', () => {
const set = createIconSet();
place(set, 'a', 1);
place(set, 'b', 2);
settle(set);
set.remove('b');
assert.equal(set.count, 1);
assert.ok(set.dirty.segments.empty);
});
test('a released row hands its renderer handle back', () => {
const set = createIconSet();
const released = [];
set.onRelease = (handle) => released.push(handle);
set.setHandle(set.add('a'), 'ha');
set.setHandle(set.add('b'), 'hb');
set.remove('a');
set.clear();
assert.deepEqual(released, ['ha', 'hb']);
assert.equal(set.count, 0);
assert.equal(set.has('b'), false);
});
test('unchanged writes leave the columns clean', () => {
const set = createIconSet();
const row = set.add('a');
set.setSize(row, 20, 20);
set.setScale(row, 0.8);
set.setShown(row, true);
set.setColor(row, Cesium.Color.CYAN);
set.setScaleByDistance(row, new Cesium.NearFarScalar(1000, 3, 8e6, 0.5));
settle(set);
set.setSize(row, 20, 20);
set.setScale(row, 0.8);
set.setShown(row, true);
set.setColor(row, Cesium.Color.CYAN);
set.setScaleByDistance(row, new Cesium.NearFarScalar(1000, 3, 8e6, 0.5));
assert.ok(set.dirty.style.empty);
assert.ok(set.dirty.colors.empty);
set.setScale(row, 0.9);
assert.deepEqual(set.dirty.style.runs(set.count), [[0, 0]]);
});
test('the set grows past its capacity and keeps every row', () => {
const set = createIconSet({ capacity: 2 });
for (let i = 0; i < 5; i++) place(set, `r${i}`, i, i * 10);
assert.equal(set.count, 5);
assert.ok(set.capacity >= 5);
assert.equal(set.generation, 2);
for (let i = 0; i < 5; i++)
assert.equal(set.course(set.row(`r${i}`), 0), i * 10);
});
test('times are seconds since the epoch', () => {
const epoch = Cesium.JulianDate.fromIso8601('2026-11-03T12:00:00Z');
const set = createIconSet({ epoch });
assert.equal(
set.seconds(
Cesium.JulianDate.addSeconds(epoch, 42.5, new Cesium.JulianDate()),
),
42.5,
);
});
test('dirty rows go out as coalesced runs, or whole when most rows changed', () => {
const rows = new DirtyRows(1000);
for (const row of [900, 3, 5, 40, 901, 3]) rows.mark(row);
assert.deepEqual(rows.runs(1000), [
[3, 5],
[40, 40],
[900, 901],
]);
assert.deepEqual(rows.runs(1000, 64), [
[3, 40],
[900, 901],
]);
assert.deepEqual(
rows.runs(500),
[
[3, 5],
[40, 40],
],
'rows past the count are dropped',
);
rows.clear();
assert.ok(rows.empty);
for (let row = 0; row < 300; row++) rows.mark(row * 3);
assert.deepEqual(rows.runs(1000), [[0, 999]]);
});
test('a new row has no image until one is set', () => {
const set = createIconSet();
const row = set.add('a');
assert.equal(set.imageFar(row), -1);
assert.equal(set.imageNear(row), -1);
set.setImage(row, 3, 4);
assert.equal(set.imageNear(row), 4);
});
test('a set of capacity zero still grows', () => {
const set = createIconSet({ capacity: 0 });
set.add('a');
set.add('b');
assert.equal(set.count, 2);
});
test('rebasing moves the epoch without moving any row', () => {
const epoch = Cesium.JulianDate.fromIso8601('2026-11-03T12:00:00Z');
const set = createIconSet({ epoch });
const fix = Cesium.Cartesian3.fromDegrees(-97, 33, 10_000);
const row = set.add('a');
writeArcSegment(set.segments, row, {
fix,
t0: 5000,
minDt: 0,
maxDt: 300,
speedMps: 240,
trackDeg: 80,
turnRateDps: 2,
});
writeCourseGlide(set.segments, row, 30, 5000, 20);
set.segmentWritten(row, 5100);
const tau = 5060;
const before = set.position(row, tau);
const courseBefore = set.course(row, tau);
settle(set);
const later = (s) =>
Cesium.JulianDate.addSeconds(epoch, s, new Cesium.JulianDate());
assert.equal(set.rebase(later(1000)), false, 'under half an hour: stays');
assert.equal(set.rebase(later(4000)), true);
assert.equal(set.seconds(later(4000)), 0);
const after = set.position(row, tau - 4000);
assert.ok(Cesium.Cartesian3.distance(before, after) < 0.01);
assert.ok(Math.abs(set.course(row, tau - 4000) - courseBefore) < 1e-3);
assert.equal(set.expires(row), 1100);
assert.deepEqual(set.dirty.segments.runs(set.count), [[0, 0]]);
});
+584
View File
@@ -0,0 +1,584 @@
/**
* @module iconSetPrimitive
* @description Draws an {@link module:iconSet} in a Cesium scene: one instanced quad per
* row, moved along its segment on the GPU at the set's render time. This replaces a
* BillboardCollection for large moving fleets, so the CPU writes rows only when their
* motion or style changes and uploads just the rows it wrote.
*
* It reproduces the billboard behaviour the aircraft layers used: pixel size by
* `width × scale × NearFarScalar(distance)`, screen rotation along the course
* (`alignedAxis` zero), depth test disabled at any distance (drawn on the near plane),
* opaque and translucent passes, horizon culling, and per-row picks whose object is
* `{ id, meta, primitive }` (`primitive.show` hides that one row, as drillPick expects).
* Rows flagged to recede also take the aircraft limb recession, composed with focus.
*
* This module is the one place the icon sets touch Cesium's renderer internals.
*/
import * as Cesium from 'cesium';
import { ICON_SEGMENT_GLSL, SEGMENT_STRIDE } from './iconSegment.js';
import { ICON_CULL_LIFT, STYLE_STRIDE } from './iconSet.js';
import { iconAtlas, MAX_ICON_SLOTS } from './iconAtlas.js';
import {
cameraLimbDistanceM,
getAircraftRecessionParams,
} from './aircraftRecession.js';
/** On-screen device pixels above which a row draws its near (large) image. */
const NEAR_IMAGE_PX = 76;
/** Rows lifted this far before the horizon test when flagged (low fixes). */
const CULL_LIFT_M = 12;
const ATTRIBUTE_LOCATIONS = Object.freeze({
corner: 0,
seg0: 1,
seg1: 2,
seg2: 3,
seg3: 4,
seg4: 5,
seg5: 6,
style0: 7,
style1: 8,
style2: 9,
color: 10,
pickColor: 11,
});
const VERTEX_SHADER = /* glsl */ `
in vec2 corner;
in vec4 seg0;
in vec4 seg1;
in vec4 seg2;
in vec4 seg3;
in vec4 seg4;
in vec4 seg5;
in vec4 style0; // width, height, scale, alpha
in vec4 style1; // far image, near image, flags, focus
in vec4 style2; // scale by distance (near, near value, far, far value)
in vec4 color;
in vec4 pickColor;
uniform float u_tau;
uniform vec3 u_cameraPosition;
uniform vec4 u_horizon; // camera in ellipsoid-scaled space, squared scaled limb distance
uniform vec3 u_cameraRight;
uniform vec3 u_cameraUp;
uniform vec4 u_recession; // limb distance, start ratio, strength, enabled
uniform vec3 u_recessionFloors; // scale, alpha, combined alpha
uniform vec4 u_slots[${MAX_ICON_SLOTS}];
out vec2 v_uv;
out vec4 v_color;
out vec4 v_pickColor;
// The row's anchor in clip space before screen offsets, for effects that wrap
// main() (cyber sonar) and must not change geometry or picks.
vec4 iconAnchorClip = vec4(0.0, 0.0, 0.0, -1.0);
${ICON_SEGMENT_GLSL}
bool iconFlag(float flags, float bit) {
return mod(floor(flags / bit), 2.0) == 1.0;
}
bool occludedByEllipsoid(vec3 world) {
vec3 cv = u_horizon.xyz;
float vh2 = u_horizon.w;
vec3 vt = world * czm_ellipsoidInverseRadii - cv;
float vtDotVc = -dot(vt, cv);
return vh2 < 0.0
? vtDotVc > 0.0
: vtDotVc > vh2 && vtDotVc * vtDotVc / dot(vt, vt) > vh2;
}
void main() {
v_pickColor = pickColor;
v_uv = vec2(0.0);
v_color = vec4(0.0);
gl_Position = vec4(0.0, 0.0, 2.0, 1.0); // outside the clip volume: not drawn
float flags = style1.z;
if (!iconFlag(flags, 1.0)) return;
vec3 rte = iconSegmentPosition(seg0, seg1, seg2, seg3, seg4, u_tau);
vec3 world = u_cameraPosition + rte;
vec3 cullPoint = iconFlag(flags, ${ICON_CULL_LIFT.toFixed(1)}) ? world + normalize(world) * ${CULL_LIFT_M.toFixed(1)} : world;
if (occludedByEllipsoid(cullPoint)) return;
vec4 positionEC = czm_modelViewRelativeToEye * vec4(rte, 1.0);
if (positionEC.z >= 0.0) return; // behind the camera
iconAnchorClip = czm_projection * positionEC;
float distanceSq = dot(positionEC.xyz, positionEC.xyz);
float distanceScale = style2.z > 0.0 ? czm_nearFarScalar(style2, distanceSq) : 1.0;
float scale = style0.z;
float alpha = style0.w;
float focus = clamp(style1.w, 0.0, 1.0);
if (iconFlag(flags, 4.0) && u_recession.w > 0.0) {
float ratio = sqrt(distanceSq) / u_recession.x;
float t = clamp((ratio - u_recession.y) / (1.0 - u_recession.y), 0.0, 1.0);
float ease = t * t * (3.0 - 2.0 * t) * u_recession.z;
scale *= 1.0 + (u_recessionFloors.x - 1.0) * ease;
alpha *= max(u_recessionFloors.z, focus * (1.0 + (u_recessionFloors.y - 1.0) * ease));
} else if (iconFlag(flags, 4.0)) {
alpha *= max(u_recessionFloors.z, focus); // recession off: the floor still holds
} else {
alpha *= focus;
}
float glyphPx = style0.x * scale * distanceScale * czm_pixelRatio;
float slot = glyphPx > ${NEAR_IMAGE_PX.toFixed(1)} ? style1.y : style1.x;
if (slot < 0.0) return; // no image set
vec4 rect = u_slots[int(slot)];
if (rect.z <= 0.0) return; // image not loaded yet
vec2 halfSize = style0.xy * (scale * distanceScale * 0.5) * corner;
if (iconFlag(flags, 2.0)) {
float course = radians(iconSegmentCourse(seg3, seg4, seg5, u_tau));
vec3 up = czm_geodeticSurfaceNormal(world, vec3(0.0), czm_ellipsoidInverseRadii * czm_ellipsoidInverseRadii);
// East as Cesium's ENU frame builds it; at the poles it uses +y.
vec2 horizontal = vec2(-world.y, world.x);
vec3 east = dot(horizontal, horizontal) > 1.0 ? normalize(vec3(horizontal, 0.0)) : vec3(0.0, 1.0, 0.0);
vec3 north = cross(up, east);
vec3 forward = east * sin(course) + north * cos(course);
float dx = dot(forward, u_cameraRight);
float dy = -dot(forward, u_cameraUp);
// Heading into or out of the screen: no stable direction, stay upright
// (screenProjectedRotation's 0.5 m of a 2 km probe).
if (dx * dx + dy * dy >= 6.25e-8) {
float rotation = atan(-dx, -dy);
float c = cos(rotation);
float s = sin(rotation);
halfSize = mat2(c, s, -s, c) * halfSize;
}
}
positionEC.xy += halfSize * czm_metersPerPixel(positionEC);
gl_Position = czm_projection * positionEC;
#ifdef LOG_DEPTH
czm_vertexLogDepth();
#endif
// Depth test disabled at any distance: draw on the near plane, as a billboard
// with disableDepthTestDistance = Infinity.
float zclip = gl_Position.z / gl_Position.w;
if (zclip >= -1.0 && zclip <= 1.0) {
gl_Position.z = -gl_Position.w;
#ifdef LOG_DEPTH
v_depthFromNearPlusOne = 1.0;
#endif
}
v_uv = mix(rect.xy, rect.zw, vec2(corner.x * 0.5 + 0.5, 0.5 - corner.y * 0.5));
v_color = vec4(color.rgb, color.a * alpha);
}
`;
const FRAGMENT_SHADER = /* glsl */ `
uniform sampler2D u_atlas;
in vec2 v_uv;
in vec4 v_color;
in vec4 v_pickColor;
void main() {
vec4 color = czm_gammaCorrect(texture(u_atlas, v_uv));
color *= czm_gammaCorrect(v_color);
if (color.a < 0.005) discard;
#ifdef OPAQUE
if (color.a < 0.995) discard;
#else
if (color.a >= 0.995) discard;
#endif
out_FragColor = color;
#ifdef LOG_DEPTH
czm_writeLogDepth(v_depthFromNearPlusOne);
#endif
}
`;
const SEGMENT_BYTES = SEGMENT_STRIDE * 4;
const STYLE_BYTES = STYLE_STRIDE * 4;
const EARTH_BOUNDS = new Cesium.BoundingSphere(
Cesium.Cartesian3.ZERO,
6_500_000,
);
/**
* @param {{ set: object, renderTime: () => Cesium.JulianDate }} options `renderTime`
* gives the instant to draw at (the layer's delayed clock).
*/
export class IconSetPrimitive {
constructor({ set, renderTime }) {
this.set = set;
this.renderTime = renderTime;
this.show = true;
this._tau = 0;
this._generation = -1;
this._buffers = null;
this._vertexArray = null;
this._pickColors = null;
this._programs = null;
this._commands = null;
this._texture = null;
this._atlasVersion = -1;
this._slots = Array.from(
{ length: MAX_ICON_SLOTS },
() => new Cesium.Cartesian4(),
);
this._horizon = new Cesium.Cartesian4();
this._cameraPosition = new Cesium.Cartesian3();
this._cameraRight = new Cesium.Cartesian3();
this._cameraUp = new Cesium.Cartesian3();
this._recession = new Cesium.Cartesian4();
this._recessionFloors = new Cesium.Cartesian3();
this._destroyed = false;
set.onRelease = (handle) => handle.pickId.destroy();
const primitive = this;
this._uniforms = {
u_tau: () => primitive._tau,
u_cameraPosition: () => primitive._cameraPosition,
u_horizon: () => primitive._horizon,
u_cameraRight: () => primitive._cameraRight,
u_cameraUp: () => primitive._cameraUp,
u_recession: () => primitive._recession,
u_recessionFloors: () => primitive._recessionFloors,
u_slots: () => primitive._slots,
u_atlas: () => primitive._texture,
};
}
update(frameState) {
const set = this.set;
if (
!this.show ||
!set.show ||
(!frameState.passes.render && !frameState.passes.pick)
)
return;
const context = frameState.context;
this._syncAtlas(context);
if (!this._texture) return;
const renderTime = this.renderTime();
set.rebase(renderTime);
if (this._generation !== set.generation) this._allocate(context);
this._upload(context);
if (set.count === 0) return;
this._tau = set.seconds(renderTime);
this._syncCamera(frameState);
if (!this._programs) this._createCommands(context);
for (const command of this._commands) {
command.instanceCount = set.count;
frameState.commandList.push(command);
}
}
_syncAtlas(context) {
if (iconAtlas.version === this._atlasVersion || !iconAtlas.canvas) return;
this._atlasVersion = iconAtlas.version;
this._texture?.destroy();
this._texture = new Cesium.Texture({
context,
source: iconAtlas.canvas,
flipY: false,
sampler: new Cesium.Sampler({
wrapS: Cesium.TextureWrap.CLAMP_TO_EDGE,
wrapT: Cesium.TextureWrap.CLAMP_TO_EDGE,
minificationFilter: Cesium.TextureMinificationFilter.LINEAR,
magnificationFilter: Cesium.TextureMagnificationFilter.LINEAR,
}),
});
for (let slot = 0; slot < MAX_ICON_SLOTS; slot++)
Cesium.Cartesian4.unpack(iconAtlas.rects, slot * 4, this._slots[slot]);
}
_allocate(context) {
const set = this.set;
this._destroyBuffers();
const capacity = set.capacity;
const usage = Cesium.BufferUsage.DYNAMIC_DRAW;
const buffers = {
corner: Cesium.Buffer.createVertexBuffer({
context,
typedArray: new Float32Array([-1, -1, 1, -1, 1, 1, -1, 1]),
usage: Cesium.BufferUsage.STATIC_DRAW,
}),
index: Cesium.Buffer.createIndexBuffer({
context,
typedArray: new Uint16Array([0, 1, 2, 0, 2, 3]),
usage: Cesium.BufferUsage.STATIC_DRAW,
indexDatatype: Cesium.IndexDatatype.UNSIGNED_SHORT,
}),
segments: Cesium.Buffer.createVertexBuffer({
context,
sizeInBytes: capacity * SEGMENT_BYTES,
usage,
}),
style: Cesium.Buffer.createVertexBuffer({
context,
sizeInBytes: capacity * STYLE_BYTES,
usage,
}),
colors: Cesium.Buffer.createVertexBuffer({
context,
sizeInBytes: capacity * 4,
usage,
}),
picks: Cesium.Buffer.createVertexBuffer({
context,
sizeInBytes: capacity * 4,
usage,
}),
};
const float = Cesium.ComponentDatatype.FLOAT;
const byte = Cesium.ComponentDatatype.UNSIGNED_BYTE;
const attributes = [
{
index: ATTRIBUTE_LOCATIONS.corner,
vertexBuffer: buffers.corner,
componentsPerAttribute: 2,
componentDatatype: float,
},
];
for (let i = 0; i < 6; i++)
attributes.push({
index: ATTRIBUTE_LOCATIONS[`seg${i}`],
vertexBuffer: buffers.segments,
componentsPerAttribute: 4,
componentDatatype: float,
offsetInBytes: i * 16,
strideInBytes: SEGMENT_BYTES,
instanceDivisor: 1,
});
for (let i = 0; i < 3; i++)
attributes.push({
index: ATTRIBUTE_LOCATIONS[`style${i}`],
vertexBuffer: buffers.style,
componentsPerAttribute: 4,
componentDatatype: float,
offsetInBytes: i * 16,
strideInBytes: STYLE_BYTES,
instanceDivisor: 1,
});
for (const [name, buffer] of [
['color', buffers.colors],
['pickColor', buffers.picks],
])
attributes.push({
index: ATTRIBUTE_LOCATIONS[name],
vertexBuffer: buffer,
componentsPerAttribute: 4,
componentDatatype: byte,
normalize: true,
instanceDivisor: 1,
});
this._buffers = buffers;
this._vertexArray = new Cesium.VertexArray({
context,
attributes,
indexBuffer: buffers.index,
});
this._pickColors = new Uint8Array(capacity * 4);
this._generation = set.generation;
for (const rows of Object.values(set.dirty)) rows.markAll();
if (this._commands)
for (const command of this._commands)
command.vertexArray = this._vertexArray;
}
_upload(context) {
const set = this.set;
const { segments, style, colors } = set.dirty;
const count = set.count;
for (const [from, to] of segments.runs(count))
this._buffers.segments.copyFromArrayView(
set.segments.subarray(from * SEGMENT_STRIDE, (to + 1) * SEGMENT_STRIDE),
from * SEGMENT_BYTES,
);
for (const [from, to] of style.runs(count)) {
this._buffers.style.copyFromArrayView(
set.style.subarray(from * STYLE_STRIDE, (to + 1) * STYLE_STRIDE),
from * STYLE_BYTES,
);
// Rows that are new or moved by a removal: (re)write their pick colours.
for (let row = from; row <= to; row++) this._writePick(context, row);
this._buffers.picks.copyFromArrayView(
this._pickColors.subarray(from * 4, (to + 1) * 4),
from * 4,
);
}
for (const [from, to] of colors.runs(count))
this._buffers.colors.copyFromArrayView(
set.colors.subarray(from * 4, (to + 1) * 4),
from * 4,
);
segments.clear();
style.clear();
colors.clear();
}
_writePick(context, row) {
const set = this.set;
let handle = set.handleAt(row);
if (!handle) {
const id = set.idAt(row);
const facade = {
id,
get show() {
const at = set.row(id);
return at >= 0 && set.shown(at);
},
set show(value) {
const at = set.row(id);
if (at >= 0) set.setShown(at, value);
},
};
handle = {
pickId: context.createPickId({
id,
get meta() {
const at = set.row(id);
return at >= 0 ? set.metaAt(at) : undefined;
},
primitive: facade,
}),
};
set.setHandle(row, handle);
}
const color = handle.pickId.color;
const o = row * 4;
this._pickColors[o] = Cesium.Color.floatToByte(color.red);
this._pickColors[o + 1] = Cesium.Color.floatToByte(color.green);
this._pickColors[o + 2] = Cesium.Color.floatToByte(color.blue);
this._pickColors[o + 3] = Cesium.Color.floatToByte(color.alpha);
}
_syncCamera(frameState) {
const camera = frameState.camera;
const radii = Cesium.Ellipsoid.WGS84.oneOverRadii;
const position = camera.positionWC;
Cesium.Cartesian3.clone(position, this._cameraPosition);
Cesium.Cartesian3.clone(camera.rightWC, this._cameraRight);
Cesium.Cartesian3.clone(camera.upWC, this._cameraUp);
const cx = position.x * radii.x;
const cy = position.y * radii.y;
const cz = position.z * radii.z;
Cesium.Cartesian4.fromElements(
cx,
cy,
cz,
cx * cx + cy * cy + cz * cz - 1,
this._horizon,
);
const tuning = getAircraftRecessionParams();
const height = camera.positionCartographic?.height;
const limb = cameraLimbDistanceM(height, tuning.earthRadiusM);
const active =
Number.isFinite(height) &&
height > 0 &&
height < tuning.globeViewBlendEndM &&
limb > 0;
let strength = 0;
if (active) {
const g = Math.min(
1,
Math.max(
0,
(height - tuning.globeViewBlendStartM) /
(tuning.globeViewBlendEndM - tuning.globeViewBlendStartM),
),
);
strength = 1 - g * g * (3 - 2 * g);
}
Cesium.Cartesian4.fromElements(
active ? limb : 1,
tuning.startLimbRatio,
strength,
active ? 1 : 0,
this._recession,
);
Cesium.Cartesian3.fromElements(
tuning.scaleFloor,
tuning.alphaFloor,
tuning.combinedAlphaFloor,
this._recessionFloors,
);
}
_createCommands(context) {
const programFor = (pass) =>
Cesium.ShaderProgram.fromCache({
context,
vertexShaderSource: new Cesium.ShaderSource({
sources: [VERTEX_SHADER],
}),
fragmentShaderSource: new Cesium.ShaderSource({
defines: pass === 'opaque' ? ['OPAQUE'] : ['TRANSLUCENT'],
sources: [FRAGMENT_SHADER],
}),
attributeLocations: ATTRIBUTE_LOCATIONS,
});
this._programs = {
opaque: programFor('opaque'),
translucent: programFor('translucent'),
};
const depthTest = { enabled: true, func: Cesium.DepthFunction.LESS };
const command = (pass, program, renderState) =>
new Cesium.DrawCommand({
owner: this,
pass,
primitiveType: Cesium.PrimitiveType.TRIANGLES,
vertexArray: this._vertexArray,
count: 6,
instanceCount: 0,
shaderProgram: program,
uniformMap: this._uniforms,
renderState,
boundingVolume: EARTH_BOUNDS,
pickId: 'v_pickColor',
});
this._commands = [
command(
Cesium.Pass.OPAQUE,
this._programs.opaque,
Cesium.RenderState.fromCache({ depthTest, depthMask: true }),
),
command(
Cesium.Pass.TRANSLUCENT,
this._programs.translucent,
Cesium.RenderState.fromCache({
depthTest,
depthMask: false,
blending: Cesium.BlendingState.ALPHA_BLEND,
}),
),
];
}
_destroyBuffers() {
this._vertexArray?.destroy(); // destroys its buffers too
this._vertexArray = null;
this._buffers = null;
}
isDestroyed() {
return this._destroyed;
}
destroy() {
this._destroyBuffers();
this._programs?.opaque.destroy();
this._programs?.translucent.destroy();
this._texture?.destroy();
// The set outlives its renderer: release only the pick ids this one made.
const set = this.set;
for (let row = 0; row < set.count; row++) {
set.handleAt(row)?.pickId.destroy();
set.setHandle(row, undefined);
}
set.onRelease = null;
for (const rows of Object.values(set.dirty)) rows.markAll();
this._destroyed = true;
return Cesium.destroyObject(this);
}
}