Digital Energy Factory — performance profile and ranked bottleneck report
2026-09-19. Measurement only; no renderer change was made. Instrumentation added to the app (all default-off, no visual effect): object names on the scene layers, runtime.perf timing accumulators, a ?profile=1 flag that pins the resolution, ?ui=off, and a runtime post-processing mask mirroring the existing ?fx= parameter.
Machine: the development MacBook (Apple GPU, ANGLE/Metal), headless Chrome. Viewport 1920×1200 CSS at device pixel ratio 1.5, which the app caps to a 2880×1800 drawing buffer — the same pixel count as the Retina window in the stutter screenshot. A second set at dpr 1.0 (1920×1200) is included for comparison.
Tools (kept in the repo): scripts/profile.mjs (per-config page loads + Chrome trace), scripts/profile2.mjs (single session, features toggled at runtime, paired R→T→R→T against the full scene), CDP CPU sampling, and a frame-time recorder. Raw results are in factory/.build/profile/.
1. Headline
The stutter has one dominant cause, and it is not scene complexity. One scanned CC0 model (portable_generator, Poly Haven) ships a glass material with KHR_materials_transmission (portable_generator_glass, transmission = 1). three.js therefore runs renderTransmissionPass every frame: it re-renders the entire opaque scene into a full-resolution half-float render target and generates its mipmap chain, so that the glass can refract it. That happens whether or not the generator is on screen (its instance count is zero from the overview) because the trigger is "a visible object with a transmissive material" — and the instanced mesh is visible with count 0.
| Full scene, 2880×1800, camera orbiting (uncapped) | As shipped | Transmission = 0 |
|---|---|---|
| Average frame time | 41–56 ms | 24 ms |
| Static frame time | 36–49 ms | 20.4 ms |
| 1 % low (moving) | 8–13 fps | 18 fps |
Share of the frame spent in renderTransmissionPass (CPU profile, inclusive) |
83 % | 0 |
Under vsync at the same resolution: 24 fps moving with 197 frames over 33 ms in 10 s (as shipped) → 40.6 fps with 5 such frames (transmission off). At dpr 1.0 with transmission off the full scene holds 60 fps with zero hitches.
Everything else measured below is real but second-order: shadows ≈ 4–6 ms, N8AO ≈ 4–5 ms, PV ≈ 5–7 ms, depth-of-field in facility view ≈ 8 ms, PBR maps ≈ 2.7 ms. Vegetation, workers, vehicles, decals, HUD, simulation, flows, GC and React are each under 1 ms or zero.
2. Method and what had to be corrected
- Average FPS was discarded as the metric. Every configuration reports mean, p50, p95, p99, 1 % low, 0.1 % low, worst frame, hitch counts (> 33 ms, > 50 ms), coefficient of variation and frame-to-frame jitter, separately for a static camera and for a scripted 6–10 s orbit that also dips through the 110 m vegetation LOD boundary.
- Run-to-run drift was measured before anything was ranked. Sequential single-shot configurations differed by up to 2× for identical settings (stage B: 137 fps cold, 68 fps after five minutes of load) — GPU clock and thermal state. The stage ladder was therefore measured up and down and averaged, and every feature toggle was measured as reference → toggle → reference → toggle inside one page session, reporting the delta against the adjacent references. Reference drift across the 16-toggle series stayed within 20.3–21.2 ms post-fix (35.8–36.8 ms pre-fix), so the paired deltas are reliable to about ±0.5 ms.
- The headless frame-rate cap was removed (
--disable-frame-rate-limit --disable-gpu-vsync) so raw cost is measurable; capped runs were repeated for pacing. - The adaptive-dpr fallback added yesterday had to be pinned (
?profile=1), because it silently halved the pixel count for any configuration slower than 42 fps and made the first ladder inconsistent. - A texture-memory hypothesis was tested and rejected (capping every texture to 256 px, 784 MB → 36 MB, changed nothing).
- The CPU profile settled it. With the transmission pass active, 83 % of sampled time is inside
renderTransmissionPass→upload/setTexture2D(target upload and mipmap generation). Without it, self-time is 61 %drawElements, 18 %bindVertexArray, 3 %uniformMatrix4fv: the main thread is blocked inside GL calls, i.e. waiting on the GPU queue. JavaScript logic (projectObject,setProgram, ReactjsxDEV) totals under 5 %. The "main thread 120 % busy" in the Chrome trace is GPU back-pressure, not script.
3. Inventory (full scene)
| Meshes | 711 (61 instanced, ~69,200 instances, mostly vegetation) |
| Materials / shader programs | 606 / 55 |
| Geometries / textures (GL) | 641 / 169 |
| Texture memory estimate | 784 MB uncompressed (13 textures at 2048², 93 at 1024²; largest: grass, concrete, asphalt, gravel, metal 2k diffuse+normal pairs at 21 MB each) |
| Shadow maps | 4096² sun + 2 × 1024² spots = 72 MB |
| Post targets at 2880×1800 | 40 MB per half-float target (N8AO half-res, bloom mips, SMAA, DoF) |
| Draw calls (full, overview) | 754–890 per frame, of which ~410 are the ground decals |
| Triangles (full, incl. shadow pass) | 11.9 M (transmission off) / 15.3 M (as shipped: the pass re-draws opaque geometry) |
| Lights | 14 (sun + hemisphere + ambient + 10-spot pool), 3 shadow casting |
| Simulation step CPU | 0.005–0.02 ms per frame (max 0.6 ms at a midnight rollover) |
| Traffic/agents CPU | 0.03–0.08 ms per frame |
4. Stage ladder A → K (2880×1800, uncapped, mean of ascending and descending passes)
As shipped (transmission pass active from stage B onward, because the generator is part of the props layer)
| Stage | Static avg FPS | Static 1% low | Moving avg FPS | Moving 1% low | 0.1% low | Avg ms (moving) | Worst ms | Hitches >33 ms | Draw calls | Triangles |
|---|---|---|---|---|---|---|---|---|---|---|
| A empty scene | 862 | 61 | 353 | 40 | 32 | 2.8 | 2,208* | 2 | 1 | 0 |
| B factory geometry only | 79 | 31 | 65 | 31 | 31 | 15.3 | 33 | 0 | 153 | 1.13 M |
| C + PBR materials | 60 | 27 | 54 | 27 | 27 | 18.4 | 37 | 0 | 153 | 1.12 M |
| D + PV | 39 | 14 | 32 | 14 | 14 | 31.3 | 72 | 4 | 180 | 3.32 M |
| E + vegetation | 39 | 14 | 31 | 14 | 13 | 31.8 | 74 | 4 | 202 | 6.48 M |
| F + shadows | 32 | 12 | 27 | 12 | 12 | 36.9 | 89 | 8 | 379 | 14.9 M |
| G + AO | 30 | 11 | 25 | 11 | 11 | 39.4 | 93 | 12 | 391 | 14.1 M |
| H + workers/vehicles | 29 | 11 | 25 | 11 | 11 | 40.8 | 102 | 14 | 426 | 14.6 M |
| I + simulation | 29 | 11 | 25 | 11 | 11 | 40.0 | 97 | 13 | 428 | 14.8 M |
| J + HUD | 29 | 11 | 25 | 11 | 10 | 40.4 | 98 | 14 | 431 | 15.0 M |
| K full (+ decals, bloom, vignette, SMAA) | 28 | 8 | 25 | 8 | 8 | 40.6 | 146 | 22 | 861 | 15.3 M |
* the 2.2 s worst frame in stage A is the material recompile caused by the toggle itself (shadows and maps restored on 606 materials), not scene cost.
Transmission = 0 (same ladder, same session type)
| Stage | Static avg FPS | Static 1% low | Moving avg FPS | Moving 1% low | 0.1% low | Avg ms (moving) | Worst ms | Hitches >33 ms | Draw calls | Triangles |
|---|---|---|---|---|---|---|---|---|---|---|
| A empty scene | 513 | 95 | 282 | 40 | 33 | 3.6 | 2,209* | 2 | 1 | 0 |
| B factory geometry only | 161 | 93 | 128 | 44 | 35 | 7.8 | 83 | 1 | 79 | 0.56 M |
| C + PBR materials | 119 | 81 | 105 | 50 | 48 | 9.5 | 21 | 0 | 78 | 0.56 M |
| D + PV | 77 | 59 | 63 | 30 | 28 | 16.0 | 38 | 2 | 92 | 1.71 M |
| E + vegetation | 77 | 58 | 62 | 30 | 27 | 16.1 | 38 | 2 | 103 | 3.33 M |
| F + shadows | 64 | 51 | 49 | 24 | 24 | 20.3 | 44 | 12 | 280 | 11.2 M |
| G + AO | 54 | 43 | 43 | 20 | 20 | 23.5 | 52 | 28 | 297 | 11.2 M |
| H + workers/vehicles | 53 | 44 | 42 | 19 | 19 | 23.9 | 55 | 25 | 324 | 11.8 M |
| I + simulation | 53 | 42 | 42 | 19 | 19 | 23.9 | 54 | 26 | 323 | 11.7 M |
| J + HUD | 52 | 43 | 42 | 19 | 19 | 23.7 | 54 | 25 | 321 | 11.6 M |
| K full | 49 | 41 | 41 | 19 | 19 | 24.2 | 56 | 10 | 754 | 11.9 M |
Reading the post-fix ladder in milliseconds (static): geometry 6.2 → +2.2 PBR maps → +4.8 PV → +0.1 vegetation → +3.4 shadows → +2.9 AO → +0.4 agents → 0 simulation → 0 HUD → +1.4 decals/bloom/vignette/SMAA = 20.4 ms. The as-shipped ladder is the same shape with every opaque layer roughly doubled, which is exactly what a second full-scene render does.
5. Feature toggles on the full scene (paired, 2880×1800, uncapped)
As shipped
| Toggle | Δ static ms | Δ moving ms | 1 % low moving (ref → toggle) | Draw calls |
|---|---|---|---|---|
| scanned props OFF (removes the transmissive material) | −15.7 … −24.8 | −16.6 … −27.3 | 8 → 14 | 704 |
| PV OFF | −10.8 | −14.9 | 8 → 14 | 823 |
| PV structure (racking) only OFF | −10.4 | −5.4 | 8 → 8 | 856 |
| ground OFF | −8.9 | −8.8 | 7 → 11 | 425 |
| shadows OFF | −6.3 | −6.3 | 10 → 7 | 659 |
| PBR maps OFF | −5.7 | (recompile stall) | ||
| AO OFF | −4.8 | −5.4 | 10 → 11 | 643 |
| all post-processing OFF | −4.4 | −5.4 | 10 → 11 | 622 |
| buildings OFF | −3.4 | −1.0 | ||
| bloom + vignette + SMAA OFF | −1.7 | −2.0 | ||
| energy plant OFF | −1.3 | −1.3 | ||
| decals OFF (−410 draw calls) | −0.9 | −0.7 | 10 → 12 | 450 |
| workers OFF | −0.8 | −1.2 | ||
| vegetation OFF | 0.0 | −2.9 | 8 → 10 | 805 |
| HUD hidden | −0.1 | −0.9 | ||
| simulation paused | −0.1 | −1.8 | ||
| vehicles OFF | 0.0 | −0.9 | ||
| energy flows ON | +0.3 | −1.1 | ||
| PV frames / faces / individual scanned models / light pools / procedural props | ≈ 0 each | |||
| PV, props or vegetation cast no shadow; shadow map 2048² | ≈ 0 each | |||
| textures capped at 1024 / 512 / 256 px (784 → 552 / 140 / 36 MB) | 0.0 / −0.3 / 0.0 |
The non-linearity is the tell: hiding the props layer saves 16–25 ms, hiding any single model saves nothing, and every big "layer" saving (PV racking, ground, buildings) is about twice that layer's post-fix cost. All of it is the second render.
Transmission = 0
| Toggle | Δ static ms | Δ moving ms | 1 % low moving (ref → toggle) | Draw calls |
|---|---|---|---|---|
| PV OFF | −5.0 | −6.8 | 15 → 23 | 729 |
| AO OFF | −4.6 | −4.2 | 18 → 20 | 538 |
| shadows OFF | −4.4 | −6.2 | 18 → 24 | 549 |
| all post-processing OFF | −4.4 | −4.8 | 18 → 22 | 516 |
| PBR maps OFF | −2.7 | −2.5 | 18 → 20 | 757 |
| bloom + vignette + SMAA OFF | −1.6 | −1.4 | ||
| facility view (DoF pass mounted) | −1.6 (nearer view draws less) | +1.8 static path; +8 ms in the vsync orbit (§6) | ||
| decals OFF (−410 draw calls) | −0.8 | +0.4 | 14 → 17 | 348 |
| HUD hidden | −0.4 | −0.4 | ||
| workers OFF | −0.3 | −0.4 | ||
| dpr 1.0 (runtime, buffer 1920×1200) | −0.2 | −0.1 | ||
| vegetation OFF | 0.0 | −2.0 | ||
| props (scanned) OFF | −0.1 | −0.9 | ||
| vehicles OFF | −0.1 | −0.2 | ||
| simulation paused | 0.0 | −0.3 | ||
| energy flows ON | +0.1 | 0.0 |
6. Frame pacing (vsync 60 Hz, 10 s scripted orbit, full scene)
| Configuration | Avg FPS | 1 % low | 0.1 % low | p95 ms | p99 ms | Worst ms | Frames > 33 ms | Frames > 50 ms | CV | GC events / max | Long tasks > 50 ms |
|---|---|---|---|---|---|---|---|---|---|---|---|
| As shipped, 2880×1800, overview | 23.9 | 17.1 | 16.9 | 54.6 | 57.2 | 59.1 | 197 / 239 | 48 | 0.19 | 6 / 0.9 ms | 1 |
| As shipped, 2880×1800, facility (DoF) | 23.2 | 16.0 | 15.6 | 55.5 | 60.2 | 64.1 | 208 / 233 | 58 | 0.19 | 6 / 1.3 ms | 0 |
| Transmission 0, 2880×1800, overview | 40.6 | 28.8 | 27.6 | — | — | 36.2 | 5 | 0 | — | — | — |
| Transmission 0, 2880×1800, facility (DoF) | 30.7 | 21.3 | 21.2 | — | — | 47.1 | 142 | — | — | 7 / 1.1 ms | 0 |
| Transmission 0, 1920×1200, overview | 59.8 | 43.6 | 42.4 | — | — | 23.6 | 0 | 0 | — | — | — |
| Transmission 0, 1920×1200, facility (DoF) | 59.4 | 42.2 | 41.5 | — | — | 24.1 | 0 | 0 | — | — | — |
Frame-time graph (10 s orbit, vsync, 2880×1800): docs/factory-frametime-graph.png (copy of factory/.build/profile/frametime-graph.png) — as shipped (red) sits around 40–55 ms with a 5 ms band of jitter; transmission off (green) sits around 24 ms.
Pacing findings:
- The perceived stutter at Retina resolution is sustained 40–56 ms frames, not isolated spikes: coefficient of variation 0.19, worst frame only 1.5× the mean. It reads as "juddery 20 fps", which matches the complaint better than a 60/18/60 pattern would.
- No GC problem. 6–7 minor collections in 10 s, longest 1.3 ms. (One run showed 383 collections during its first seconds, still 2.2 ms max.)
- No React/DOM problem. Layout + paint + style ≈ 7–9 ms per 10 s; unmounting the HUD saves 0.4 ms per frame; the store publishes every 7 frames.
- No steady-state shader compile stalls: zero new programs during any measured pass. Cold-start stalls do exist: the first sight of a PV field cost 0.9–1.1 s and vegetation 0.7–0.8 s in cold runs (first draw of their shader variants). They occur once per session; a warm-up render at load would remove them.
- Camera movement adds 4–5 ms over static at the Retina buffer (24 vs 20 ms), of which vegetation LOD churn is ≈ 2 ms and PV ≈ 1.8 ms.
7. Ranked bottleneck report (Retina buffer, full scene; costs are paired deltas)
| # | Bottleneck | Cost per frame | Share of the as-shipped 41 ms | Evidence | Note (no change made) |
|---|---|---|---|---|---|
| 1 | Transmission pass triggered by portable_generator_glass (KHR_materials_transmission in the scanned GLB) |
16–28 ms | 40–57 % | §1, §5: props OFF vs any single model; CPU profile 83 % in renderTransmissionPass; transmission=0 → 20.8 ms, twice reproduced |
Set transmission = 0 (or replace the glass with an opaque/alpha material) on import in assets.ts, and reject transmissive materials in tools/optimize-models.mjs. Any future glTF with transmission re-creates this. |
| 2 | Resolution (2880×1800 vs 1920×1200) | 8–20 ms depending on the mix | — | Separate loads: 25 ms → 51–56 ms as shipped; 60 fps vs 41 fps post-fix under vsync | The adaptive-dpr fallback added yesterday addresses this; after fix #1 it may only need to engage in facility view. |
| 3 | Depth of field (facility/tour camera modes only) | ≈ 8 ms moving (30.7 vs 40.6 fps) | — | §6 facility rows; the stutter screenshot was taken in facility view | Half-resolution DoF or a cheaper CoC blur; it is mounted only in facility/tour already. |
| 4 | PV layer — the racking structure merged geometry, not the instanced modules |
5–7 ms | 12–16 % | §5: PV OFF −5.0/−6.8; frames/faces ≈ 0, racking −10.4 pre-fix | The racking is a single 187 k-triangle non-indexed Builder mesh drawn in every pass; module frames/faces are cheap. Candidate for LOD or shadow exclusion. |
| 5 | Shadows (4096² sun + 2 spot, PCFSoft, camera-following frustum) | 4.4–6.2 ms | 11–15 % | §5 shadows OFF; shadow map 2048² and caster exclusions change ≈ 0 | Cost is in the receivers' PCFSoft sampling and the extra draw pass, not map size. |
| 6 | N8AO (half-res, quality low) | 4.2–4.6 ms | 10–11 % | §5 AO OFF ≈ all post OFF | |
| 7 | PBR map sampling (world-UV two-lookup anti-tiling + macro map on ground/cladding) | 2.5–2.7 ms | 6 % | §5 PBR maps OFF | Texture memory is irrelevant (§5 cap test); it is shader cost on the ground. |
| 8 | Bloom + vignette + SMAA | 1.4–1.6 ms | 4 % | ||
| 9 | Vegetation (1,400 trees, impostors) | 0 static / 2 ms moving | 0–5 % | LOD churn only; a hysteresis on the 110 m threshold would smooth it. | |
| 10 | Decals (410 individual planes) | 0.8 ms | 2 % | Draw-call count is not a bottleneck on this GPU; merging is optional. | |
| 11 | Workers + vehicles (instanced, vertex-shader animated) | 0.3–0.7 ms | 1 % | ||
| 12 | HUD (React, 8 Hz publish) | 0.4 ms | 1 % | trace layout/paint ≈ 1 % | Yesterday's backdrop-filter removal already took the big cost out. |
| 13 | Simulation + traffic (JS) | < 0.1 ms | 0 % | runtime.perf |
|
| 14 | Energy flows, light pools, procedural props, scanned models individually, shadow map size, caster subsets, texture memory, GC | ≈ 0 | 0 % |
Cold-start (once per session): first-sight shader compiles for PV (≈ 1 s) and vegetation (≈ 0.8 s) — not steady-state, but the first orbit of a session will hitch on them.
8. Answer to "which layer collapses"
Pre-fix ladder in the requested form (static, 2880×1800):
| Stage | Avg FPS | 1 % low | Worst frame | Draw calls | Triangles |
|---|---|---|---|---|---|
| Geometry | 79 | 31 | 33 ms | 153 | 1.1 M |
| + PBR | 60 | 27 | 37 ms | 153 | 1.1 M |
| + PV | 39 | 14 | 72 ms | 180 | 3.3 M |
| + Trees | 39 | 14 | 74 ms | 202 | 6.5 M |
| + Shadows | 32 | 12 | 89 ms | 379 | 14.9 M |
| + AO | 30 | 11 | 93 ms | 391 | 14.1 M |
| + Agents | 29 | 11 | 102 ms | 426 | 14.6 M |
| + HUD | 29 | 11 | 98 ms | 431 | 15.0 M |
| Full | 28 | 8 | 146 ms | 861 | 15.3 M |
The visible "collapse" is at +PV (79 → 39), but the ladder is misleading on its own: the generator's transmissive material sits in the geometry stage from B onward, so every row is paying for a second render of everything above it. Post-fix the same rows read 161 → 119 → 77 → 77 → 64 → 54 → 53 → 52 → 49, and the single largest step is still PV (−42 fps ≈ 4.8 ms), followed by shadows and AO.
9. Engine question
Nothing measured points at Three.js/WebGL as the limit. Post-fix the full scene at 1920×1200 holds vsync with 0 hitches, and at 2880×1800 the remaining 20 ms is ordinary, attributable work (PV racking, shadows, AO, DoF) that responds to ordinary Three.js measures. JavaScript, React, GC and draw-call count are all in the noise. A WebGPU or engine change would not have found or fixed the transmission pass; it is a content/import problem.
Recommended order, for a later optimisation pass (not done here): fix #1 on import and in the asset pipeline → re-profile → decide DoF resolution and PV racking LOD → then shadows/AO quality per dpr.
8. Fix applied (2026-09-19)
Transmission regression closed at three layers.
- Runtime loader (
factory/src/scene/assets.ts,guardTransmission): every material of every loaded GLB is inspected; anyMeshPhysicalMaterialwithtransmission,thicknessor their maps is downgraded unless"<model>/<material>"is approved intools/asset-policy.json. Glass-like names become ordinary alpha-blended decorative glass (opacity 0.42, roughness ≤ 0.12); anything else stays opaque. The downgrade is logged and recorded intransmissionAudit. - Optimizer (
factory/tools/optimize-models.mjs,auditRefraction): every imported glTF is scanned forKHR_materials_transmission,KHR_materials_volumeandKHR_materials_dispersion; offenders are printed with model and material name, stripped (glass-like materials converted to alpha-blended glass) unless approved, and--strictrejects the asset instead. Unused extension declarations are removed from the output. - Regression test (
factory/tools/check-assets.mjs,npm run test:assets, also the first step ofnpm run build): FAILS if any optimised runtime asset contains an unapproved refraction material or a dangling refraction extension declaration.portable_generator.glbwas re-optimised: its glass no longer carries the extension.
Acceptance (vsync, same harness as §6, .build/profile/vsync-fixed-one.md, vsync-fixed-dpr1-one.md):
| Configuration | Baseline (transmission forced 0 at runtime, §6) | Fixed build |
|---|---|---|
| 2880×1800 overview, moving | 40.6 fps, 1 % low 28.8, worst 36.2 ms, 5 hitches | 39.4 / 38.4 fps (two passes), 1 % low 28.7 / 27.8, worst 35.1 / 36.9 ms, 11 / 16 hitches > 33 ms, 12.0 M tris |
| 1920×1200 overview, moving | 59.8 fps, 0 hitches | 58 / 54.2 fps, static 60 fps, worst 25.9 / 27.8 ms, 0 hitches |
| 2880×1800 facility (DoF), moving | 30.7 fps, 142 hitches | 29.4 fps, 144 hitches (before the adaptive DoF below) |
| Functional suite | 31/31 | 31/31 |
The overview at 2880×1800 matches the measured transmission-0 baseline within the ±1 fps run-to-run drift documented in §2; triangle counts confirm the second scene render is gone (12.0 M vs 15.3 M as shipped).
Adaptive depth of field (Scene.tsx Effects): the DoF pass stays mounted in facility/tour view, but its internal update (circle-of-confusion + bokeh blur chain) is skipped while the camera moves. On the transition to "moving" one last update is flushed with the whole depth range in focus, so the merged post shader mixes in nothing; after the camera has been still for 350 ms the bokeh fades back in over 0.3 s. See the rows appended below for the measured effect.
Adaptive DoF measurement (.build/profile/vsync-dof-one.md, run immediately after the verify suite and two profile sessions, i.e. in the thermally depressed GPU state described in §2 — absolute numbers are ~20 % below the cool session above, so only the within-session pairs are comparable):
| Same session, 2880×1800, vsync | Static avg fps | Moving avg fps | Moving 1 % low | Worst ms | Hitches > 33 ms |
|---|---|---|---|---|---|
| Overview (no DoF mounted), pass 1 / 2 | 34.5 / 38.7 | 30.8 / 32.0 | 20.8 / 21.9 | 48.6 / 47.3 | 142 / 124 |
| Facility (adaptive DoF) | 41.3 | 31.7 | 22.0 | 48.0 | 124 |
Before the change the facility view cost ≈ 8 ms more than the overview while the camera moved (29.4 vs 39.4 fps in the cool session above; 30.7 vs 40.6 in §6). With the adaptive pass the facility view now moves at the same frame time as the overview (31.7 vs 32.0 fps in the same session), and the blur is only computed once the camera has been still for 350 ms. Functional suite: 31/31.