orbit-studio / src /modules /util /PolylineBatch.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
8.83 kB
// One polyline primitive that many satellites' orbit lines are drawn into.
//
// Thousands of separate polylines is thousands of draw calls, so the lines are
// merged into a single Primitive and that Primitive is rebuilt whenever the set
// changes. Rebuilding is asynchronous and the scene must not be morphed while a
// build is in flight, which is what `settled()` is for.
//
// Two batches exist, and the frame is what separates them:
//
// - **inertial** — the Orbit component. The ellipse is fixed in inertial space,
// so the whole primitive is re-oriented by a model matrix twice a second and
// the geometry itself only has to be rebuilt when the membership changes.
// - **fixed** — the Orbit track component. An Earth-relative track is not a
// rigid transform of itself as time passes, so there is no matrix that keeps
// it current; instead the owner re-supplies geometry periodically through
// `replace`, and the coalescing window collapses those thousands of swaps into
// one rebuild. See SatelliteManager's track refresh.
import { type GeometryInstance, type JulianDate, Matrix4, PolylineColorAppearance, Primitive, SceneMode, Transforms, defined } from "@cesium/engine";
import type { Viewer } from "@cesium/widgets";
import { CesiumCallbackHelper } from "./CesiumCallbackHelper";
/** Ticks to coalesce over, so a hundred satellites arriving cost one rebuild. */
const COALESCE_TICKS = 30;
/** How often the batch is re-oriented into the inertial frame, in seconds. */
const FRAME_UPDATE_SECONDS = 0.5;
/**
* Which frame the geometries handed to this batch are expressed in — and so
* whether a model matrix can keep them current. See the note at the top.
*/
export type BatchFrame = "inertial" | "fixed";
export class PolylineBatch {
/** So a broken assumption is reported once rather than on every tick. */
static #reportedMissingState = false;
static #reportMissingState(): void {
if (PolylineBatch.#reportedMissingState) {
return;
}
PolylineBatch.#reportedMissingState = true;
console.error("Cesium Primitive has no _state; driving it every tick instead. Cesium internals have moved — see PolylineBatch.");
}
#viewer: Viewer;
readonly #frame: BatchFrame;
#geometries: GeometryInstance[] = [];
#primitive: Primitive | undefined;
/** A rebuild is queued and waiting out the coalescing window. */
#scheduled = false;
/** A Primitive is being built and is not in the scene yet. */
#building = false;
#settledWaiters: Array<() => void> = [];
constructor(viewer: Viewer, frame: BatchFrame = "inertial") {
this.#viewer = viewer;
this.#frame = frame;
if (frame === "inertial") {
// Permanent, and a no-op while there is no batch. The orbits are drawn in the
// inertial frame, so the whole primitive is re-oriented rather than each orbit
// being recomputed.
CesiumCallbackHelper.createPeriodicTimeCallback(viewer, FRAME_UPDATE_SECONDS, (time) => this.#applyInertialFrame(time));
}
}
/** Whether a rebuild is queued or in flight. */
get pending(): boolean {
return this.#scheduled || this.#building;
}
/** What a rebuild costs is a function of this. */
get size(): number {
return this.#geometries.length;
}
add(geometry: GeometryInstance): void {
this.#geometries.push(geometry);
this.#schedule();
}
remove(geometry: GeometryInstance): void {
this.#geometries = this.#geometries.filter((candidate) => candidate !== geometry);
this.#schedule();
}
/**
* Swap one member's geometry for a freshly built one.
*
* A remove followed by an add would do the same thing, but this is the call a
* periodic refresh makes once per satellite per cycle, and at five thousand
* satellites the difference between one array pass and two is worth having.
* More to the point it says what it means: the batch's membership has not
* changed, only the shape of one line in it.
*
* Returns false when `previous` is not a member — a satellite whose component
* was disabled between the refresh being scheduled and it running — so the
* caller can drop the geometry it just built rather than leaking it into a
* batch that no longer wants it.
*/
replace(previous: GeometryInstance, next: GeometryInstance): boolean {
const index = this.#geometries.indexOf(previous);
if (index === -1) {
return false;
}
this.#geometries[index] = next;
this.#schedule();
return true;
}
/**
* Resolves once the batch matches the geometries it has been given.
*
* The caller that needs this is the scene morph: suppressing the Orbit
* component drops every geometry, and morphing before the batch has caught up
* would rebuild it into the projection being left behind.
*/
settled(): Promise<void> {
if (!this.pending) {
return Promise.resolve();
}
return new Promise((resolve) => {
this.#settledWaiters.push(resolve);
});
}
#resolveSettled(): void {
const waiters = this.#settledWaiters;
this.#settledWaiters = [];
waiters.forEach((resolve) => resolve());
}
#schedule(): void {
if (this.#scheduled) {
return;
}
this.#scheduled = true;
const stop = CesiumCallbackHelper.createPeriodicTickCallback(this.#viewer, COALESCE_TICKS, () => {
// A build is still in flight; keep the window open and try again.
if (this.#building) {
return;
}
stop();
this.#scheduled = false;
if (this.#geometries.length === 0) {
this.#clear();
this.#resolveSettled();
return;
}
this.#build();
});
}
#clear(): void {
if (!this.#primitive) {
return;
}
this.#viewer.scene.primitives.remove(this.#primitive);
this.#primitive = undefined;
this.#viewer.scene.requestRender();
}
#build(): void {
this.#building = true;
const primitive = new Primitive({
geometryInstances: this.#geometries,
appearance: new PolylineColorAppearance(),
});
// Drive the primitive through its creation states by hand, so the finished
// one replaces the old one in a single frame rather than the scene showing a
// gap while Cesium builds it.
let lastState = -1;
const readyCallback = this.#viewer.clock.onTick.addEventListener(() => {
if (!primitive.ready) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const state = (primitive as any)._state;
if (state === undefined) {
// Cesium-internal, and the one reach here that would fail in silence:
// `update` would run exactly once, the primitive would never become
// ready, `#building` would stick, and orbits, tracks, `settled()` and
// therefore every scene morph would stop — with nothing logged. Say so,
// and drive it anyway, which is what the state check was only avoiding
// for the sake of one update per state.
PolylineBatch.#reportMissingState();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(primitive as any).update(this.#viewer.scene.frameState);
return;
}
if (state !== lastState) {
lastState = state;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(primitive as any).update(this.#viewer.scene.frameState);
}
return;
}
// Oriented before it goes in, so it is never drawn a frame behind the
// batch it is replacing.
this.#orient(primitive, this.#viewer.clock.currentTime);
this.#clear();
this.#viewer.scene.primitives.add(primitive);
this.#primitive = primitive;
this.#viewer.scene.requestRender();
this.#building = false;
readyCallback();
if (!this.pending) {
this.#resolveSettled();
}
});
}
#applyInertialFrame(time: JulianDate): void {
if (this.#primitive) {
this.#orient(this.#primitive, time);
}
}
/**
* `modelMatrix` in the inertial frame is only supported in 3D — outside it,
* Cesium throws from inside the render loop — so the identity matrix stands in,
* and the periodic update puts the rotation back on return to 3D.
*
* A fixed-frame batch is already in the frame it is drawn in and needs no
* matrix at all.
*/
#orient(primitive: Primitive, time: JulianDate): void {
if (this.#frame === "fixed") {
return;
}
if (this.#viewer.scene.mode !== SceneMode.SCENE3D) {
primitive.modelMatrix = Matrix4.IDENTITY;
return;
}
const icrfToFixed = Transforms.computeIcrfToFixedMatrix(time);
if (defined(icrfToFixed)) {
primitive.modelMatrix = Matrix4.fromRotationTranslation(icrfToFixed);
}
}
}