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; } }