Luxar Viewer Performance Audit (2026-09-04)
Fetch → cache → decode → process → commit → render, audited by reading the code
(four parallel read-only passes over packages/luxar-viewer/src) and by
measuring the production build (vite build of 67ec42091, served by
vite preview) in headless Chromium 1234 on the real GPU
(ANGLE Metal Renderer: Apple M4 Max, 40 cores, 128 GB, 1600×1000 canvas,
DPR pinned to 1 unless stated). Every number below was measured; every code
claim was read. Items marked [inferred] were not directly verified.
Status. This is the dated record that drove the September 2026 viewer performance work (PR #2528, follow-ups in #2527). Every finding below is now either landed, or dismissed with a measurement in that PR’s description — including finding 8 (OPFS write-queue drops): its fix landed, then #2528’s byte cap on the queue brought the cmu1 drops back (0 → 3 966), because the cap was
max(resolved L1 size, 64 MB)and a pending write’s buffer is the same one L1 already holds and bounds. #2561 re-sizes that cap from the non-cache heap remainder instead — a quarter of it, so 328 MB on a 4 GiB Chrome heap like this machine’s, which clears both the 214 MB cmu1 streams before the cap binds (peak pending ≈ cumulative streamed, since its 10 961 writes drain far slower than they arrive — [inferred], not a measured retention) and its 277 MB whole-store ceiling, though only by 1.18× on the latter; the allowance is heap-relative, so for this scene it binds again below roughly a 2.6 GiB heap, which is intended. #2561 also flips the overflow policy to drop-the-arrival, so the oldest end drains in order. The cmu1 before/after row below has NOT been re-measured on the audit machine since; what is in place is the fix plus an unconditional assertion in the audit bench (§8) thatopfsDroppedis 0 — load-phase and end-of-run on every cold repetition, and on the warm revisit — with a second assertion that the resolved allowance still covers what the scene streamed, so a cap that binds again shows up as a failed bench run rather than only as a slower revisit. The ad-hoc probe kit it describes was replaced by a repeatable harness:pnpm test:perf:e2e -g "viewer audit"(see §8). Numbers in §3 are the BEFORE state; the PR carries the same-browser before/after table.
Probe kit (Playwright scripts + analysers) lived in delme/viewer-perf-probes/
(not committed); raw results (JSON, request logs, console logs, traces,
screenshots) were in the job tmp dir. See §8 for how to re-run today.
1. Headline findings, ranked by impact
# |
Finding |
Evidence |
Impact |
|---|---|---|---|
1 |
Lower DPR makes overdraw-heavy scenes SLOWER, and the adaptive-DPR controller drives DPR down when fps drops. 1.5 M points framed into ~1 600 px: DPR 1.0 → 41.7 ms/frame, 0.9 → 50, 0.75 → 75, 0.5 → 83 ms. 29.6 M 2-D splats: 1.0 → 108 ms, 0.5 → 192 ms. Same 1.5 M points zoomed so the cube covers ~24 k px: 8.3 ms (120 fps). |
§3.2, §3.3 |
Frame rate collapses exactly where the controller “helps”; the U-shape probe catches it only after a 1.5 s excursion, and it then parks at 0.9 (20 % slower than 1.0). |
2 |
No screen-space density cap for points/splats. Additive ladders always stream to 100 %; partition parts are only frustum-culled; |
§3.2, |
GPU wall is overdraw, not element count. A projected-density guard (keep ≈ 1–4 elements/px) would give 5–10× on these views and stop the ladder early on hosted loads. |
3 |
Every scene is loaded twice at startup. |
§3.4, console timelines |
Doubles CPU/GPU work on the critical path right after first paint. On cmu1 the second pass + refinement blocked the main thread 5.9 s of a 10 s load (25 long tasks, max 937 ms). |
4 |
GSplat projection for ndim ≤ 3 runs on the main thread in WASM ( |
§3.4 |
Most real scenes are 2-D/3-D splats, so the 15-worker pool sits idle while the UI freezes. |
5 |
Worker pool is created lazily and each of the 16 workers fetches + compiles its own WASM. 17 |
§3.1 |
Under real network conditions worker bootstrap, not data, sets first-paint. Invisible on localhost (ready in 70 ms). |
6 |
Small chunks are RTT-bound on HTTP/1.1. dense (1 504 chunks, p50 4.2 KB): 10.6 s on HTTP/1.1 (7.4 Mbps of 25) vs 4.0 s on HTTP/2 (19.6 Mbps). ct (621 chunks, p50 20 KB): 6.4 s vs 4.7 s. |
§3.1 |
|
7 |
Refinement is frame-bound, not data-bound. Fully cached neuromast timepoint (16 nodes, ~110 k splats, 0 network requests): first commit 14 ms, full ladder 40 ms because |
§3.5 |
Playback of cached 4-D data caps near 25 fps regardless of CPU. |
8 |
OPFS write queue drops under burst ( |
§3.1 |
The L2 tier silently fails for exactly the scenes that need it. |
9 |
Blend warm-up scales with node count, not program count. 4 keeper materials per node compile one per idle callback: 100-node scene → 400 idle callbacks, 3.3 s until readiness (5 s budget then “releasing readiness”). Only 9 distinct GL programs exist. Keepers are retained per node. |
§3.4 |
Harmless at 2 nodes; a 1 000-part partition would spend ~30 s and hold 4 000 material clones. |
10 |
4 wasted 404 probes per node for optional arrays (zarr.json + 3 v2 docs): 404 of 820 requests on the 100-node scene. |
§3.1 |
One extra RTT-wave per node on hosted stores; cacheable but never cached. |
Things that are fine and were suspected: bloom (15 passes) costs nothing measurable at 1600×1000 (ct/celegans/neuromast: 120 fps with and without); the depth-sort worker is not a bottleneck; main-thread decompression is negligible for small chunks (dense load: main thread 91 % idle); 654 k 3-D splats render at 120 fps; the 100-node scene draws 101 calls at 120 fps; adaptive-DPR does restore full DPR at idle; per-update abort/coalescing works (no stale-commit artefacts seen).
2. Current state of the code (what exists)
Condensed from the four code passes; file references are the anchors to read.
2.1 Fetch + cache
Three chunk tiers + a per-slice geometry cache: L0 decoded typed arrays (byte-LRU, 200 MB,
cache/decompressed-chunk-cache/*, Proxy on zarritagetChunk, clones every decoded chunk atcached-zarr-array.ts:200); L1 compressed bytes (segmented LRU, 100 MB, metadata segment); L2 OPFS (2 GB, backgroundOpfsWriteQueue, delete barriers, circuit breaker); S-cache post-projection slice payloads (cache/slice-cache.ts, ~1.4 GB budget here).Heap-aware budgets (
cache/heap-budget.ts),?cacheBudgetMB, content-hash validation with format-2/3 probing (validation-queue.ts), same-key request coalescing (bypassed when the caller passes a signal,multi-level-caching-store.ts:492), global 64-wide fetch gate (utils/fetch-concurrency.ts), retry with jittered backoff, body cancellation, zip range-reader with EOCD stitching.Decompression is numcodecs Emscripten WASM on the main thread (blosc/zstd/lz4); decode (dequantise/LUT/broadcast) goes to the worker pool above 1 000 elements and returns transferables.
Prefetch: ±1 chunk adjacency (
chunk-prefetcher.ts, silently no-op without registered bounds), range warming, t+1 slice prefetch on shadow loaders.
2.2 Processing
Comlink pool of
hardwareConcurrency − 1workers (15 here), least-busy dispatch, per-call timeout, pool-wide abort; created lazily on first decode; each worker runsinitWasm()itself (workers/data-worker/initialize.ts:35).Points: decode in workers, projection + radii + compaction + 3 bounds passes on the main thread (
data/points/projection.ts). GSplats: fusedproject_gsplats_nd_to_3dkernel; worker only whenndim > 3; inputs structured-cloned (not transferred) to keep the S-cache intact. Lines symmetric. Mesh in-process.WASM: SIMD,
opt-level 3, LTO, 50 KB; no threads; every call copies inputs into the wasm heap and outputs back (13 arrays in for the gsplat kernel).Depth sort: dedicated worker, 65 536-bucket radix, triggers on commit and on >3° rotation / 5 % view-axis translation, double-buffered
aSortedIndexwith chunked upload, sync first sort ≤ 250 k.Accumulators allocate-once with 1.5× growth (monotonic capacity).
2.3 Orchestration
loadScene(data/scene-loader/lifecycle/load-scene.ts) is a strict serial chain: dispose → caches →openStore(consolidated metadata) → root →buildSceneGraph(serial N+1zarr.open, cheap with.zmetadata) →loadSceneNodes(8-wide per parent, FIFO in authored order, byte-gated) → overlays → refinement kick.Each leaf attaches a placeholder, then fetch → process → synchronous atomic commit (
commit/README.md: no async inside commit), so pixels appear per node.updateView: single pending slot, latest-wins, in-flight update aborted, pass-waiters resolve on the next commit; no debounce.Refinement (
progressive/refinement.ts): one rung perscheduleFrame, loaders serial, holds the update lock;RefinementResidencyBudgetis a refusal gate (never evicts) capped at 512 MiB (heap-budget.ts:41).LOD: screen-area selector with asymmetric 10 % hysteresis for
kind=lod; partitions frustum-culled per frame against a cached per-part footprint invalidated by geometry commits (lod-group-registry.ts:1441-1470); additive ladders have no coverage-based stop.
2.4 Rendering
On-demand rAF loop with 2 s idle stop and idle full-DPR frame; frame pacing after two >250 ms frames.
Instanced unit quads + one RGBA32F DataTexture per node (3/6/4 texels per element), partial row uploads with a 75 % full-upload knee,
GPUBufferPoolcapacity buckets + byte budget shared with LOD eviction (hidden levels only).Per-node materials (texture binding), 9 distinct programs in practice; compile-time
#ifdefvariants; blend warm-up keepers.Post: HDR half-float target → optional bloom (8 mips) → mega-shader → optional FXAA; defaults off except tone mapping. LDR half-float target allocated even when FXAA is off. Bloom/FXAA/MSAA/SSAA all opt-in; demos enable bloom.
Adaptive DPR: 500 ms evaluation over a 1 s window, ×0.9 down / ×1.05 up, U-shape probe with learned floors and backoff, idle restore, default cap 1.0.
Points:
minPointSize = 1.5 * max(uPixelRatio, 1)framebuffer pixels (materials/point/shader-glsl.ts:190). GSplats: extent clamp + coverage fade bound overdraw per instance, not per pixel.
3. Measurements
Scenes: performance_benchmark_example (bench: 100 nodes × 1 000 points, format 2),
dense_cubic_gradient_example (dense: 1 M-point lattice + 500 k stars, 1 503 chunks, 9.8 MB),
gsplats_3d_ct_totalsegmentator (ct: 654 k splats, 5 nodes, 621 chunks, 13 MB, bloom on),
gsplats_4d_celegans_tracking (4-D, ~1.5 k splats per timepoint),
gsplats_4d_neuromast_2ch (16 nodes, 11.4 M splats, ~110 k per timepoint, 8-rung ladders),
gsplats_2d_cmu1_pathology (cmu1: 12 parts, 29.6 M 2-D splats, 10 961 chunks, 277 MB).
3.1 Loading
Localhost (no throttle). “scene loaded” = Scene loaded successfully console line, relative to navigation start.
scene |
load start |
workers ready |
scene loaded |
first |
notes |
|---|---|---|---|---|---|
bench |
125 ms |
not needed (≤1 000 pts/node) |
404 ms |
440 ms |
404 requests were 404s (4 per node) |
dense |
118 |
335 (created at 266) |
504 |
600–609 (after the 2nd pass) |
1 504 chunks, p50 16 ms each |
ct |
– |
– |
~600 |
– |
621 chunks |
cmu1 |
121 |
– |
3 593 |
– |
2nd pass 3 888→4 903, refinement →8 103, GPU evictions →9 001, readiness 9 677 |
neuromast (t=0) |
117 |
176 |
198 |
– |
298 requests, 5.4 MB |
Throttled to 25 Mbps / 30 ms RTT (CDP emulation), same scenes, full-load wall time and link utilisation:
scene |
server |
wall |
avg Mbps (of 25) |
workers ready |
chunk p50 duration |
|---|---|---|---|---|---|
dense |
HTTP/1.1 (6 conns) |
10.6 s |
7.4 |
4.06 s |
436 ms |
dense |
HTTP/2 |
4.0 s |
19.6 |
4.31 s |
340 ms |
ct |
HTTP/1.1 |
6.4 s |
16.4 |
1.27 s |
639 ms |
ct |
HTTP/2 |
4.7 s |
22.6 |
4.51 s |
2 437 ms |
ct, warm OPFS |
HTTP/1.1 |
1.1 s (0 network chunks, 621 L2 reads) |
– |
1.10 s |
– |
dense, cold, persistent profile |
HTTP/1.1 |
11.7 s |
– |
5.20 s |
– |
dense, warm OPFS |
HTTP/1.1 |
1.2 s (0 network chunks, 1 504 L2 reads) |
– |
1.05 s |
– |
Request mix per session (any scene): 22 JS bundle files, 16 worker scripts, 17 luxar_wasm_bg.wasm, 5–10 metadata probes (1–6 of them 404).
Cache counters after a cold load: L0 hits == misses on every 3-D scene (the second pass); L2 write-queue dropped = 252 (dense, ephemeral context; 0 in the persistent-profile run, so it is timing-dependent), 9 775 (cmu1), 0 (ct, bench).
3.2 Steady-state frame rate (continuous render, probe-frames.mjs)
rAF cadence while forcing a frame every rAF; main thread idle >88 % in all rows, so this is GPU time. Bloom on/off made no difference in any row.
scene |
view |
DPR 1.0 |
0.9 |
0.75 |
0.5 |
0.35 |
|---|---|---|---|---|---|---|
dense 1.5 M pts |
default fit (cube ≈ 40×40 px) |
41.7 ms |
50 |
75 |
83 |
84 |
dense 1.5 M pts |
zoomed (cube ≈ 155×155 px) |
8.3 ms |
– |
– |
16.7 |
– |
cmu1 29.6 M splats |
default fit |
108 ms |
126 |
142 |
192 |
– |
ct 654 k splats |
default |
8.3 (120 fps) |
– |
– |
8.3 |
– |
bench 100 k pts / 101 draws |
default |
8.3 |
– |
– |
8.3 |
– |
neuromast 110 k splats |
default |
8.3 |
– |
– |
8.3 |
– |
celegans |
default |
8.3 |
– |
– |
8.3 |
– |
The dense and cmu1 rows are the pathology: cost tracks elements per pixel, not pixels. Lowering DPR concentrates the same ≥1.5 px sprites into fewer tiles and gets slower. Zooming the same 1.5 M points to 15× the area made them 5× faster.
Main-thread trace during the dense orbit: 8 % busy, animate self-time 114 ms over 15 s → the render loop CPU cost is negligible; the wall is the GPU.
3.3 Adaptive DPR behaviour observed (dense, console)
643 Scaled down: DPR 1.00 → 0.90 (FPS: 8.7, probing for U-shape) <- fired DURING load
2175 Probe accepted at DPR 0.90: 8.7 → 19.2 FPS (×2.21) <- load finished, misattributed
11445 Probe rejected at DPR 0.81: FPS 23.4 → 13.4 (×0.57) … floor set, retry in 30s
The controller reads loading hitches as GPU load, steps down, and its probe then credits the end of loading to the lower DPR (accepted ×2.21). The next step is correctly refused, and it parks at 0.9, which we measured at 50 ms vs 41.7 ms at 1.0. The idle restore to 1.0 works. It also repeatedly fired on bench2 (100 k points) during load.
3.4 Main-thread attribution on the large scene (cmu1, CDP trace + sampling profile)
window |
main thread busy |
>50 ms tasks |
top self-time (main thread only) |
|---|---|---|---|
0.1–3.6 s (first pass) |
103 % (overlapping) |
11 (max 259 ms) |
luxar WASM 11 %, blob WASM (blosc) 4 %, |
3.6–9.7 s (v1 re-load + refinement + GPU evictions) |
91 % |
14 (max 634 ms; |
luxar WASM 39 % ( |
Long tasks over the 10 s load: 25, summing 5 885 ms (0.6–0.95 s blocked per second in seconds 5–9). The 100-node bench scene’s 3.3 s “gap” between first paint and readiness is 400 idle-callback steps of the blend warm-up (trace: 400 FireIdleCallback, main thread 7 % busy).
3.5 4-D playback (neuromast, probe-playback.mjs, step time dimension, --full waits for isLoading=false)
loop |
first commit (median) |
full ladder (median) |
requests/step |
KB/step |
elements at first commit → full |
|---|---|---|---|---|---|
0 cold |
32 ms |
57 ms |
100 |
651 |
37 k → 80 k |
1 |
12 ms |
39 ms |
13 |
52 |
105 k → 111 k |
2 fully cached |
14 ms |
40 ms |
0 |
0 |
111 k → 111 k |
Zero I/O still costs 40 ms per timepoint: the ladder is paced at one rung per animation frame. celegans (tiny per-timepoint) stepped in 1–4 ms.
4. Optimization opportunities, ranked
Each item names the mechanism, the evidence, and the expected effect. “Verified” means the code path was read and the effect measured or directly implied by a measurement.
High impact
Projected-density guard for points and splats (render + load). Compute per node each frame:
elements / projectedBboxArea(px). Above a threshold (say 4 elements/px) either (a) cap the additive ladder rung the refinement loop is allowed to commit (load side:refinement.tscurrently has no such rule), and (b) stochastically skip instances in the vertex shader by a per-nodeuKeepFraction(render side; the instance ID gives a free hash). Expected: dense default view 42 → <10 ms; cmu1 default view 108 → ~10–15 ms and a fraction of the bytes on hosted loads. The screen-area selector machinery (lod-selector-math.ts) already projects bboxes every frame, so the metric is nearly free.Remove the startup double pass.
setup.ts:187firesupdateAllNDNodes()unconditionally afterloadScenealready committed the initial view. Skip it when the resolved view state equals the oneloadSceneused (or haveloadScenepublish its view version and let the init pass no-op). Verified by L0 hit/miss parity on every 3-D scene and by the cmu1 timeline (1 s re-load + a second accumulator growth per node). Expected: halve post-first-paint CPU/GPU work; remove ~1 s of the cmu1 freeze and the duplicate 1.1 M-splat texture uploads.Move ndim ≤ 3 gsplat projection (and points projection) off the main thread. Flip the
ndim > 3gate indata-processor-gsplats.ts:294to a size gate; for points, moveprojectPointsTo3D+ compaction into the pool. Transfer inputs when the S-cache does not need them (or copy in the worker). Expected: the 39 % WASM share of cmu1’s main-thread time moves to 15 idle workers; long tasks shrink to the commit (upload) itself.Eager worker pool + shared compiled WASM. Create the pool at boot (before
openStore), fetch andWebAssembly.compileonce on the main thread and post theWebAssembly.Moduleto workers (structured-clone-able). Verified: 17 wasm fetches + 16 worker-script fetches per session; pool ready at 4.3–4.5 s under a 25 Mbps throttle, gating first paint; warm-cache ct load bounded by pool startup. Expected on hosted: first paint moves from “when the pool finally starts” to “when the first chunks land”; also prioritise worker assets over chunk fetches (fetch(..., {priority: 'high'})).Coverage-aware additive streaming for hosted loads. Same metric as (1) on the load side: stop committing rungs beyond ~4 elements/px for the current view, resume when the camera zooms. cmu1 would stop after ~2 of 8 rungs at the opening framing (the memory notes it takes 68 s hosted to stream everything). This is the largest bandwidth win available without changing the format.
Medium impact
Batch the refinement pass.
refinement.ts:203awaits loaders serially and yields one rAF per rung. Allow up to N loaders (or a byte budget) per frame and commit all rungs already resident in the S-cache in one frame. Verified: fully cached timepoint costs 40 ms; a one-frame commit would be ≈ 8 ms. Playback of cached 4-D data goes from ~25 fps to display rate.Adaptive DPR: gate on GPU-boundness and direction. Do not evaluate while
isLoadingor while a commit landed in the window; compare the probe against a same-load-state baseline (the first probe credited the end of loading to the DPR step, §3.3); and treat “lower DPR got slower” as evidence of overdraw, which should feed (1), not DPR. Verified: monotonic slow-down at lower DPR on both overdraw scenes.OPFS write queue. Raise
maxDepthto cover a burst (chunks/session is 10 k+ here), or coalesce writes into fewer, larger files, or drop to a “write after load settles” policy instead of drop-oldest. Verified 89 % dropped on cmu1. Also?noOpfsskipping?clearCache(multi-level-caching-store.ts:271). What landed: the depth cap went to 16 384, the retained-byte cap is a quarter of the non-cache heap remainder (#2528, #2561), and overflow now drops the arriving write rather than the oldest — so the “write after load settles” alternative was never needed. The?clearCachenote is untouched.Chunk sizing guidance for HTTP/1.1 hosts.
luxar optimizealready exists; thedenseexample is authored at 4 KB chunks (1 504 requests for 9.8 MB) and is 2.6× slower on HTTP/1.1 than HTTP/2. Makeluxar servespeak HTTP/2 (or at least adviseoptimize --profile hosting), and haveluxar info --statsflag stores whose chunk count exceeds ~1 request per 30 KB.Cut the 404 probes. Gate
colors/radii/sharpnessesopens on the node attrs or the consolidated metadata (the loader already has the group listing), asscalarsis (points-spatial-index-loader.ts:424). 4 requests per node on hosted stores, uncached.Blend warm-up: one keeper per distinct program, not per node. Key keepers by
getProgramCacheKey(9 programs) instead of per source material; drop the retained per-node clones. Verified: 400 idle steps and 3.3 s readiness on 100 nodes.
Lower impact / hygiene (verified in code, not measured)
Drop the L0 clone (
cached-zarr-array.ts:200); zarrita copies into the selection anyway.Transfer gsplat projection inputs when no S-cache retention is needed; pool the four worst-case output buffers.
RGBA32F → RGBA16F element textures (planned in
gpu-byte-budget.ts:27-41), halves resident GPU bytes and thetexSubImage2Dshare.Free the LDR half-float target when FXAA is off (
resource-lifecycle.ts:71,118); don’tdispose()the HDR target on DPR steps when sample count is unchanged; coalesce adaptive-DPR resizes.Partition frustum bounds: the loaded-geometry footprint is now cached per part and dirtied by geometry commits, while transformed authored boxes reuse caller-owned storage (#2605). An h2afva-shaped 44-part / 4,448-node selector microbenchmark (300 static frames, five runs) dropped the median registry pass from 2.93 ms/frame and 44
Box3.setFromObjectsubtree walks/frame to 0.061 ms/frame and zero steady-state walks. The O(groups²) containment relation scan remains insrc/rendering/depth-sort-coordinator/render-order.ts:781-827, while its per-frame sphere and graph scratch allocations are removed (#2636).gpuBufferPool.beginFrame()is per commit cycle, not per frame (gpu-buffer-pool.ts:172vsatomic-commit.ts:115), soevictionFramesis mis-unit’d.WebGPU backend ignores
updateRanges(full re-upload) — parity gap for streaming scenes.get()returnsundefinedafter exhausted retries → zarrita fills silently (multi-level-caching-store.ts:403,413).Console volume: 4 717 console lines for the 100-node load (47 per node), all through the interceptor. Cheap here, but it scales with node count.
5. Hypotheses tested and rejected
“Bloom is expensive.” 15 passes per frame at 1600×1000: no measurable change on any scene (ct, celegans, neuromast, dense, cmu1 all identical with bloom on/off).
“The sort worker was spinning for 3.3 s on the bench scene.” A sampling artefact (
_t= the worker’s asyncinitialize); per-thread busy time shows the sort worker idle; the gap was the blend warm-up.“Main-thread blosc decompression dominates loads.” Only ~11 % of main-thread samples even on the 277 MB scene; negligible on small-chunk scenes. It matters, but behind projection and upload.
“WebGL timer queries measure GPU frame time.”
EXT_disjoint_timer_query_webgl2on ANGLE/Metal reported 40–56 ms for scenes that render at a solid 120 fps and 253 ms for cmu1 (actual 108 ms), andgl.finish()returned in 0.1 ms. Do not use it here; use the rAF cadence under forced continuous rendering (probe-frames.mjs).“100 draw calls are slow.” bench renders 101 calls at 120 fps under forced continuous rendering; one earlier drag-orbit reading of 33 ms did not reproduce (that run also logged an adaptive-DPR step during load), so the drag-based number is treated as instrumentation noise.
“The 64-wide fetch gate is the hosted bottleneck.” On HTTP/2 the link ran at 78–90 % utilisation; the gate is fine. On HTTP/1.1 the browser’s 6 connections are the wall, not the gate.
6. Observability gaps found while measuring
No time-to-first-paint / time-to-full-load stamps anywhere; the monitor has cumulative counters only. The console lines (
Loading scene from,Loaded N … for,[GEOM] vN,Scene loaded successfully,Progressive: k/n LODs loaded) are the only timeline. Aperformance.markat each would make this audit a one-liner.renderer.info(draw calls, triangles, programs) is not surfaced;info.autoResetmakes it a per-pass counter.__luxarDebug.getState()is installed only after the blend warm-up settles (up to 5 s after first paint), so readiness-gated probes under-observe the load (this bit me twice).getLodLoadStats()is off by default and the only view into lazy/additive level loads.Adaptive-DPR decisions are logged but not exposed as state (current operating DPR, floor, last verdict).
7. What was measured with what
Harness:
@playwright/test1.62 driving Chromium 1234 headless with--use-angle=metal --ignore-gpu-blocklist(real GPU; verified byUNMASKED_RENDERER_WEBGL). Datasets served from the repo root by a CORShttp.server(HTTP/1.1) on :8099 and a Nodehttp2TLS server on :8443; viewer fromvite previewon :4377.Load probe (
probe-load.mjs): every request start/end/size/status, full console with timestamps,PerformanceObserver('longtask'), poll ofgetState().totalElements/isLoading,cache.getStats(),workers.getStats(), CDPPerformance.getMetrics, optional CDP network throttle (--net=hosted= 25 Mbps / 30 ms), optional CPU throttle, optional Chrome trace with sampling profiler (--trace), optional persistent profile for warm-cache runs (--profile=dir).Frame probe (
probe-frames.mjs): forces a render every rAF for 3 s per variant (base, bloom off/on, DPR sweep viaadaptiveDPRManager.setManualDPR, auto-rotate) and reports cadence p50/p95 and passes per rAF fromrenderer.info.render.frame.Playback probe (
probe-playback.mjs):app.setDimensionValue+awaitDimensionUpdate, optional wait forisLoading=false, per-step requests/bytes/long tasks, S-cache and L0 counters.Trace analyser (
analyze-trace.mjs): renderer main-thread busy %, long tasks, self-time by trace event, main-thread-only sampling profile restricted to a time window.Request analyser (
analyze-requests.mjs): in-flight and Mbps per 250 ms bin over the load window, request kinds, chunk-size percentiles.
8. Re-running
The measurements above are reproduced by the committed perf bench
(packages/luxar-viewer/src/tests/e2e/viewer-audit-perf-bench.spec.ts), which
replaced the ad-hoc probe kit. It writes perf-results/<git sha>/results.json
(medians of 3 per metric) and scripts/perf-diff.mjs diffs two runs.
# data server: the repo root (datasets/ underneath), CORS, HTTP/1.1 like the audit
python3 -m http.server 8099 --bind 127.0.0.1 & # or any CORS-enabled static server
cd packages/luxar-viewer && pnpm build # production bundle (audit numbers are production)
LUXAR_PERF_BROWSER=chromium LUXAR_PERF_PREVIEW=1 LUXAR_PERF_HEADLESS=1 \
LUXAR_PERF_CHROME_ARGS=--use-angle=metal LUXAR_PERF_PORT=4380 \
LUXAR_PERF_DATA_BASE=http://127.0.0.1:8099 \
pnpm test:perf:e2e -g "viewer audit" # LUXAR_PERF_AUDIT_SCENES=dense-points,cmu1-2d to subset
node scripts/perf-diff.mjs perf-results/<before>/results.json perf-results/<after>/results.json
Two measurement rules learned here: frame cadence is taken under forced
continuous render (renderOnce() every rAF), never with
EXT_disjoint_timer_query (unreliable on ANGLE/Metal); and frame rows come
from the bundled Chromium (LUXAR_PERF_BROWSER=chromium) because headless
system Chrome on macOS intermittently caps rAF at 30 Hz. __luxarDebug.getPerf()
exposes the load timeline, renderer counters, adaptive-DPR diagnostics and the
per-node projected density the bench reads.