diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a76faec8d..f3467710f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/cyberSonarGpu.js b/src/cyberSonarGpu.js index bb4aff4d6..9689d534b 100644 --- a/src/cyberSonarGpu.js +++ b/src/cyberSonarGpu.js @@ -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 diff --git a/src/data/iconAtlas.js b/src/data/iconAtlas.js new file mode 100644 index 000000000..8e08c2532 --- /dev/null +++ b/src/data/iconAtlas.js @@ -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(); diff --git a/src/data/iconSegment.js b/src/data/iconSegment.js new file mode 100644 index 000000000..c23addfb4 --- /dev/null +++ b/src/data/iconSegment.js @@ -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; +} +`; diff --git a/src/data/iconSegment.test.mjs b/src/data/iconSegment.test.mjs new file mode 100644 index 000000000..6384d91aa --- /dev/null +++ b/src/data/iconSegment.test.mjs @@ -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', + ); +}); diff --git a/src/data/iconSet.js b/src/data/iconSet.js new file mode 100644 index 000000000..6ebf55672 --- /dev/null +++ b/src/data/iconSet.js @@ -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; +} diff --git a/src/data/iconSet.test.mjs b/src/data/iconSet.test.mjs new file mode 100644 index 000000000..934b83e53 --- /dev/null +++ b/src/data/iconSet.test.mjs @@ -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]]); +}); diff --git a/src/data/iconSetPrimitive.js b/src/data/iconSetPrimitive.js new file mode 100644 index 000000000..f832cfdf7 --- /dev/null +++ b/src/data/iconSetPrimitive.js @@ -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); + } +}