// The one Cesium-bound piece: it turns "draw N satellites with these // components" into a reconcile, and the render loop into frame samples. import type { JulianDate } from "@cesium/engine"; import { useCesiumStore } from "../../stores/cesium"; import { useSatStore } from "../../stores/sat"; import type { CesiumController } from "../CesiumController"; import type { DesiredScene } from "../SatelliteManager"; import type { BenchmarkTarget, FootprintSample, MeasureOptions, SceneApplied, SceneRequest } from "./benchmarkRunner"; import { FrameSampler, type FrameSample } from "./frameSampler"; declare global { /** * `performance.measureUserAgentSpecificMemory()`, absent from the TypeScript dom * lib because it is not Baseline. Declared here rather than cast at the call * site so the shape is stated once. */ interface MemoryMeasurement { bytes: number; breakdown: Array<{ bytes: number; types: string[]; attribution: Array<{ url: string; scope: string }> }>; } interface Performance { measureUserAgentSpecificMemory?: () => Promise; // Chrome only. The comment here used to claim 5 MB buckets unless started // with --enable-precise-memory-info; measured on Chrome in 2026 that is not // so — eight consecutive reads gave eight distinct non-round values with and // without the flag. What makes a single read useless is not granularity but // uncollected garbage. See FrameSample.heap. memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number }; } } /** About two seconds at 60 fps — enough for the live readout to be steady. */ const LIVE_WINDOW_FRAMES = 120; const BYTES_PER_MB = 1024 * 1024; /** A GPU query result is usually a frame or two away; give up rather than leak. */ const GPU_QUERY_POLL_MS = 4; const GPU_QUERY_MAX_POLLS = 250; export interface LiveSnapshot { frames: FrameSample; satellitesVisible: number; componentsDrawn: string[]; clockMultiplier: number; entities: number; primitives: number; } export interface TargetOptions { /** * Restrict the sweep to satellites carrying this tag. Unset means the whole * loaded catalog, which is what makes the counts reachable on any route * rather than only where a big enough group happens to be configured. */ tag?: string; /** * Place a ground station, which switches pass prediction on for every * satellite. Off by default: it is a large cost that has nothing to do with * drawing, so it belongs in its own run rather than in every row. */ groundStation?: { lat: number; lon: number }; } /** * How long to let the app go quiet before reading the footprint. * * Long enough for the sample-window top-ups already in flight to land and their * buffers to become collectable. Six seconds was where the reading stopped moving: * the same scene read 550 MB at +6 s and 544 MB at +16 s, against 1044 and 1295 MB * with the clock running. */ const FOOTPRINT_QUIESCE_MS = 6000; const wait = (ms: number, signal: AbortSignal): Promise => new Promise((resolve) => { if (signal.aborted || ms <= 0) { resolve(); return; } const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms); function onAbort(): void { clearTimeout(timer); resolve(); } signal.addEventListener("abort", onAbort, { once: true }); }); /** * Wait for `count` presented frames — but never forever. * * A hidden tab does not throttle requestAnimationFrame, it suspends it, so * without the timeout a sweep started and then backgrounded wedges on step one * and never reports anything. The timeout means it carries on instead, and the * `frames` column is what says the sample was worthless. */ const nextFrames = (count: number, timeoutMs = 1000): Promise => new Promise((resolve) => { let remaining = count; let settled = false; const done = (): void => { if (settled) { return; } settled = true; clearTimeout(timer); resolve(); }; const timer = setTimeout(done, timeoutMs); const step = (): void => { remaining -= 1; if (remaining <= 0) { done(); return; } requestAnimationFrame(step); }; requestAnimationFrame(step); }); /** * Where one GPU reading belongs: the sampler that was collecting when the query * was started, and the epoch it was then on. See `#endGpuQuery`. */ interface GpuTarget { sampler: FrameSampler; epoch: number; } /** * Whether this page can measure an absolute footprint at all. * * Two conditions, and the isolation one is the interesting half: the API is only * exposed to a cross-origin isolated context, so a page served without * `Cross-Origin-Opener-Policy: same-origin` and * `Cross-Origin-Embedder-Policy: credentialless` cannot see it however new the * browser. That is a deployment fact rather than a browser fact, which is why the * panel says which of the two is missing instead of just greying a control. */ export const canMeasureFootprint = (): boolean => window.crossOriginIsolated && typeof performance.measureUserAgentSpecificMemory === "function"; /** Best effort, and separate from Cesium's context so nothing internal is poked. */ function gpuName(): string { try { const gl = document.createElement("canvas").getContext("webgl2"); const info = gl?.getExtension("WEBGL_debug_renderer_info"); return info ? String(gl?.getParameter(info.UNMASKED_RENDERER_WEBGL)) : "unknown"; } catch { return "unknown"; } } export class CesiumBenchmarkTarget implements BenchmarkTarget { readonly #cc: CesiumController; readonly #live = new FrameSampler(LIVE_WINDOW_FRAMES); #sweep: FrameSampler | undefined; #preUpdateAt = 0; /** Duration of the clock tick that preceded the frame being rendered. See #instrumentClockTick. */ #tickMs = 0; /** `EXT_disjoint_timer_query_webgl2`, or undefined where the browser has no such thing. */ #timerExt: { TIME_ELAPSED_EXT: number; GPU_DISJOINT_EXT: number } | undefined; #gl: WebGL2RenderingContext | undefined; /** * At most one query in flight. Beginning one per frame would queue hundreds of * query objects and force a flush on each; one at a time samples a subset of * frames, which is all a median needs. */ #queryInFlight: { query: WebGLQuery; targets: GpuTarget[] } | undefined; options: TargetOptions = {}; /** * What the app looked like before the first prepare(), so restore() can put it * back. Held until restore rather than per run, because a run that throws * still has to give the user their scene back. */ #saved: { requestRenderMode: boolean; shouldAnimate: boolean; multiplier: number; scene: DesiredScene } | undefined; constructor(cc: CesiumController) { this.#cc = cc; const { scene } = cc.viewer; // Two marks per frame: the wall clock between presented frames comes from // postRender alone, and the work inside one frame needs both. Cesium runs // most position updates in clock onTick, before preUpdate, so measuring // from preUpdate deliberately excludes them — see README. this.#initGpuTimer(scene); this.#instrumentClockTick(); scene.preUpdate.addEventListener(() => { this.#preUpdateAt = performance.now(); this.#beginGpuQuery(); }); scene.postRender.addEventListener(() => { const now = performance.now(); const cpuMs = now - this.#preUpdateAt; this.#live.push(now, cpuMs, this.#tickMs); this.#sweep?.push(now, cpuMs, this.#tickMs); // After the timing marks, so the read is never inside what it would // otherwise inflate. Per frame rather than once per step because a single // reading measures when the last GC happened, not what the scene costs. const bytes = performance.memory?.usedJSHeapSize; if (bytes !== undefined) { const mb = bytes / BYTES_PER_MB; this.#live.pushHeap(mb); this.#sweep?.pushHeap(mb); } this.#endGpuQuery(); }); } /** * The GPU-side clock. Optional in every sense: the extension is absent on some * browsers, blocked on others, and — as measured on ANGLE/Metal — can return * times several multiples of the frame interval, which is why nothing here * trusts the number on sight. The report gates it; this only collects it. */ #initGpuTimer(scene: object): void { try { // `context` is Cesium-internal and untyped; reaching for it is the only way // to time the GPU on the context the app is actually drawing with. const gl = (scene as { context?: { _gl?: WebGL2RenderingContext } }).context?._gl; const ext = gl?.getExtension("EXT_disjoint_timer_query_webgl2") as { TIME_ELAPSED_EXT: number; GPU_DISJOINT_EXT: number } | null; if (gl && ext) { this.#gl = gl; this.#timerExt = ext; } } catch { // A missing or blocked extension is not worth a broken benchmark. } } #beginGpuQuery(): void { const gl = this.#gl; const ext = this.#timerExt; if (!gl || !ext || this.#queryInFlight) { return; } try { const query = gl.createQuery(); if (!query) { return; } gl.beginQuery(ext.TIME_ELAPSED_EXT, query); const targets: GpuTarget[] = [{ sampler: this.#live, epoch: this.#live.epoch }]; if (this.#sweep) { targets.push({ sampler: this.#sweep, epoch: this.#sweep.epoch }); } this.#queryInFlight = { query, targets }; } catch { this.#timerExt = undefined; } } #endGpuQuery(): void { const gl = this.#gl; const ext = this.#timerExt; const pending = this.#queryInFlight; if (!gl || !ext || !pending) { return; } const { query, targets } = pending; this.#queryInFlight = undefined; try { gl.endQuery(ext.TIME_ELAPSED_EXT); } catch { gl.deleteQuery(query); this.#timerExt = undefined; return; } // The result lands some frames later, so poll rather than block — and it is // delivered to the samplers this query was *started* for, at the epoch they // were then on. Delivering it to whatever was open on arrival is what made this // column untrustworthy: a query from the tail of a heavy step landed in the // next step's window, and a query from a warmup frame landed in the sample the // warmup exists to protect. A 5,000-satellite step reported 34.5 ms and the // 0-satellite step after it reported 24. let attempts = 0; const poll = (): void => { attempts += 1; try { if (gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) { // A disjoint means the GPU was interrupted and the timing is garbage. if (!gl.getParameter(ext.GPU_DISJOINT_EXT)) { const ns = gl.getQueryParameter(query, gl.QUERY_RESULT) as number; for (const target of targets) { if (target.sampler.epoch === target.epoch) { target.sampler.pushGpu(ns / 1e6); } } } gl.deleteQuery(query); return; } if (attempts > GPU_QUERY_MAX_POLLS) { gl.deleteQuery(query); return; } setTimeout(poll, GPU_QUERY_POLL_MS); } catch { this.#timerExt = undefined; } }; setTimeout(poll, GPU_QUERY_POLL_MS); } /** True when this browser offered a GPU clock at all. */ get gpuTimingAvailable(): boolean { return this.#timerExt !== undefined; } /** The in-browser readout, sampled continuously whether a sweep is running or not. */ live(): LiveSnapshot { return { frames: this.#live.snapshot(), satellitesVisible: this.#cc.sats.visibleSatellites.length, componentsDrawn: this.#cc.sats.enabledComponents, clockMultiplier: this.#cc.viewer.clock.multiplier, entities: this.#cc.viewer.entities.values.length, primitives: this.#cc.viewer.scene.primitives.length, }; } environment(): Record { const { canvas } = this.#cc.viewer.scene; return { build: `${__BUILD_SHA__} ${__BUILD_DATE__}`, mode: import.meta.env.DEV ? "dev (unminified — numbers are pessimistic)" : "production build", userAgent: navigator.userAgent, gpu: gpuName(), canvas: `${canvas.width}x${canvas.height}`, devicePixelRatio: window.devicePixelRatio, hardwareConcurrency: navigator.hardwareConcurrency, // Recorded because it invalidates the whole run: a hidden tab presents no // frames at all, so every frame figure below would be noise. visibility: document.visibilityState, // Whether an absolute footprint was obtainable, which is a property of how // the page was served rather than of the machine. crossOriginIsolated: String(window.crossOriginIsolated), }; } /** * Time the whole clock tick, by wrapping `clock.tick` rather than by adding a * listener to `clock.onTick`. * * Position updates happen in `onTick` listeners, and an `onTick` listener of * our own could only mark the point it is *itself* reached. Cesium raises * listeners in registration order, and two of the ones that matter — the * manager's derived-geometry refresh and the orbit batch's re-orientation — * are registered when the viewer is built, long before the panel that * constructs this target. A marker would sit behind them and quietly miss * exactly the work it was added to find. * * `clock.tick()` raises the event, so wrapping it captures every listener * whatever the order, which is the only version of this that cannot be wrong. * The cost is two `performance.now()` calls a frame, and it is only ever * installed in a session that has opened the benchmark panel. */ #instrumentClockTick(): void { const { clock } = this.#cc.viewer; // eslint-disable-next-line @typescript-eslint/unbound-method const original = clock.tick; if ((original as { __benchmarkWrapped?: boolean }).__benchmarkWrapped) { return; } const wrapped = (): JulianDate => { const started = performance.now(); try { return original.call(clock); } finally { this.#tickMs = performance.now() - started; } }; (wrapped as { __benchmarkWrapped?: boolean }).__benchmarkWrapped = true; clock.tick = wrapped; } async prepare(): Promise { const { clock } = this.#cc.viewer; const cesiumStore = useCesiumStore(); this.#saved ??= { requestRenderMode: cesiumStore.requestRenderMode, shouldAnimate: clock.shouldAnimate, multiplier: clock.multiplier, scene: this.#storeScene() }; // requestRenderMode skips frames when nothing moved, which would make the // frame deltas measure how idle the render loop is rather than how much a // scene costs. The clock has to run for the same reason: a stopped clock // means no position updates, and position updates are most of the cost. // // Through the store so the Render menu's switch follows: a sweep started from // the console with the panel closed still changes this, and a control showing // the opposite of what is in force is worse than no control. cesiumStore.requestRenderMode = false; clock.shouldAnimate = true; // Every count is sliced out of the loaded catalog, so the whole catalog has // to be there first — otherwise the sweep measures group downloads. await this.#cc.sats.catalog.ensureAll(); } catalogSize(): number { return this.#names().length; } async apply(request: SceneRequest): Promise { const names = this.#names().slice(0, request.satelliteCount); const { clock } = this.#cc.viewer; // The clock is set back to real time for the build. A step at ×1000 would // otherwise sweep the sample window forward while the scene is being // constructed, so `buildMs` would carry a propagation cost belonging to the // measurement that follows it. clock.multiplier = 1; // Clear first, so buildMs is the cost of building this scene rather than // the cost of the diff from the previous one. const clearStart = performance.now(); this.#cc.sats.reconcile(this.#scene([], [])); const clearMs = performance.now() - clearStart; await nextFrames(2); // `buildMs` is the wall time to a complete scene, not the synchronous part // of the call. Satellites are instantiated to a per-frame budget now (see // SatelliteManager.#build), so reconcile returns with the queue still // draining — without the await, every row would report whatever fraction of // the population happened to exist when the first frame ended. const buildStart = performance.now(); this.#cc.sats.reconcile(this.#scene(names, request.components)); await this.#cc.sats.buildSettled(); const buildMs = performance.now() - buildStart; await nextFrames(2); // Only now, so the warmup period absorbs the first refreshes at the new rate. clock.multiplier = request.clockMultiplier; const satellites = this.#cc.sats.visibleSatellites; const componentInstances: Record = {}; for (const satellite of satellites) { for (const component of satellite.componentNames) { componentInstances[component] = (componentInstances[component] ?? 0) + 1; } } return { satellitesRequested: request.satelliteCount, satellitesVisible: satellites.length, componentsRequested: [...request.components], componentsDrawn: this.#cc.sats.enabledComponents, componentInstances, clockMultiplier: clock.multiplier, entities: this.#cc.viewer.entities.values.length, primitives: this.#cc.viewer.scene.primitives.length, clearMs, buildMs, }; } async measure(options: MeasureOptions): Promise { const sampler = new FrameSampler(); this.#sweep = sampler; try { await wait(options.warmupMs, options.signal); // The warmup frames are thrown away, not averaged in: the first frames // after a build carry shader compiles and buffer uploads that a steady // state does not. sampler.reset(); await wait(options.sampleMs, options.signal); return sampler.snapshot(); } finally { this.#sweep = undefined; } } /** * An absolute footprint for the scene currently up. * * The `JavaScript`/`Window` breakdown entry is singled out as `jsMb` because it * is the figure comparable with everything else here — `measureUAM`'s total also * counts DOM and shared memory across workers, which is a broader thing than the * heap the rest of the framework talks about. Both are kept: the total is the * honest answer to "what does this tab cost", and measured they are far apart * (427 MB against 297 MB at 5,000 satellites). */ async measureFootprint(): Promise { const measure = performance.measureUserAgentSpecificMemory; if (!canMeasureFootprint() || !measure) { return undefined; } const startedAt = performance.now(); // Stop the clock and let the app go quiet first, which is the difference // between measuring a scene and measuring the garbage it happens to be // producing. At 5,000 satellites with orbits the app propagates and // re-transforms sample windows continuously, and the reading lands wherever // that churn is at the time — measured on one scene, seconds apart: // // clock running 1044 MB total (worker 557) then 1295 MB (window 1106) // clock stopped 550 MB total (worker 51) then 544 MB (window 357) // // so the running figures were a factor of two and a half apart on a scene that // had not changed. Safe to do here and nowhere else: the footprint is captured // after the sample window has closed, so no frame timing can see it. const clock = this.#cc.viewer.clock; const wasAnimating = clock.shouldAnimate; clock.shouldAnimate = false; try { await new Promise((resolve) => setTimeout(resolve, FOOTPRINT_QUIESCE_MS)); const result = await measure.call(performance); const js = result.breakdown.find((entry) => entry.types.includes("JavaScript") && entry.attribution.some((item) => item.scope === "Window")); // Separated out because `totalMb` counts them and nothing else does — see // FootprintSample.workerMb. const workerBytes = result.breakdown.filter((entry) => entry.attribution.some((item) => (item.scope ?? "").includes("Worker"))).reduce((sum, entry) => sum + entry.bytes, 0); return { totalMb: result.bytes / BYTES_PER_MB, jsMb: (js?.bytes ?? result.bytes) / BYTES_PER_MB, workerMb: workerBytes / BYTES_PER_MB, elapsedMs: performance.now() - startedAt, }; } catch { // A rejected measurement is a missing row, not a failed sweep: the API can // refuse (a detached frame, a browser that changed its mind) and the run // still has every frame timing it came for. return undefined; } finally { clock.shouldAnimate = wasAnimating; } } async restore(): Promise { const saved = this.#saved; if (!saved) { return; } const { clock } = this.#cc.viewer; useCesiumStore().requestRenderMode = saved.requestRenderMode; clock.shouldAnimate = saved.shouldAnimate; clock.multiplier = saved.multiplier; // The sweep drove the manager directly, so the store's scene has to be put // back by hand — sceneSync's watcher only fires when the store changes, and // the store never changed. this.#cc.sats.reconcile(this.#storeScene()); this.#saved = undefined; await nextFrames(1); } /** * Names in a stable order, so "the first 500" is the same 500 whatever order * the groups happened to load in and whichever run this is. Deduplicated * because activation matches by name and two catalog entries may share one. */ #names(): string[] { const entries = this.options.tag ? this.#cc.sats.catalog.entriesWithTag(this.options.tag) : this.#cc.sats.catalog.entries; // eslint-disable-next-line unicorn/no-array-sort -- already a fresh array return [...new Set(entries.map((entry) => entry.name))].sort(); } #scene(enabledSatellites: string[], components: readonly string[]): DesiredScene { const station = this.options.groundStation; return { enabledTags: [], enabledSatellites, disabledSatellites: [], components: [...components], groundStations: station ? [{ lat: station.lat, lon: station.lon, name: "Benchmark" }] : [], overpassMode: "elevation", trackedSatellite: "", }; } /** The scene the store currently wants, which is what restore() puts back. */ #storeScene(): DesiredScene { const store = useSatStore(); return { enabledTags: [...store.enabledTags], enabledSatellites: [...store.enabledSatellites], disabledSatellites: [...store.disabledSatellites], components: [...store.enabledComponents], groundStations: store.groundStations.map((station) => ({ ...station })), overpassMode: store.overpassMode, trackedSatellite: store.trackedSatellite, }; } }