# Benchmarking framework Three questions about satvis's performance, answered repeatably: > How does the frame cost scale with the number of satellites, what does each > satellite component cost on top of the ones already being drawn, and what does > running the clock faster — that is, propagating more often — cost? It replaces the console-paste script that used to be `src/modules/benchmark.ts`, which measured average and worst frame time and printed one line per step. The same idea, with the parts that made its numbers hard to trust fixed: percentiles instead of an average and a max, warmup frames discarded, a build torn down before the next one is timed, the first step re-run at the end to catch drift, and what was _actually drawn_ recorded next to what was asked for. ## Run it **Render menu → Measurement → Benchmark**, or `?bench=true` in the url. They are the same switch: it is `cesium.showBenchmark`, url-synced like every other switch in that menu, so a benchmarking session is a shareable link. Opening the panel is what loads the framework, and that is also what puts `window.bench` there for console use. **Measure a production build, not `pnpm dev`** — a dev build is unminified and runs Vue in development mode, so its numbers are pessimistic by an unknown factor: ```bash pnpm build && pnpm preview ``` Then the panel, or the console: ```js bench.quick(); // 3 counts × 7 isolated sets — checks the harness bench.run(); // 5 counts × 7 isolated sets: each component's own cost bench.cumulative(); // 5 counts × 8 growing sets: cost on top of what is already drawn bench.clock(); // 5 counts × 4 clock rates: the propagation axis bench.run({ satelliteCounts: [0, 500, 5000], componentSets: [["Point", "Orbit"]] }); bench.run({ clockMultipliers: [1, 100] }); // any sweep can take the clock axis bench.run({ groundStation: { lat: 48.18, lon: 11.75 } }); // switches pass prediction on bench.run({ captureFootprint: true }); // absolute memory per step, ~17 s each bench.run({ repeatFirstStep: false }); // skip the closing drift check bench.watch(); // log a live line every 2 s; returns a stop function bench.cancel(); bench.log(); bench.csv(); bench.json(); bench.text(); ``` Every sweep closes by re-running its first step, so the step count is one more than the axes multiply out to. A step costs `warmupMs + sampleMs` (2 s + 4 s by default) plus its build, which is what keeps the default counts down to five. Keep the tab in the foreground. A background tab presents no frames at all and every row becomes a lie — see the first caveat below. ## What it measures A step is one point in a three-axis sweep: **satellite count × component set × clock rate**. The clock axis is one value (×1) unless asked for, so it costs nothing when the question is only about drawing. | Column | Meaning | | ------------------ | -------------------------------------------------------------------------------------------- | | `fps`, `frameMs` | Between presented frames. What the user feels; flattens against vsync at 60/120 fps | | `cpuMs` | `preUpdate` → `postRender`. The render only — position updates are in `tickMs` | | `tickMs` | `clock.tick()`, every `onTick` listener included. Where propagation shows up | | `gpuMs` | GPU time per frame, where the driver's clock can be believed. Blank otherwise | | `p95`, `worst` | Percentiles, not just a max — one 400 ms frame should not define a row | | `frames` | The sample size. A handful means the row is noise; read this one first | | `jankPct` | Share of frames slower than 33 ms | | `clock` | The clock rate the step ran at, as a multiple of real time | | `buildMs` | Wall time to a **complete** scene: instantiation plus component creation, spread over frames | | `clearMs` | Tearing the previous scene down | | `visible` | Satellites actually drawn, which is **not** always the count requested | | `drawn` | Components actually drawn, when they differ from the ones requested | | `heapMb` | Heap low-water mark. **Not printed** — an input to the memory fit. csv/json only | | `heapPeakMb` | High-water mark. `heapPeakMb - heapMb` is the window's allocation rate. csv/json | | `footprintMb` | Absolute JS footprint, garbage excluded. Only with the footprint switch on | | `footprintTotalMb` | The whole agent — JS plus DOM and workers. Broader, and csv/json only | And derived, across steps: - **scaling** — a least-squares fit of main-thread frame time, meaning `cpuMs + tickMs`, against the satellites drawn, per _series_ (component set **and** clock rate, so a fit is never averaged across clocks): ms per 1,000 satellites, the fixed cost at zero, r² (well under 1.0 means the cost is _not_ linear in the count), the frame-time **floor**, and where 60 fps runs out. Read `floor` before `sats@60`. The floor is everything outside the main thread — GPU work and the wait for vsync, which `frameMs` cannot separate — and it is the term that decides whether the slope matters at all. A floor already past 16.7 ms means 60 fps was gone before the first satellite, and `sats@60` goes blank rather than extrapolating a count that was never the problem. Neither obvious candidate works as the fit basis, which is why it is the sum: - `cpuMs` alone misses most of the per-satellite cost. Cesium's `Viewer` runs `dataSourceDisplay.update` — every entity's position evaluation — inside an `onTick` listener, before `preUpdate`. Fitting it had the Point series holding 60 fps to **1.66 million** satellites while the measured frame at five thousand was already 14.9 ms. - `frameMs` cannot be fitted at all, because vsync quantises it. Measured on a 120 Hz display with points only: from 0 to 1,000 satellites main-thread work went 0.64 → 1.21 ms and `frameMs` sat at exactly 8.33 ms the whole way, the extra work absorbed by idle already being spent waiting for the next tick — a fit through it reads a slope of **zero**. Past the interval it stops being continuous rather than becoming useful: at 5,000 satellites with 11.5 ms of main-thread work, the _median_ frame still presented at 8.72 ms and the mean of 11.72 ms really meant "15.5% of frames missed a tick". Its intercept is the refresh interval, a property of the display rather than of the app. `sats@60` is therefore a **main-thread** ceiling, and it assumes the GPU is not the binding constraint. That is what the measurements show once a scene is large enough to matter — at 5,000 points, main-thread work was 11.52 ms and the frame 11.72, so the frame _was_ the main thread and the GPU overlapped it — but a bigger canvas or MSAA and HDR at full device pixels raises the floor, and the floor column is how you notice. - **memory** — a least-squares fit of the heap floor against the satellites drawn, per series: MB per 1,000 satellites, KB per satellite, and r². Chrome only, and a slope rather than a footprint — **read r² first**, because the whole method rests on an assumption that can break. See the memory caveat below. With **accurate memory footprint** switched on (Render menu → Benchmark → settings → extras, or `captureFootprint: true`) each step also gets an absolute figure with garbage excluded, from `performance.measureUserAgentSpecificMemory()`. That adds a `mem MB` column and an `absolute` KB-per-satellite beside the derived one — two independent derivations of the same quantity, so agreement is evidence and a gap is a question. Measured on one run: 54.4 derived against 54.0 absolute. The `absolute` column carries **its own r² and point count**, because a capture can be refused for a single step: that leaves it fitted over two points while the floor fit beside it still has three, and it would otherwise be printed under the floor fit's green r². It is off by default because it is the most expensive thing here: the call resolves only when a collection happens, about 17 s a step, which the duration estimate includes (the default sweep reads `≈ 0m 38s` off and `≈ 2m 20s` on). It needs a cross-origin isolated page, which `pnpm dev` and `pnpm preview` serve and a deployed satvis.space does not — see [Cross-origin isolation](#cross-origin-isolation). Where the page is not isolated the switch is disabled and says why. **This is also the only leak check that works.** Two absolute figures for one scene, minutes apart, are comparable in a way the heap floor is not: on a clean run the first step and its repeat measured 36.2 and 38.7 MB, where the floors for those same two rows read 35.0 and 103.3 — a 195% swing against a real 7%. - **marginal cost** — each set differenced against the largest set measured under the same conditions that is a strict subset of it, on main-thread frame time. In a cumulative sweep that is the cost of the component just added; in an isolated sweep it is that component's cost over a bare point. One function serves both. - **propagation** — each clock rate differenced against ×1 for the same satellites and components, on `tickMs`. Only present when the clock was actually swept, because an empty table would read as "propagation is free" rather than "nobody asked". It differenced `cpuMs` until it was pointed at a real question and got it wrong. Measured at 5,000 satellites drawing points at ×10000 — a step running at 2.2 fps with 462 ms frames — it reported a delta of **−0.08 ms and 0 µs per satellite**, because all of the cost was in the clock tick that `cpuMs` starts after. Direct instrumentation put 95% of wall time inside `SampledTrajectory.update`. The one table named after propagation could not see propagation; it now differences `tickMs` and prints `cpuMs` beside it for contrast. **`tickMs` has the same blind spot one step further out, and this table inherits it.** Since propagation moved to a worker, the samples come back in `message` events, and what the main thread does with them — resolving the request and filing the chunk — runs in its own task, inside neither `clock.tick()` nor `preUpdate`→`postRender`. So it lands in `frameMs` and in nothing else. Measured at 5,000 satellites and ×100000: `frameMs` 106.1, of which `cpuMs` 0.86, `tickMs` 3.44 and `gpuMs` 17.8 — about 85 ms attributed to nothing, at 9.4 fps, where it cannot be idle waiting for vsync. Below roughly ×1000 the residue is small and this table reads true; above it, treat the figure as a floor and read `frameMs` beside it. Identifying the residue needs a profile rather than another sweep — the reply handler is the candidate, not a confirmed cause. - **drift** — the first step, re-run as the last step, against its original. A sweep is minutes long and the app it measures does not hold still: shader caches fill, the JIT settles, the heap grows. This is the only figure in the run that can tell a rising line that is the scene from a rising line that is the clock, so a small `mainDriftPct` is what licenses reading the other tables at all — over 10% and both the panel and `logRun` say so. The repeat step is excluded from every other table: it is a second sample of a scene already in the set, and averaging it in would weight one point twice and hide the drift it was measured to expose. `buildDriftPct` is usually the louder of the two and expected to be strongly negative — see the `buildMs` caveat below. ### Why the clock rate is a propagation axis Propagation is not paid per frame. `SampledTrajectory.start` refreshes its sample window on a **simulation-time** callback — every quarter of an orbital period — and each refresh re-propagates 120 SGP4 samples per orbit for that satellite. So refreshes per wall second are proportional to the multiplier: at ×1000 a quarter orbit goes by in about a second and a half, where at ×1 it takes a quarter of an orbit. Drawing does not care what the clock is doing, which is exactly what makes the difference between two clock rates attributable to propagation. A `usPerSatellite` that holds steady across counts at one rate says the cost is per-satellite propagation and nothing else. ### Cross-origin isolation `performance.measureUserAgentSpecificMemory()` — the **accurate memory footprint** switch — is only exposed to a cross-origin isolated page, so it needs `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: credentialless`, which `pnpm dev` and `pnpm preview` both send. One interaction is unresolved rather than settled: **isolation and this app's service worker.** An isolated document refuses to start a dedicated worker from a cached response carrying no `Cross-Origin-Embedder-Policy`, and `createVerticesFromHeightmap.js` is exactly such a worker — when it is blocked no terrain geometry is built, the globe is black, and the satellites go on drawing over nothing. That was observed once directly, with the blocked request in the network log and a precache entry whose `cross-origin-embedder-policy` was null, and it was reported again as recurring on every reload rather than once. It has not been reproduced deliberately. Four configurations were tried in system Chrome — a fresh origin over three loads with the precache settled at 116 entries; the same build isolated and then de-isolated on one origin; a second worktree's build served on an origin the isolated build had populated; and repeated reloads in the automated browser pane. All of them rendered the globe with the worker constructing fine. Two things did come out of the attempt and both matter: - **A service worker replays the stored `COOP`/`COEP` headers**, so an origin can stay isolated after the server stops sending them. Isolation is sticky per origin, not per response. - **The preview port is shared between git worktrees.** A service worker is scoped to the origin, so one worktree's build populates caches that another worktree's build is then served against — different assets, and now possibly different isolation state, behind one registration. The mechanism is not understood. If the globe goes black while the satellites draw, clear that origin's service worker and caches — and check whether `createVerticesFromHeightmap.js` shows `ERR_BLOCKED_BY_RESPONSE`, which is what separates this from the shared-origin cache mess above. **Shipping this to production needs more than a `cacheId` bump.** Precache entries are keyed by content revision and Cesium's workers are copied verbatim between builds, so a deploy would not re-fetch them — and `cesium-cache` runtime-caches those same workers `CacheFirst` for 30 days under a name Workbox does not namespace with `cacheId`. Both would have to change in one release. PostHog under `credentialless` is also still unverified. ## Things that will bite you - **The tab has to stay visible.** A hidden tab does not throttle `requestAnimationFrame`, it suspends it — no frames are presented at all, and every timing becomes noise. The sweep no longer wedges when that happens (each frame wait has a 1 s timeout), and it says so instead: `frames` on each row is the sample size, rows under 20 frames are struck through in the panel, `logRun` warns before the tables, and the run's environment records `visibility`. Read `frames` before believing anything else on a row. - **`?framepump=1` is for a tab that cannot be made visible**, which in practice means an automated browser pane. It replaces `requestAnimationFrame` with a MessageChannel — the one scheduler a hidden page does not throttle, where `setTimeout` is clamped to a second — and drives `resize`/`render` in place of the viewer's own loop, which cannot be restarted once its callback has been suspended. Add `?framems=` to pace it something other than 60 Hz. Pair it with `?bench=true`: this module is loaded by the panel, so without the panel there is nothing to install it. The pane also has to have laid the tab out, or Cesium's canvas is 0 px wide and draws nothing however many frames it is given — the pump says so once when it sees that, rather than letting a sweep return zeros that look like measurements. It buys a scene that builds and renders; it does **not** buy a frame rate. Frames arrive on a fixed interval of the pump's choosing, so `fps` and `frameMs` measure the pump and flatten against its rate exactly as they would against vsync. `cpuMs` and `tickMs` are spans inside a frame and do not care what scheduled it, so those stay readable. Treat such a run as a comparison between two builds, both pumped, and say so wherever the numbers are quoted. - **The sweep drives `SatelliteManager.reconcile` directly, not the store.** It has to: `sceneSync` switches Label off above 200 active satellites, so a store-driven sweep could not measure labels at 1,000. The cost is that a store change mid-sweep would overwrite the scene — so don't touch the toolbar while it runs. `restore()` puts the store's scene, `requestRenderMode` and `shouldAnimate` back afterwards. - **Render-on-demand is switched off as soon as the panel opens**, and put back when it closes. With it on, the gap between frames measures how idle the loop is rather than what a scene costs, so there is no reading to be had — which is why this is not offered as a choice. Switching it back on from the Render menu while the panel is open puts a warning across the top of the panel, beside the readout it invalidates. The clock is likewise forced to run for the duration of a sweep: a stopped clock means no position updates, and position updates are most of the cost. - **`buildMs` is wall time, not blocking time, and it is not the freeze.** Satellites are instantiated to a per-frame budget (`SatelliteManager.#build`), so `reconcile` returns with the queue still draining and the step waits on `buildSettled()` before measuring — without that wait every row would report whatever fraction of the population existed when the first frame ended. The consequence is that `buildMs` went **up** when the freeze went away: at 5,000 satellites a points-only build blocked for 908 ms as one frame and now completes in about 1,450 ms with no frame over 100 ms. If what you want is the freeze, measure the gaps in the rAF stream; this column cannot see them. - **`buildMs` is always measured at ×1**, whatever the step's clock rate. A step at ×1000 would otherwise sweep the sample window forward mid-build, so the build would carry propagation belonging to the measurement after it. The rate is applied once the scene is up, so the warmup absorbs the first refreshes at the new rate. - **`cpuMs` excludes the clock tick; `tickMs` is that tick.** Cesium runs `clock.onTick` — where sampled positions update — before `scene.preUpdate`, so per-satellite position work lands in `frameMs` but not in `cpuMs`. It is measured separately by wrapping `clock.tick` itself rather than by adding an `onTick` listener: listeners are raised in registration order and two that matter (the manager's derived-geometry refresh, the orbit batch's re-orientation) are registered with the viewer, long before the panel, so a marker of our own would sit behind them and miss the work it was there to find. Read the pair together — `cpuMs` well under `tickMs` is a propagation-bound scene, and the reverse is a draw-bound one. Neither covers the whole main thread. Worker replies are handled in their own task, outside both regions, so `cpuMs + tickMs` can sit far below `frameMs` on a scene that is nonetheless main-thread bound — see the propagation table above for the measurement and the bound on when it matters. - **The derived tables fit against `cpuMs`, which is main-thread time only, and this app is usually GPU-bound.** Measured on an M4 Pro at 2560×1440 with zero satellites: `frameMs` 14.3, `cpuMs` 0.74 — the CPU is 5% of the frame, and the remaining ~13.5 ms is fragment work that `scalingFits` and `marginalCosts` cannot see. Ablation put nearly all of it in two settings, both full-screen per-pixel costs: **4× MSAA** (Cesium's default) and **`highDynamicRange`** (set in `createViewer.ts`), with `quality: high` rendering at full device pixels and so quadrupling both on a Retina display. Everything scene-shaped — atmosphere, fog, globe lighting, sun/moon/starfield — came to under 1.5 ms together. So a component that is cheap on the CPU but adds fragments will look free in the marginal-cost table and still cost frames. Read `gpuMs` beside `cpuMs`, and where `gpuMs` is blank read `frameMs`: if it sits well above the display's fastest observed interval, the scene is GPU-bound whatever `cpuMs` says. **That is the empty scene, and it stops being true once satellites are drawn.** What changes it is `tickMs`: Cesium's `Viewer` runs `dataSourceDisplay.update` — every entity visualizer, and so every satellite's position evaluation — inside its own `onTick` listener, which is before `preUpdate` and therefore outside `cpuMs`. Measured with points and nothing else, `cpuMs + tickMs` against `frameMs`: at zero satellites 1.06 of 8.66 ms, at 5,000 satellites **14.65 of 14.90 ms**. The fixed floor is GPU work; the part that grows with the count is main-thread work, and almost all of it is the tick. `scalingFits` and `marginalCosts` fit `cpuMs + tickMs` for exactly this reason, and report the frame-time floor beside the slope so a GPU-bound configuration is visible rather than implied. See **scaling** above. - **`gpuMs` is withheld rather than guessed when the driver lies.** A frame that presented every 14 ms cannot have cost the GPU 49 ms, but that is exactly what `EXT_disjoint_timer_query_webgl2` reported on ANGLE/Metal. Every row's figure is checked against its own frame interval (`GPU_TIMER_TRUST_FACTOR`, 1.5×) and the whole column blanks when most rows fail, with `logRun` saying which of the two reasons applies — no extension, or one that cannot be believed. Two other approaches were tried and do not work: `gl.finish()` never synchronises in Chrome (WebGL is proxied to a separate GPU process), and timing a tight `scene.render()` loop measures queueing rather than execution. - **`buildMs` is not comparable across component sets.** The first pass over a population pays for whatever it warms up; a measured run had `Point` at 500 satellites cost 3,245 ms to build and `Point + Orbit` at the same 500 cost 399 ms — more drawing, an eighth of the time, because it was second. Compare `buildMs` down a column (rising counts within one set), never across sets. The drift table quantifies it directly: `buildDriftPct` is that same first-pass cost, measured rather than argued about. Whether it is satellite.js, the trajectory sampling or plain JIT warmup is the first thing this framework is worth pointing at. - **`visible` may exceed the count requested.** Activation matches by _name_ and two catalog entries can share one, so asking for 500 drew 501. That is why the fits are computed against `visible` rather than the requested count. - **Not every component applies to every satellite.** Ground track and sensor cone are drawn per orbit class, a 3D model needs a model url. `visible` and `componentInstances` are recorded for exactly this reason; check them before believing a flat line. - **Memory is reported as a slope, and the slope has one failure mode.** `performance.memory.usedJSHeapSize` counts garbage that has not been collected yet, and script cannot force a collection. So a heap reading is not a footprint, and the single one this framework used to print per step read 86 MB and 462 MB on consecutive passes over the _same_ scene — it sent someone hunting a leak that did not exist. The heap is now sampled every frame, the per-step floor is kept out of the printed tables (csv and json still carry it), and what is reported is `memoryFits`: the floor fitted against the satellites drawn, within one series. Why a fit works: the standing garbage is roughly a **common offset** across the rows of one series measured in one pass, so it lands in the intercept and leaves the slope alone. Checked against forced collections over CDP with each scene held up, the fit reported **53.7 KB per satellite against a true 52.5** — about 2% out, r² 0.999. When it does not work: if a major collection lands _between_ two rows of a series, the offset stops being common and the slope is meaningless rather than merely noisy. Measured on such a pass — zero-satellite floor 419 MB, the next row 101 MB — the fit reported **−2.8 MB per 1,000 satellites**, memory apparently freed by drawing. That is what r² is for and it caught it at **0.002**, against 0.999 for the good pass. `memoryFitTrustworthy` gates on it, the panel marks the row, and `logRun` warns — if you see it, re-run the sweep. It also refuses a **two-count sweep** (`MIN_MEMORY_FIT_POINTS`, 3), however beautifully it fits: two points always lie on their own line, so r² comes back 1.000 exactly where the offset assumption has been tested least. Three counts is the fewest that can disagree with itself. `heapDriftPct` on the drift table is the same scene's floor minutes apart. Treat it as a **prompt, not a verdict**: it moves with whenever V8 last collected, and measured on two clean runs it read −14.6% and −10.2% with nothing wrong. A large figure means go and check with a real collection, not that there is a leak. For an absolute number there is no substitute for a collection script cannot ask for: DevTools, or `HeapProfiler.collectGarbage` over CDP. Measured that way, the live set after five passes at 5,000 satellites was flat at 40–41 MB (nothing leaks), and a live 5,000-satellite scene is 287 MB against 30 MB empty. There is one accurate in-page alternative, tried rather than assumed: `performance.measureUserAgentSpecificMemory()`. It needs cross-origin isolation, and `Cross-Origin-Opener-Policy: same-origin` with `Cross-Origin-Embedder-Policy: credentialless` isolates this app without breaking anything — verified `crossOriginIsolated` true, catalog loaded, 74 satellites drawn, no console errors, and still working when framed from a foreign origin (COOP does not apply to iframes, so a framed instance runs unisolated as before). `credentialless` is required rather than `require-corp`, which would need ion and Google tiles to send CORP headers they do not send. Its figure agreed with a forced collection to 0.2%: 52.6 KB per satellite against 52.7. Every cross-origin consumer was checked against a control differing only in the headers, and all of them behaved identically: the five imagery hosts (ArcGIS, OSM, VersaTiles, NASA GIBS, Iowa Mesonet), ReEarth terrain, `api.cesium.com`, 36 tile loads from `assets.ion.cesium.com` under ion World Terrain, 178 more under OSM Buildings, and 114 from `tile.googleapis.com` under Google photorealistic in the sky view — every status 200, no failures, no `blockedReason` on either side. That last one also settles the worry that the PWA's `statuses: [0, 200]` rule implied opaque ion responses: they come back 200, so they are CORS and `credentialless` leaves them alone. Two providers fail identically with and without the headers and so are unrelated: `api.maptiler.com` answers 403 (its key looks domain-restricted the way the ion token is) and ArcGIS terrain makes no requests at all. PostHog is the one consumer still unverified. What makes it a separate tool rather than a column is its cost. The call resolves only after a natural major collection: measured over six calls, **14–19 s each, mean 17 s**. It does not perturb what it measures (frame median 8.33 ms both during a call and quiet), but at one call per step a 36-step sweep would grow by ten minutes. Where it fits is once before and once after a run — about 34 s for two real live sets of the same scene, which is the leak check `heapDriftPct` could not be — or a short dedicated sweep of three counts for absolute footprints. Chrome only. Granularity is not the limitation: measured, eight consecutive reads give eight distinct non-round values with and without `--enable-precise-memory-info`. - **The whole catalog is loaded before the first step** (`catalog.ensureAll()`), so a run measures drawing rather than downloading. Counts are sliced from the sorted catalog, so "the first 500" is the same 500 every time — but which 500 depends on the route's preset, and their orbit classes decide what can be drawn. Pass `{ tag: "Starlink" }` to pin the population. - **Ground stations are off by default.** One station switches pass prediction on for every satellite, which is a large cost that has nothing to do with drawing. Give it its own run. ## Shape One file knows about Cesium. Everything else is pure and unit-tested (`benchmark.test.ts`), which is what lets the analysis be trusted without a browser in the loop: - `frameSampler.ts` — timestamps in, percentiles out. No Cesium, no DOM. - `benchmarkPlan.ts` — the three-axis sweep matrix, pure. - `report.ts` — rows, the linear fits (frame time and memory), the marginal-cost, propagation and drift differencing, csv/json. - `benchmarkRunner.ts` — the loop, over a `BenchmarkTarget` interface. - `cesiumBenchmarkTarget.ts` — the only file that knows what a viewer is. - `framePump.ts` — frames for a page the browser stopped presenting. Off unless `?framepump` asks; the queue in it is pure and tested. - `index.ts` — the console handle, `window.bench`. - `../../components/BenchmarkPanel.vue` — the in-browser half. Nothing here is in the bundle a normal visitor downloads: the panel is an async component, so the whole framework is a chunk that loads only when the switch goes on, and it is excluded from the PWA precache (`vite.config.ts`) so the glob does not pull it down anyway. `CesiumPerformanceStats` (behind the `showFps` toggle) is separate and untouched. It is deliberately not replaced: Cesium's own FPS counter is an independent second opinion on the panel's headline figure, computed by code this framework does not own, which is why the panel is positioned to leave it visible.