Spaces:
Configuration error
Configuration error
| // 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); | |
| } | |
| } | |
| } | |