- Removed global renderer, camera, and scene management in favor of layer framework integration. - Implemented terrain layer registration with setup, render, and dispose methods. - Enhanced texture loading and caching mechanisms. - Updated geometry building to return Mesh objects directly. - Added performance benchmarking story for render performance validation. - Created bundle size audit story to ensure effective tree-shaking and size constraints.
16 KiB
Story 3.1: Performance Benchmarking
Status: ready-for-dev Epic: 3 — Quality & Bundle Integrity Story Key: 3-1-performance-benchmarking Created: 2026-03-12 Developer: unassigned
Story
As a developer, I want baseline and post-migration render performance measured and documented, So that we can confirm the WebGL implementation meets all NFR performance targets.
Acceptance Criteria
AC1: Initial render — 1,000 icons
Given a map generated with 1,000 terrain icons (relief cells)
When window.drawRelief() is called and render time is measured via performance.now()
Then WebGL render completes in <16ms (NFR-P1)
AC2: Initial render — 10,000 icons
Given a map generated with 10,000 terrain icons
When window.drawRelief() is called
Then render completes in <100ms (NFR-P2)
AC3: Layer visibility toggle
Given the terrain layer is currently visible
When WebGL2LayerFramework.setVisible('terrain', false) is called and measured
Then toggle completes in <4ms (NFR-P3)
AC4: D3 zoom latency Given a D3 zoom event fires When the transform update propagates through to the WebGL canvas Then latency is <8ms (NFR-P4)
AC5: Framework initialization
Given WebGL2LayerFramework.init() is called cold
When measured via performance.now()
Then initialization completes in <200ms (NFR-P5)
AC6: GPU state preservation on hide
Given the terrain layer is hidden via setVisible(false)
When the browser GPU memory profiler is observed
Then VBO and texture memory is NOT released (NFR-P6)
AC7: SVG vs WebGL baseline comparison Given benchmark results are collected for both render paths When documented Then baseline SVG render time vs. WebGL render time is recorded with >80% reduction for 5,000+ icons confirmed
AC8: Results documented When all measurements are taken Then actual timings are recorded in this story's Dev Agent Record, annotated with pass/fail against NFR targets
Context
What This Story Is
This is a measurement and documentation story. The code is complete (Epics 1 and 2 done). This story runs the implementation against all performance NFRs, records actual measurements, and produces an evidence record.
There are two components:
-
Automated bench test (
src/renderers/draw-relief-icons.bench.ts) — Vitestbench()for geometry build time (buildSetMeshproxy). Runs in node env with Three.js mocked (same mock as framework tests). Measures CPU cost of geometry construction, not GPU cost. Partial proxy for NFR-P1/P2. -
Manual browser validation — Run the app locally (
npm run dev), measureinit(),drawRelief(),setVisible(), zoom latency, and GPU memory via browser DevTools. Record results in completion notes.
Why Split Automated vs Manual
draw-relief-icons.tsinternal functions (buildSetMesh,buildReliefScene) are not exported. They run insidewindow.drawRelief().- GPU render time (
renderer.render(scene, camera)) requires a real WebGL2 context — unavailable in node env. - Browser-mode Vitest (
vitest.browser.config.ts) could bench real GPU calls, but has setup overhead and flaky timing. Manual DevTools profiling is the gold standard for GPU frame time. - Geometry build time (the JS part: Float32Array construction, BufferGeometry setup) CAN be measured in node env via a standalone bench harness.
Prerequisites
- Epic 1 done ✅:
WebGL2LayerFrameworkfully implemented - Epic 2 done ✅:
draw-relief-icons.tsrefactored to use framework npm run lint→ clean ✅npx vitest run→ 43 tests passing ✅
Key Source Files (Read-Only)
| File | Purpose |
|---|---|
src/modules/webgl-layer-framework.ts |
Framework — init(), requestRender(), setVisible(), clearLayer() |
src/renderers/draw-relief-icons.ts |
Renderer — window.drawRelief(), buildSetMesh(), buildReliefScene() |
src/config/relief-config.ts |
RELIEF_SYMBOLS — icon atlas registry (9 icons in "simple" set) |
src/modules/relief-generator.ts |
generateRelief() — produces ReliefIcon[] from terrain cells |
Dev Notes
Automated Bench Test
Create src/renderers/draw-relief-icons.bench.ts. Use Vitest's bench() function (built into Vitest 4.x via tinybench). The test must mock Three.js the same way webgl-layer-framework.test.ts does.
Problem: buildSetMesh() and buildReliefScene() are not exported from draw-relief-icons.ts. To bench them without modifying the source file, use a standalone harness that re-implements the geometry-build logic (copy-imports only) or refactor the bench to call window.drawRelief() after setting up all required globals.
Recommended approach — standalone geometry harness (no source changes required):
// src/renderers/draw-relief-icons.bench.ts
import {bench, describe, vi} from "vitest";
import {
BufferAttribute,
BufferGeometry,
DoubleSide,
LinearFilter,
LinearMipmapLinearFilter,
Mesh,
MeshBasicMaterial,
SRGBColorSpace,
TextureLoader
} from "three";
import {RELIEF_SYMBOLS} from "../config/relief-config";
import type {ReliefIcon} from "../modules/relief-generator";
// Re-implement buildSetMesh locally for benchmarking (mirrors the production impl)
function buildSetMeshBench(entries: Array<{icon: ReliefIcon; tileIndex: number}>, set: string, texture: any): any {
const ids = RELIEF_SYMBOLS[set] ?? [];
const n = ids.length || 1;
const cols = Math.ceil(Math.sqrt(n));
const rows = Math.ceil(n / cols);
const positions = new Float32Array(entries.length * 4 * 3);
const uvs = new Float32Array(entries.length * 4 * 2);
const indices = new Uint32Array(entries.length * 6);
let vi = 0,
ii = 0;
for (const {icon: r, tileIndex} of entries) {
const col = tileIndex % cols;
const row = Math.floor(tileIndex / cols);
const u0 = col / cols,
u1 = (col + 1) / cols;
const v0 = row / rows,
v1 = (row + 1) / rows;
const x0 = r.x,
x1 = r.x + r.s;
const y0 = r.y,
y1 = r.y + r.s;
const base = vi;
positions.set([x0, y0, 0], vi * 3);
uvs.set([u0, v0], vi * 2);
vi++;
positions.set([x1, y0, 0], vi * 3);
uvs.set([u1, v0], vi * 2);
vi++;
positions.set([x0, y1, 0], vi * 3);
uvs.set([u0, v1], vi * 2);
vi++;
positions.set([x1, y1, 0], vi * 3);
uvs.set([u1, v1], vi * 2);
vi++;
indices.set([base, base + 1, base + 3, base, base + 3, base + 2], ii);
ii += 6;
}
const geo = new BufferGeometry();
geo.setAttribute("position", new BufferAttribute(positions, 3));
geo.setAttribute("uv", new BufferAttribute(uvs, 2));
geo.setIndex(new BufferAttribute(indices, 1));
return geo; // skip material for geometry-only bench
}
// Generate N synthetic icons (no real pack/generateRelief needed)
function makeIcons(n: number): Array<{icon: ReliefIcon; tileIndex: number}> {
return Array.from({length: n}, (_, i) => ({
icon: {i, href: "#relief-mount-1", x: (i % 100) * 10, y: Math.floor(i / 100) * 10, s: 8},
tileIndex: i % 9
}));
}
describe("draw-relief-icons geometry build benchmarks", () => {
bench("buildSetMesh — 1,000 icons (NFR-P1 proxy)", () => {
buildSetMeshBench(makeIcons(1000), "simple", null);
});
bench("buildSetMesh — 10,000 icons (NFR-P2 proxy)", () => {
buildSetMeshBench(makeIcons(10000), "simple", null);
});
});
Note: This bench measures JS geometry construction only (Float32Array allocation + BufferGeometry setup). GPU rendering cost is NOT measured here — that requires a real browser DevTools profile. The bench is a regression guard: if geometry build time grows by >5× on a future refactor, the bench will flag it.
Run bench: npx vitest bench src/renderers/draw-relief-icons.bench.ts
Three.js mock: Add the same vi.mock("three", () => { ... }) block from webgl-layer-framework.test.ts. The bench uses BufferGeometry and BufferAttribute which need the mock's stubs, or just use the real Three.js (no GPU needed for geometry).
Simplification: Do NOT mock Three.js for the bench file.
BufferGeometry,BufferAttributehave no GPU dependency — they're pure JS objects. OnlyWebGLRenderer,Scene,OrthographicCameraneed mocking. The bench can import real Three.js and create real buffer geometries without any DOM/GPU.
Manual Browser Measurement Protocol
Run npm run dev in a terminal. Open the app at http://localhost:5173/Fantasy-Map-Generator/.
NFR-P5: init() time (<200ms)
// In browser console before map load:
const t0 = performance.now();
WebGL2LayerFramework.init();
console.log("init time:", performance.now() - t0, "ms");
NFR-P1: drawRelief 1k icons (<16ms)
// Generate a small map, then:
const icons1k = pack.relief.slice(0, 1000);
const t0 = performance.now();
window.drawRelief("webGL", document.getElementById("terrain"));
requestAnimationFrame(() => console.log("drawRelief 1k:", performance.now() - t0, "ms"));
NFR-P2: drawRelief 10k icons (<100ms)
const icons10k = pack.relief.slice(0, 10000);
// Repeat as above with 10k icons
NFR-P3: setVisible toggle (<4ms)
const t0 = performance.now();
WebGL2LayerFramework.setVisible("terrain", false);
console.log("toggle:", performance.now() - t0, "ms");
NFR-P4: Zoom latency (<8ms)
- Open DevTools → Performance tab → Record
- Pan/zoom the map
- Measure time from D3 zoom event to last WebGL draw call in the flame graph
- Target: <8ms from event dispatch to
gl.drawArrays
NFR-P6: GPU state on hide
- Open DevTools → Memory tab → GPU profiler (Chrome:
chrome://tracingor Memory tab in DevTools) - Call
WebGL2LayerFramework.setVisible('terrain', false) - Confirm texture and VBO memory sizes do NOT decrease
- Expected:
clearLayer()is NOT called onsetVisible(false)— GPU memory preserved
SVG vs WebGL comparison (AC7)
// SVG path:
const s = performance.now();
window.drawRelief("svg", document.getElementById("terrain"));
console.log("SVG render:", performance.now() - s, "ms");
// WebGL path (after undrawing SVG):
window.undrawRelief();
const w = performance.now();
window.drawRelief("webGL", document.getElementById("terrain"));
requestAnimationFrame(() => console.log("WebGL render:", performance.now() - w, "ms"));
Vitest Config Note
The existing vitest.browser.config.ts uses Playwright for browser tests. The bench file uses the default vitest.config.ts (node env). Three.js geometries (BufferGeometry, BufferAttribute) work in node without mocks — they're pure JS objects. No browser or mock needed for geometry benchmarks.
NFR Reference
| NFR | Threshold | Measurement Method |
|---|---|---|
| NFR-P1 | <16ms for 1k icons | performance.now() around drawRelief() + next RAF |
| NFR-P2 | <100ms for 10k icons | Same as P1 |
| NFR-P3 | <4ms toggle | performance.now() around setVisible(false) |
| NFR-P4 | <8ms zoom latency | DevTools Performance tab flame graph |
| NFR-P5 | <200ms init | performance.now() around framework.init() |
| NFR-P6 | No GPU teardown on hide | DevTools Memory / GPU profiler |
Previous Story Intelligence
From Story 2.2 (draw-relief-icons.ts refactor)
window.drawRelief("webGL")→ callsloadTexture(set).then(() => { buildReliefScene(icons); WebGL2LayerFramework.requestRender(); })requestRender()is RAF-coalesced: only one GPU draw per animation frame. Measurement must wait for the RAF callback.window.undrawRelief()→ callsWebGL2LayerFramework.clearLayer("terrain")which callsgroup.clear()— does NOT dispose GPU resources (NFR-P6 compliant)window.rerenderReliefIcons()→ singleWebGL2LayerFramework.requestRender()call — this is the zoom path
From Story 2.3 (fallback verification)
WebGL2LayerFramework.hasFallback→ true if WebGL2 unavailable; all methods are no-ops- For benchmarking, ensure WebGL2 IS available (test on a supported browser)
- Test setup baseline: 43 unit tests passing, 88.51% statement coverage
From Story 1.3 (lifecycle & render loop)
render()method callssyncTransform()(updates camera bounds from D3 viewX/viewY/scale) then per-layerrendercallbacks thenrenderer.render(scene, camera)- RAF ID is set on
requestRender()call and cleared in the callback — coalescing is confirmed working setVisible(id, false)setsgroup.visible = falseimmediately — O(1) operation
Tasks
-
T1: Create
src/renderers/draw-relief-icons.bench.ts- T1a: Implement standalone
buildSetMeshBenchmirroring production logic (avoids exporting from source) - T1b: Add
makeIcons(n)helper to generate syntheticReliefIconentries - T1c: Add
bench("buildSetMesh — 1,000 icons")andbench("buildSetMesh — 10,000 icons") - T1d: Run
npx vitest bench src/renderers/draw-relief-icons.bench.ts— record results
- T1a: Implement standalone
-
T2: Measure NFR-P5 (init time) in browser
- Use
performance.now()before/afterWebGL2LayerFramework.init()call - Record: actual init time in ms → target <200ms
- Use
-
T3: Measure NFR-P1 and NFR-P2 (render time) in browser
- Run app with 1,000 icons → record
drawRelief()time - Run app with 10,000 icons → record
drawRelief()time - Use RAF-aware measurement (measure from call to next
requestAnimationFramecallback) - Record: P1 actual (target <16ms), P2 actual (target <100ms)
- Run app with 1,000 icons → record
-
T4: Measure NFR-P3 (toggle time) in browser
- Wrap
WebGL2LayerFramework.setVisible('terrain', false)inperformance.now() - Record: toggle time in ms → target <4ms
- Wrap
-
T5: Measure NFR-P4 (zoom latency) in browser
- Use DevTools Performance tab — capture pan/zoom interaction
- Measure from D3 zoom event to WebGL draw call completion
- Record: latency in ms → target <8ms
-
T6: Verify NFR-P6 (GPU state preservation) in browser
- After calling
setVisible(false), check DevTools Memory that textures/VBOs are NOT released - Structural verification:
clearLayer("terrain")is NOT called onsetVisible()(confirmed by code inspection ofwebgl-layer-framework.tsline 193) - Document: pass/fail with evidence
- After calling
-
T7: Measure SVG vs WebGL comparison (AC7)
- Time
window.drawRelief("svg")for 5,000+ icons - Time
window.drawRelief("webGL")for same icon set - Calculate % reduction → target >80%
- Time
-
T8:
npm run lint— zero errors (bench file must be lint-clean) -
T9:
npx vitest run— all 43 existing tests still pass (bench file must not break unit tests) -
T10: Document all results in Dev Agent Record completion notes:
- Bench output (T1d)
- Browser measurements for P1–P6 (T2–T6)
- SVG vs WebGL comparison (T7)
- Pass/fail verdict for each NFR
Dev Agent Record
Agent Model Used
to be filled by dev agent
Debug Log References
Completion Notes List
Record actual measured timings for each NFR here:
| NFR | Target | Actual | Pass/Fail |
|---|---|---|---|
| NFR-P1 (1k icons) | <16ms | tbd | tbd |
| NFR-P2 (10k icons) | <100ms | tbd | tbd |
| NFR-P3 (toggle) | <4ms | tbd | tbd |
| NFR-P4 (zoom latency) | <8ms | tbd | tbd |
| NFR-P5 (init) | <200ms | tbd | tbd |
| NFR-P6 (GPU state) | no teardown | tbd | tbd |
| AC7 (SVG vs WebGL) | >80% reduction | tbd | tbd |
File List
Files created/modified (to be filled by dev agent):
src/renderers/draw-relief-icons.bench.ts— NEW: geometry build benchmarks (vitest bench)