Diagnosing and resolving the frame-rate collapse of the double-helix view under full population — a measured optimization pass on Page2.
With both strands rendered and fully populated (1,002 figure labels + 488 event labels), the double-helix view ran at 4.3 FPS — effectively unusable. Five targeted, measured changes returned it to a usable frame rate and reduced texture memory by 16×, with no loss of visual fidelity at the zoom levels in actual use.
Every change in this record was made on a working copy, verified against the renderer's own counters and Safari's frame profiler, and validated by re-measurement before the next change. No optimization was applied on intuition alone — a discipline that repeatedly corrected wrong initial assumptions about the bottleneck.
The helix and beta-helix are two interlocking spiral strands. Each figure and historical event is rendered as an individual billboard sprite carrying a baked text label. Singly and lightly populated, the view performs acceptably. With both strands active and full — the heaviest real state — it collapsed:
| Metric (initial state) | Value | Note |
|---|---|---|
| Frame rate | 4.3 FPS | unusable; 67 of 69 frames over 20 ms |
| Worst frame | 321 ms | visible stalls |
| Texture VRAM | 5,261 MB | on an integrated GPU with no headroom |
| Label sprites | 1,490 | 1,002 figures + 488 events |
| Per-name texture | 2048×256 | ≈ 2 MB each, for one line of text |
| Draw calls / frame | 669 | sprites cannot batch |
The root constraint: this is an integrated Apple GPU with unified memory. 5.3 GB of texture data for text labels left no room — the GPU was thrashing texture memory every frame. The starting hypothesis (from an early profiler reading showing 64% "JavaScript" time) was that the animation loop was the bottleneck. That hypothesis was wrong, and proving it wrong was the first job.
Rather than apply plausible fixes, the pass began by instrumenting the live scene. Two read-only console diagnostics were written to extract ground truth the standard profiler view couldn't isolate: a census of sprites, visibility, animation-state, and texture footprint; a microbenchmark of the per-sprite loop; the renderer's draw-call and texture counts; and a CPU-vs-GPU frame-time split.
This discipline paid off by being wrong-proof. Over the course of the pass, the measured data overturned the working hypothesis three separate times:
| Assumed bottleneck | What the measurement showed |
|---|---|
| The animation loop (CPU) | Loop cost was 0.12 ms/frame — trivial. The cost was texture memory. |
| Transparent-sprite depth sorting | Removing 3,000 sprites from the sort barely moved the frame time. Not the cause. |
Per-sprite texture uploads (texImage2D) | The dominant upload was the 80,000-vertex helix point-cloud, not the sprite textures. |
readPixels flush reported "GPU idle, CPU-bound." This was a false reading — Safari's driver deferred the work past the readback. Safari's frame profiler (flame chart) was the only reliable ground truth, and it named the real costs directly. Lesson: trust the profiler over any synthetic probe.Applied in sequence, each verified before the next. Ordered as performed; impact varied widely — and one change's main value was disproving a hypothesis rather than gaining frames.
Each name was baked into a 2048×256 canvas (≈2 MB) and each event into 2048×512 — roughly 50× the resolution they were ever displayed at. Since names are never zoomed into, the canvases were shrunk to 512×64 and 512×128 respectively (font and coordinates scaled proportionally, aspect ratios preserved so the per-frame scale math stayed valid). A 16× reduction in pixels per texture.
// per-name bake make2DCanvas(2048, 256) → make2DCanvas(512, 64) font 'bold 160px' → 'bold 40px' // same fit, 1/16 the pixels
Every figure carried three hidden decoration sprites (a bookmark reticle, a teleport orb, a scan glow-plate) — 3,006 sprites, of which only ~11 are ever active. They were added to the scene graph at build time and traversed every frame though almost always invisible. The change builds them but keeps them detached from the scene graph, attaching a name's companions only while it is bookmarked or teleport-active.
FPS barely moved — which was itself the finding: it proved the transparent-sprite sort was not the bottleneck, redirecting the investigation toward the GPU upload path. A structurally correct change whose chief value was diagnostic.
The flame chart revealed generateMipmap consuming 417 ms — Three.js was building full mipmap chains for all 1,490 text textures, plus the re-uploads that triggers. Text viewed head-on never needs mipmaps (they exist for textures seen shrinking into distance).
tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter; // default forces a mip chain
On a Retina display the renderer drew at pixelRatio 2 — 4× the screen's pixels. With ~1,700 transparent, blended sprites, that overdraw was the bulk of drawArrays. Lowering the cap to 1.5 cut the drawn pixel count ~44% with negligible visible softening on text against black.
setPixelRatio(Math.min(devicePixelRatio, 2)) → Math.min(devicePixelRatio, 1.5)
The helix spine is a 40,000-point cloud per strand (80,000 total), recomputed in JavaScript and re-uploaded to the GPU every frame. This — not the sprite textures — was the texImage2D cost dominating the profile. Because the helix rotates slowly (~0.9 rad over 3 s), the recompute and re-upload were throttled to every other frame (30 fps), which is visually identical.
if (frame % 2 === 0) { // recompute + re-upload at 30fps // …40,000-point spiral, both strands… helix.geometry.attributes.position.needsUpdate = true; }
| Metric | Before | After | Change |
|---|---|---|---|
| FPS (both strands, full) | 4.3 | ~18 | ≈ 4× |
| Worst frame | 321 ms | ~70 ms | −78% |
| Texture VRAM | 5,261 MB | 329 MB | −94% |
| Scene objects | 4,954 | 1,946 | −61% |
| Draw calls / frame | 669 | 26 | −96% |
| generateMipmap | 417 ms | 0 | eliminated |
| texImage2D (uploads) | dominant | 0 | eliminated |
The three dominant GPU costs were removed in turn until a single honest cost remained.
| Stage | drawArrays | texImage2D | generateMipmap |
|---|---|---|---|
| After shrink + lazy companions | 2,031 ms | 811 ms | 417 ms |
| After mipmap disable | 1,842 ms | ~1,200 ms | — gone |
| After pixelRatio 1.5 | 1,644 ms | dominant* | — |
| After helix throttle | 2,023 ms† | — gone | — |
*texImage2D became the visible dominant once overdraw fell — exposing the helix point-cloud upload as its true source. †Absolute ms varies with recording length; drawArrays is now the sole dominant cost as a share of the frame (67.6%).
After the five changes, the frame is dominated by a single cost — drawArrays (≈68%), the actual rasterization of ~1,700 transparent blended sprites and the helix point clouds. This is no longer waste; it is the genuine cost of drawing a dense, transparent, two-strand helix. Every cheap inefficiency — oversized textures, dead companion sprites, mipmap generation, redundant geometry uploads — has been removed.
Further gains exist but trade visual quality rather than removing inefficiency, which is the correct point to stop a routine optimization pass:
| pixelRatio 1.5 → 1.0 | Another ~2× cut to drawArrays fill, at the cost of noticeably softer text on Retina. |
| Reduce helix point count | 40,000 → fewer points per strand halves draw cost but thins the visible spine. |
| Sprite instancing / atlas | The structural rewrite to collapse 1,490 sprites into batched geometry. High effort; the deferred "proper" fix. Now feasible (post-shrink, all labels fit one GPU texture). |
Diagnostics noted resident GL textures creeping upward across strand rebuilds (≈12 over a 4-second window), indicating textures not fully disposed on eject/rebuild. Not a contributor to the frame-rate problem and out of scope for this pass, but the likely cause of any slow degradation over a long session. The existing diagnostic surfaces it.
Three read-only, self-restoring console probes were written for this pass. They mutate nothing and print copy-paste JSON reports. Retained for future use.
| Tool | Purpose |
|---|---|
| amenti-helix-diagnostic.js | Sprite/texture census, visibility, animation-state, VRAM and atlas feasibility, loop microbench, live FPS. |
| amenti-helix-diagnostic-2.js | Post-shrink probe: CPU-vs-GPU split, transparency/scene-graph weight, payoff projections, draw-call counts. |
| amenti-frame-profiler.js | Wraps the real per-frame work and ranks function cost as text — a console fallback for the flame chart. |
drawArrays, texImage2D, generateMipmap) that no object-counting probe could see. When a probe and the profiler disagree, the profiler is right.