octopusgames
Research/Factory performance profile

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


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:


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.

  1. Runtime loader (factory/src/scene/assets.ts, guardTransmission): every material of every loaded GLB is inspected; any MeshPhysicalMaterial with transmission, thickness or their maps is downgraded unless "<model>/<material>" is approved in tools/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 in transmissionAudit.
  2. Optimizer (factory/tools/optimize-models.mjs, auditRefraction): every imported glTF is scanned for KHR_materials_transmission, KHR_materials_volume and KHR_materials_dispersion; offenders are printed with model and material name, stripped (glass-like materials converted to alpha-blended glass) unless approved, and --strict rejects the asset instead. Unused extension declarations are removed from the output.
  3. Regression test (factory/tools/check-assets.mjs, npm run test:assets, also the first step of npm run build): FAILS if any optimised runtime asset contains an unapproved refraction material or a dangling refraction extension declaration. portable_generator.glb was 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.