Spaces:
Configuration error
Configuration error
| # 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. | |