orbit-studio / src /modules /util /CesiumCleanupHelper.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
6.51 kB
import type { Viewer } from "@cesium/widgets";
import { CesiumCallbackHelper } from "./CesiumCallbackHelper";
/**
* Cesium keeps a label's glyph billboards after the label is gone.
*
* `EntityCluster` pools label slots rather than releasing them: removing an
* entity's label sets `show = false` and `text = ""` and parks the slot for
* reuse. Blanking the text makes `LabelCollection` unbind every glyph, and
* unbinding hides each glyph billboard and pushes it onto `_spareBillboards`
* *without removing it from `_glyphBillboardCollection`*. So that collection is
* left holding one billboard per glyph the scene ever drew, and pays to update
* and render all of them every frame however few are shown.
*
* Measured: after one benchmark step at 5,000 labelled satellites, returning to
* 74 satellites left 67,952 spare billboards in a collection of 68,703, and the
* frame at 250 ms — 4 fps instead of 100, for the rest of the session. Draining
* the pool put it back to 8.4 ms with nothing else changed.
*
* Upstream: https://github.com/CesiumGS/cesium/issues/7184
*/
/**
* Below this the pool is left alone. Draining forces the glyph collection to
* rebuild its vertex arrays, which is worth doing for the tens of thousands a
* constellation leaves behind and not for the handful that selecting a single
* satellite churns through.
*/
const SPARE_BILLBOARD_THRESHOLD = 100;
/** How deep the primitive tree is walked. The label collection sits three levels down. */
const MAX_DEPTH = 8;
/**
* The Cesium internals this reaches for. None of them are in the public type
* definitions and none of them are stable across versions — which is the whole
* reason `#drain` shouts rather than skipping when one is missing. The previous
* version of this file read `_billboardCollection`, renamed upstream to
* `_glyphBillboardCollection`, and silently did nothing from then on.
*/
interface LabelCollectionInternals {
_labels: unknown[];
_spareBillboards?: unknown[];
_glyphBillboardCollection?: { remove(billboard: unknown): boolean };
}
interface PrimitiveNode {
_primitives?: unknown[];
_labelCollection?: unknown;
_labels?: unknown[];
}
/**
* Every LabelCollection under `node`, found by walking rather than by index.
*
* The path used to be hard-coded as `_primitives[0]._primitives[0]._primitives[0]`,
* which is where the entity cluster's collection happens to sit today. A walk
* costs nothing at this size and survives the tree being re-nested; it also finds
* the collections belonging to any additional data source, and draining those is
* the intent either way.
*/
export function collectLabelCollections(node: unknown, found: LabelCollectionInternals[] = [], depth = 0): LabelCollectionInternals[] {
if (!node || typeof node !== "object" || depth > MAX_DEPTH) {
return found;
}
const candidate = node as PrimitiveNode;
if (Array.isArray(candidate._labels)) {
found.push(candidate as LabelCollectionInternals);
}
if (candidate._labelCollection) {
collectLabelCollections(candidate._labelCollection, found, depth + 1);
}
if (Array.isArray(candidate._primitives)) {
for (const child of candidate._primitives) {
collectLabelCollections(child, found, depth + 1);
}
}
return found;
}
export class CesiumCleanupHelper {
/** So a broken assumption is reported once rather than on every reconcile. */
static #reported = false;
/**
* Drain the leftover glyph billboards, on the next tick.
*
* Deferring is load-bearing rather than politeness: the labels are not unbound
* when their entities are removed, they are unbound when `LabelCollection.update`
* next runs, so there is nothing in the pool to drain until a frame has passed.
*
* Safe to call after any teardown — below the threshold it does nothing, which
* is what makes "whenever the active set shrank" an affordable trigger.
*/
static cleanup(viewer: Viewer): void {
const stop = CesiumCallbackHelper.createPeriodicTickCallback(viewer, 1, () => {
stop();
CesiumCleanupHelper.drain(viewer);
});
}
/** The drain itself, without the tick deferral. Exported for the unit test. */
static drain(viewer: Viewer): number {
const collections = collectLabelCollections(viewer.scene.primitives);
if (collections.length === 0) {
// Not a problem, and not evidence of a rename: `EntityCluster` creates its
// label collection on the first label, so a scene that has only ever drawn
// points legitimately has none. Reporting it here cried wolf on the
// commonest scene there is. A collection that exists but cannot be read is
// the case worth shouting about, and both this and `#trim` still do that.
return 0;
}
let removed = 0;
for (const collection of collections) {
const spares = collection._spareBillboards;
const billboards = collection._glyphBillboardCollection;
if (!spares || !billboards) {
CesiumCleanupHelper.#report("LabelCollection has no _spareBillboards / _glyphBillboardCollection");
continue;
}
if (spares.length < SPARE_BILLBOARD_THRESHOLD) {
continue;
}
// Removing a billboard destroys it, so the pool has to be emptied in the
// same breath — a destroyed billboard handed back out to a new glyph is
// worse than the leak.
spares.forEach((billboard) => billboards.remove(billboard));
removed += spares.length;
spares.length = 0;
}
if (removed > 0) {
// Says how many, because the version that logged unconditionally read as
// working for as long as it was doing nothing at all.
console.info(`Removed ${removed} leftover Cesium glyph billboards`);
viewer.scene.requestRender();
}
return removed;
}
/**
* Loud, because the failure this guards against is invisible: a renamed
* internal turns the drain into a no-op, and the only symptom is that the app
* gets slower after having shown a lot of labels.
*/
static #report(problem: string): void {
if (CesiumCleanupHelper.#reported) {
return;
}
CesiumCleanupHelper.#reported = true;
console.error(`Cannot drain leftover Cesium glyph billboards: ${problem}. Cesium internals have moved — see CesiumCleanupHelper.`);
}
/** Test seam: the once-only report is process state, so it has to be resettable. */
static resetReported(): void {
CesiumCleanupHelper.#reported = false;
}
}