Fantasy-Map-Generator/_bmad-output/implementation-artifacts/3-1-performance-benchmarking.md
Azgaar a285d450c8 feat: refactor draw-relief-icons renderer to utilize WebGL2LayerFramework
- 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.
2026-03-12 15:04:37 +01:00

16 KiB
Raw Blame History

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:

  1. Automated bench test (src/renderers/draw-relief-icons.bench.ts) — Vitest bench() for geometry build time (buildSetMesh proxy). 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.

  2. Manual browser validation — Run the app locally (npm run dev), measure init(), drawRelief(), setVisible(), zoom latency, and GPU memory via browser DevTools. Record results in completion notes.

Why Split Automated vs Manual

  • draw-relief-icons.ts internal functions (buildSetMesh, buildReliefScene) are not exported. They run inside window.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 : WebGL2LayerFramework fully implemented
  • Epic 2 done : draw-relief-icons.ts refactored 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, BufferAttribute have no GPU dependency — they're pure JS objects. Only WebGLRenderer, Scene, OrthographicCamera need 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://tracing or Memory tab in DevTools)
  • Call WebGL2LayerFramework.setVisible('terrain', false)
  • Confirm texture and VBO memory sizes do NOT decrease
  • Expected: clearLayer() is NOT called on setVisible(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") → calls loadTexture(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() → calls WebGL2LayerFramework.clearLayer("terrain") which calls group.clear() — does NOT dispose GPU resources (NFR-P6 compliant)
  • window.rerenderReliefIcons() → single WebGL2LayerFramework.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 calls syncTransform() (updates camera bounds from D3 viewX/viewY/scale) then per-layer render callbacks then renderer.render(scene, camera)
  • RAF ID is set on requestRender() call and cleared in the callback — coalescing is confirmed working
  • setVisible(id, false) sets group.visible = false immediately — O(1) operation

Tasks

  • T1: Create src/renderers/draw-relief-icons.bench.ts

    • T1a: Implement standalone buildSetMeshBench mirroring production logic (avoids exporting from source)
    • T1b: Add makeIcons(n) helper to generate synthetic ReliefIcon entries
    • T1c: Add bench("buildSetMesh — 1,000 icons") and bench("buildSetMesh — 10,000 icons")
    • T1d: Run npx vitest bench src/renderers/draw-relief-icons.bench.ts — record results
  • T2: Measure NFR-P5 (init time) in browser

    • Use performance.now() before/after WebGL2LayerFramework.init() call
    • Record: actual init time in ms → target <200ms
  • 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 requestAnimationFrame callback)
    • Record: P1 actual (target <16ms), P2 actual (target <100ms)
  • T4: Measure NFR-P3 (toggle time) in browser

    • Wrap WebGL2LayerFramework.setVisible('terrain', false) in performance.now()
    • Record: toggle time in ms → target <4ms
  • 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 on setVisible() (confirmed by code inspection of webgl-layer-framework.ts line 193)
    • Document: pass/fail with evidence
  • 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%
  • 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 P1P6 (T2T6)
    • 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)