// A position property that interpolates over a uniform grid held in a flat // typed array. // // It exists because evaluating a position is the single largest per-frame cost in // a large scene, and almost none of that cost is the interpolation itself. // Cesium's `SampledPositionProperty` supports irregular sample times, so every // read does a bracket search, JulianDate arithmetic, a copy into interpolation // scratch tables and a Cartesian3 allocation. Our samples are not irregular — // they sit on a grid anchored to the element set's epoch (see sgp4Worker) — so // finding the bracket is one `Math.floor` and the rest is arithmetic on a // Float64Array. // // Measured at 5,000 satellites drawing points, swapping only `entity.position` on // one live scene and putting it back again — an A/B/A, because a whole benchmark // sweep could not resolve this: its own drift check fired at ±23-49% on `cpuMs`, // and build times for identical work varied by 3x between runs. // // this Cesium this again // dataSourceDisplay.update 6.10 12.02 5.03 ms // fps 99.9 71.1 96.8 // // Across separate matched sweeps the propagation-and-update half of the frame, // `tickMs` at 5,000, went 11.08 -> 4.91 ms; that one is worth quoting because it // reproduced (11.08/11.44/9.31 before, 4.91/4.63 after) far outside the noise the // other columns carry. // // It also *saves* memory, which was not the point but is the larger effect: 25.7 // against 38.7 KB a satellite for everything a satellite owns, some 74 MB less at // 5,000. A `SampledPositionProperty` keeps a `JulianDate` object per sample and // there are 241 of them per satellite; deriving the times from the anchor instead // means the samples are three doubles each and nothing else. See SampledTrajectory, // which now builds the irregular-capable properties on demand from this one. // // Accuracy is checked against an independent SGP4 — python-sgp4, Vallado's // reference implementation — given the same element sets and the same instants, // comparing the two quantities a rotation about the Earth's axis leaves alone // (radius and the axial component), so no frame code of the checker's own enters // the comparison. Worst case over the regimes in the catalog, against what Cesium's // own degree-5 interpolation produces from the same samples: // // Starlink 94 min 4.7 m (Cesium: 4.3 m) // Meteosat 1436 min 0.1 m (Cesium: 0.0 m) // MMS 1 5114 min 37.9 m (Cesium: 38.5 m) // // The stencil is six for that last row and no other reason; see STENCIL, which // also says why re-checking this has to reach past the head of the catalog. // // Note what this is *not* an optimisation of. Lowering Cesium's // `interpolationDegree` from 5 to 1 saves 0.88 ms of 12 (7%) and costs 2.5 km of // accuracy; degree 3 costs 1 m and saves 0.4 ms. The degree was never the // problem, which is why this replaces the machinery rather than tuning it. import { Cartesian3, Event, JulianDate, PositionProperty, ReferenceFrame } from "@cesium/engine"; /** * `PositionProperty.convertToReferenceFrame` is a real static on the runtime class * but is missing from the published typings, so it is reached through a cast in * one place rather than with an `any` at the call site. */ const convertToReferenceFrame = ( PositionProperty as unknown as { convertToReferenceFrame(time: JulianDate, value: Cartesian3, inputFrame: ReferenceFrame, outputFrame: ReferenceFrame, result: Cartesian3): Cartesian3 | undefined; } ).convertToReferenceFrame; /** * Samples the interpolation reads. Six gives a quintic, matching the degree Cesium's * `SampledPositionProperty` was configured with. * * Four — a cubic — was enough for every orbit anyone had looked at, and wrong for * the ones nobody had. Truncation error goes as `h^(n+1)`, so dropping from degree 5 * to 3 costs a factor of `h^2`, and `h` here is a fixed fraction of the *period*: * 120 samples an orbit is 45 s for the ISS and 43 minutes for a magnetospheric * orbit. Checked against the reference SGP4 on MMS 1 (period 3.5 days, highly * eccentric), a cubic was 3.7 km out where the quintic is 13 m — the error is almost * entirely radial, which is what a low-order fit through a fast-changing radius * looks like. LEO was unaffected either way, which is why sampling only Starlink * missed it. */ const STENCIL = 6; /** Spare capacity a grow leaves behind. See `#ensure`. */ const GROWTH_HEADROOM = 1.25; /** * Positions on a uniform time grid, readable as a Cesium position property. * * The grid is defined by an anchor and a step, and a sample's index is its * position on that grid — so two batches of samples computed at different times * describe the same instants as long as they agree on the anchor, which is what * makes appending safe without any matching of times. */ export class GridPositionProperty { readonly #frame: ReferenceFrame; /** * Underscore-prefixed, and public, because Cesium reads it that way. * * `VelocityVectorProperty`'s position setter subscribes via * `value._definitionChanged.addEventListener` rather than through the * `definitionChanged` getter its own interface documents, so a property that * only has the getter throws the moment an entity is given a * `VelocityOrientationProperty` over it. Every Cesium property happens to keep * the event in a field of this name, which is why nothing upstream notices. */ readonly _definitionChanged = new Event(); #anchorEpochMs = 0; #stepSeconds = 0; /** Grid index of the first sample held. */ #firstIndex = 0; #count = 0; /** x, y, z per sample, in `#frame`. Grown as needed, never shrunk. */ #positions = new Float64Array(0); /** The anchor as a JulianDate, made once: every read would otherwise rebuild it. */ #anchor = new JulianDate(); constructor(referenceFrame: ReferenceFrame = ReferenceFrame.FIXED) { this.#frame = referenceFrame; } get referenceFrame(): ReferenceFrame { return this.#frame; } get isConstant(): boolean { return this.#count === 0; } get definitionChanged(): Event { return this._definitionChanged; } get length(): number { return this.#count; } get stepSeconds(): number { return this.#stepSeconds; } /** Grid index of the first and last samples held, for the caller's own bookkeeping. */ get firstIndex(): number { return this.#firstIndex; } /** The instant of a grid index. Derived from the anchor, never from a stored time. */ timeAt(gridIndex: number, result?: JulianDate): JulianDate { return JulianDate.addSeconds(this.#anchor, gridIndex * this.#stepSeconds, result ?? new JulianDate()); } /** * Adopt a grid. Discards anything held, because a different anchor or step means * the indices no longer mean the same instants. */ reset(anchorEpochMs: number, stepSeconds: number): void { this.#anchorEpochMs = anchorEpochMs; this.#stepSeconds = stepSeconds; this.#anchor = JulianDate.fromDate(new Date(anchorEpochMs)); this.#firstIndex = 0; this.#count = 0; this._definitionChanged.raiseEvent(this); } /** Drop everything and release the buffer, for a grid that is being abandoned. */ clear(): void { this.#firstIndex = 0; this.#count = 0; this.#stepSeconds = 0; this.#positions = new Float64Array(0); this._definitionChanged.raiseEvent(this); } isOnGrid(anchorEpochMs: number, stepSeconds: number): boolean { return this.#count > 0 && this.#anchorEpochMs === anchorEpochMs && this.#stepSeconds === stepSeconds; } /** * Add samples starting at `gridIndex`. * * Contiguity is the one requirement, and it is checked rather than assumed: a * uniform grid is the whole basis of the fast read, so a batch that would leave * a hole is rejected instead of silently shifting every sample after it. The * caller — which knows what it asked for — treats that as a failed fill. */ add(gridIndex: number, xyz: Float64Array): boolean { const incoming = Math.floor(xyz.length / 3); if (incoming === 0) { return true; } if (this.#count === 0) { this.#firstIndex = gridIndex; this.#ensure(incoming); this.#positions.set(xyz.subarray(0, incoming * 3), 0); this.#count = incoming; this._definitionChanged.raiseEvent(this); return true; } const end = this.#firstIndex + this.#count; if (gridIndex > end || gridIndex + incoming < this.#firstIndex) { // Disjoint from what is held; nothing sensible to splice. return false; } if (gridIndex >= this.#firstIndex && gridIndex + incoming <= end) { // Wholly inside: an overlap, which is legitimate — the caller may re-ask for // an interval it already has. Overwrite in place. this.#positions.set(xyz.subarray(0, incoming * 3), (gridIndex - this.#firstIndex) * 3); return true; } if (gridIndex < this.#firstIndex) { // Extends the front. Shift what is held up rather than reallocate, then write // the batch whole — where it overlaps, it overwrites with the same values, // which is cheaper than working out where the overlap starts. const prepend = this.#firstIndex - gridIndex; const total = Math.max(end, gridIndex + incoming) - gridIndex; this.#ensure(total); this.#positions.copyWithin(prepend * 3, 0, this.#count * 3); this.#positions.set(xyz.subarray(0, incoming * 3), 0); this.#firstIndex = gridIndex; this.#count = total; this._definitionChanged.raiseEvent(this); return true; } const total = gridIndex - this.#firstIndex + incoming; this.#ensure(total); this.#positions.set(xyz.subarray(0, incoming * 3), (gridIndex - this.#firstIndex) * 3); this.#count = total; this._definitionChanged.raiseEvent(this); return true; } /** How the window slides. */ dropBefore(gridIndex: number): void { const drop = gridIndex - this.#firstIndex; if (drop <= 0 || this.#count === 0) { return; } if (drop >= this.#count) { this.#firstIndex = gridIndex; this.#count = 0; return; } this.#positions.copyWithin(0, drop * 3, this.#count * 3); this.#firstIndex = gridIndex; this.#count -= drop; } dropAfter(gridIndex: number): void { const keep = gridIndex - this.#firstIndex + 1; if (keep < 0) { this.#count = 0; return; } if (keep < this.#count) { this.#count = keep; } } #ensure(samples: number): void { if (this.#positions.length >= samples * 3) { return; } // Headroom, so a window that slides for hours stops reallocating after the // first top-up: a refresh appends before the eviction that makes room for it, // so the peak is a window plus one refresh's worth and the buffer settles // there. Not doubling — that would settle at twice a window and the whole // point of the flat array is that it is small. const grown = new Float64Array(Math.max(Math.ceil(samples * GROWTH_HEADROOM), STENCIL) * 3); grown.set(this.#positions.subarray(0, this.#count * 3)); this.#positions = grown; } /** * A six-point quintic through the samples bracketing `time`. * * Outside the window it holds the end sample rather than extrapolating, matching * the HOLD the sampled property is configured with. That matters for one case: * the clock scrubbed clear of the window, where for the frame or two before the * refill lands a free-running polynomial half an orbit past its last node does not * return a slightly stale position but a meaningless one. */ #interpolate(time: JulianDate, result: Cartesian3): Cartesian3 | undefined { if (this.#count === 0 || this.#stepSeconds <= 0) { return undefined; } if (this.#count < STENCIL) { // Too few to fit the stencil through: hold the nearest. const nearest = Math.min(this.#count - 1, Math.max(0, Math.round(JulianDate.secondsDifference(time, this.#anchor) / this.#stepSeconds) - this.#firstIndex)); const at = nearest * 3; return Cartesian3.fromElements(this.#positions[at] as number, this.#positions[at + 1] as number, this.#positions[at + 2] as number, result); } const gridPosition = JulianDate.secondsDifference(time, this.#anchor) / this.#stepSeconds - this.#firstIndex; // Centred: two nodes behind the interval for a stencil of six, so the requested // instant sits between nodes 2 and 3 wherever the window allows it. let base = Math.floor(gridPosition) - 2; if (base < 0) { base = 0; } if (base > this.#count - STENCIL) { base = this.#count - STENCIL; } // Clamped to the stencil's own span, which is what makes the ends HOLD: at // u = 0 the basis is (1,0,0,0) and at u = 3 it is (0,0,0,1), so reading outside // the window returns the edge sample exactly. let u = gridPosition - base; if (u < 0) { u = 0; } if (u > STENCIL - 1) { u = STENCIL - 1; } // Lagrange basis for nodes 0..5 at u, expanded rather than looped. The // denominators are the products of node separations and so are constants. const d0 = u; const d1 = u - 1; const d2 = u - 2; const d3 = u - 3; const d4 = u - 4; const d5 = u - 5; const b0 = -(d1 * d2 * d3 * d4 * d5) / 120; const b1 = (d0 * d2 * d3 * d4 * d5) / 24; const b2 = -(d0 * d1 * d3 * d4 * d5) / 12; const b3 = (d0 * d1 * d2 * d4 * d5) / 12; const b4 = -(d0 * d1 * d2 * d3 * d5) / 24; const b5 = (d0 * d1 * d2 * d3 * d4) / 120; const p = base * 3; const a = this.#positions; result.x = (a[p] as number) * b0 + (a[p + 3] as number) * b1 + (a[p + 6] as number) * b2 + (a[p + 9] as number) * b3 + (a[p + 12] as number) * b4 + (a[p + 15] as number) * b5; result.y = (a[p + 1] as number) * b0 + (a[p + 4] as number) * b1 + (a[p + 7] as number) * b2 + (a[p + 10] as number) * b3 + (a[p + 13] as number) * b4 + (a[p + 16] as number) * b5; result.z = (a[p + 2] as number) * b0 + (a[p + 5] as number) * b1 + (a[p + 8] as number) * b2 + (a[p + 11] as number) * b3 + (a[p + 14] as number) * b4 + (a[p + 17] as number) * b5; return result; } getValue(time: JulianDate, result?: Cartesian3): Cartesian3 | undefined { return this.getValueInReferenceFrame(time, ReferenceFrame.FIXED, result); } getValueInReferenceFrame(time: JulianDate, referenceFrame: ReferenceFrame, result?: Cartesian3): Cartesian3 | undefined { const target = result ?? new Cartesian3(); if (!this.#interpolate(time, target)) { return undefined; } if (referenceFrame === this.#frame) { return target; } return convertToReferenceFrame(time, target, this.#frame, referenceFrame, target); } /** For the callers that draw a line through the samples rather than reading one. */ rawPositions(fromGridIndex = this.#firstIndex, toGridIndex = this.#firstIndex + this.#count - 1): Cartesian3[] { const from = Math.max(fromGridIndex, this.#firstIndex); const to = Math.min(toGridIndex, this.#firstIndex + this.#count - 1); const out: Cartesian3[] = []; for (let index = from; index <= to; index += 1) { const at = (index - this.#firstIndex) * 3; out.push(new Cartesian3(this.#positions[at] as number, this.#positions[at + 1] as number, this.#positions[at + 2] as number)); } return out; } /** The grid index at or after `time`, for turning an instant into a range. */ indexAtOrAfter(time: JulianDate): number { return Math.ceil(JulianDate.secondsDifference(time, this.#anchor) / this.#stepSeconds); } /** * Every sample held between two instants, with the times they sit at. * * The times are derived from the anchor rather than stored, which is the point: * a caller that needs `(time, position)` pairs — drawing a track, transforming a * window into another frame — can have them without this keeping a JulianDate * per sample, and a JulianDate per sample is most of what a * `SampledPositionProperty` costs. */ samplesBetween(from: JulianDate, to: JulianDate): { times: JulianDate[]; positions: Cartesian3[] } { const times: JulianDate[] = []; const positions: Cartesian3[] = []; if (this.#count === 0 || this.#stepSeconds <= 0) { return { times, positions }; } const first = Math.max(this.indexAtOrAfter(from), this.#firstIndex); const last = Math.min(Math.floor(JulianDate.secondsDifference(to, this.#anchor) / this.#stepSeconds), this.#firstIndex + this.#count - 1); for (let index = first; index <= last; index += 1) { const at = (index - this.#firstIndex) * 3; times.push(this.timeAt(index)); positions.push(new Cartesian3(this.#positions[at] as number, this.#positions[at + 1] as number, this.#positions[at + 2] as number)); } return { times, positions }; } allSamples(): { times: JulianDate[]; positions: Cartesian3[] } { return this.samplesBetween(this.timeAt(this.#firstIndex), this.timeAt(this.#firstIndex + Math.max(0, this.#count - 1))); } equals(other?: GridPositionProperty): boolean { return this === other; } }