mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
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:
1 parent
7204d66f0b
commit
5f36eb0e0d
8 files changed
+1982
-5
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user