// Frame timing collection. Free of Cesium and the DOM: whoever has the // timestamps pushes them in. /** Below 30 fps a frame is felt rather than merely measured. */ export const JANK_MS = 1000 / 30; export interface SeriesStats { count: number; mean: number; min: number; p50: number; p95: number; p99: number; max: number; } /** Percentiles over a copy, so the caller's array keeps its arrival order. */ export function seriesStats(values: readonly number[]): SeriesStats | undefined { if (values.length === 0) { return undefined; } // The spread is the copy the rule asks for; toSorted is past the ES2022 lib. // eslint-disable-next-line unicorn/no-array-sort const sorted = [...values].sort((a, b) => a - b); const at = (quantile: number): number => sorted[Math.min(sorted.length - 1, Math.floor(quantile * sorted.length))] as number; const sum = values.reduce((total, value) => total + value, 0); return { count: values.length, mean: sum / values.length, min: sorted[0] as number, p50: at(0.5), p95: at(0.95), p99: at(0.99), max: sorted[sorted.length - 1] as number, }; } export interface FrameSample { frames: number; elapsedMs: number; fps: number; /** * Time between consecutive presented frames — what the user feels, and what * a vsync ceiling of 60 or 120 fps shows up in. */ wall: SeriesStats | undefined; /** * Time inside one render — Cesium's preUpdate to postRender. The work the app * actually did, which is the number that keeps moving after wall time has * flattened against vsync. * * Note what this is *not*: Cesium advances the clock and runs every `onTick` * listener before `preUpdate`, so per-satellite position work is outside it. * That is `tick`. */ cpu: SeriesStats | undefined; /** * Time inside `clock.tick()` — the whole of it, every `onTick` listener * included. This is where propagation lives: the sampled-position windows are * refreshed from a simulation-time callback, so the faster the clock runs the * more of this there is, and none of it appears in `cpu`. * * Measured at 5,000 satellites drawing points and nothing else, this is the * difference between a row that reads 1.2 ms of cpu at 2.2 fps and a row that * says where the other 460 ms went. */ tick: SeriesStats | undefined; /** * Time the GPU spent on one frame, from `EXT_disjoint_timer_query_webgl2`. * * A separate population from `wall` and `cpu` rather than a third value on * each frame: a query's result arrives several frames after the frame it * timed, and only some frames are sampled at all, so its count is its own. * Undefined where the extension is missing — and note the driver can still * lie even where it is present, which is why the report gates it against the * frame interval rather than printing whatever comes back. */ gpu: SeriesStats | undefined; /** * Heap size in MB, sampled once per frame across the window. * * A population rather than one reading, because one reading is not a * measurement of anything: `usedJSHeapSize` counts garbage that has not been * collected yet, and a page cannot force a collection. The single post-sample * reading this replaces read 86 MB and 462 MB on consecutive passes over the * same scene, purely by which side of a major GC it landed on. * * What the window buys is `min` and `max` — and be clear about what each is * worth, because it is less than it looks: * * - **`min` is not the live set.** A major collection rarely lands inside a * 4 s window, so the low-water mark is mostly the heap as the window opened, * accumulated garbage included. Measured over three identical sweeps, the * zero-satellite step read 59, 436 and 270 MB against a true live set of * 39.5 MB. Read it as a *relative* figure: the difference down a column * within one sweep cancels the offset, and did so to about 1% (a 5,000 * satellite scene came out +269.9 and +269.4 MB over its own zero row on two * consecutive passes). * - **`max - min` is the allocation rate** over the window, and repeats well: * 13 MB at zero satellites, 24 MB at 1,000, 31 MB at 5,000. * * For an absolute number there is no substitute for a collection nobody can * ask for from script — DevTools, or `HeapProfiler.collectGarbage` over CDP. * * Undefined outside Chrome. Granularity is not the problem — measured, eight * consecutive reads give eight distinct non-round values, with or without * `--enable-precise-memory-info` — uncollected garbage is. */ heap: SeriesStats | undefined; jankFrames: number; jankRatio: number; } /** * A rolling or unbounded window of frame timings. * * `limit` bounds it, which is what the live readout wants (the last couple of * seconds); the sweep leaves it unbounded so a whole sample period is one * population. Deltas rather than absolute times, so a paused tab that resumes * mid-window shows up as one huge frame instead of skewing an average. */ export class FrameSampler { readonly #limit: number; #wall: number[] = []; #cpu: number[] = []; #tick: number[] = []; #gpu: number[] = []; #heap: number[] = []; #last: number | undefined; #epoch = 0; constructor(limit = 0) { this.#limit = limit; } /** * A GPU timing, whenever its query finally resolves. Kept apart from `push` * because the two are not in step: a result lands frames after the frame it * belongs to, so pairing them would mean holding frames open for a number * that may never arrive. */ pushGpu(ms: number): void { this.#gpu.push(ms); if (this.#limit > 0 && this.#gpu.length > this.#limit) { this.#gpu.shift(); } } /** * The heap for this frame, in MB. Its own population like `pushGpu`, since it * is absent entirely on browsers that do not offer the reading and a frame * without one is still a frame. */ pushHeap(mb: number): void { this.#heap.push(mb); if (this.#limit > 0 && this.#heap.length > this.#limit) { this.#heap.shift(); } } /** * `now` is a monotonic timestamp, `cpuMs` the render duration for that frame, * and `tickMs` the clock tick that preceded it. */ push(now: number, cpuMs?: number, tickMs?: number): void { const previous = this.#last; this.#last = now; if (previous === undefined) { // The first push only establishes the origin — there is no delta yet. return; } this.#wall.push(now - previous); if (cpuMs !== undefined) { this.#cpu.push(cpuMs); } if (tickMs !== undefined) { this.#tick.push(tickMs); } if (this.#limit > 0 && this.#wall.length > this.#limit) { this.#wall.shift(); this.#cpu.shift(); this.#tick.shift(); } } /** * Drop what has been collected but keep the origin, so the next frame yields * a delta instead of being swallowed. This is how a warmup period is * discarded without losing a frame at the seam. */ reset(): void { this.#wall = []; this.#cpu = []; this.#tick = []; this.#gpu = []; this.#heap = []; this.#epoch += 1; } /** * Bumped by every `reset`, so a reading that was started before the reset can * tell that it no longer belongs here. * * The GPU clock needs it: its results arrive some frames after the frame they * measure, so a query issued during the warmup resolves after the warmup has * been thrown away. Pushing it anyway put shader compiles and buffer uploads — * the very frames the warmup exists to discard — into the sampled population. */ get epoch(): number { return this.#epoch; } get frames(): number { return this.#wall.length; } snapshot(): FrameSample { const wall = seriesStats(this.#wall); const elapsedMs = this.#wall.reduce((total, value) => total + value, 0); const jankFrames = this.#wall.filter((value) => value > JANK_MS).length; return { frames: this.#wall.length, elapsedMs, fps: elapsedMs > 0 ? (this.#wall.length / elapsedMs) * 1000 : 0, wall, cpu: seriesStats(this.#cpu), tick: seriesStats(this.#tick), gpu: seriesStats(this.#gpu), heap: seriesStats(this.#heap), jankFrames, jankRatio: this.#wall.length > 0 ? jankFrames / this.#wall.length : 0, }; } }