Fantasy-Map-Generator/_bmad-output/implementation-artifacts/1-3-layer-lifecycle-register-visibility-render-loop.md

16 KiB

Story 1.3: Layer Lifecycle — Register, Visibility, Render Loop

Status: done Epic: 1 — WebGL Layer Framework Module Story Key: 1-3-layer-lifecycle-register-visibility-render-loop Created: 2026-03-12 Developer: Amelia (Dev Agent)


Story

As a developer, I want register(), unregister(), setVisible(), clearLayer(), requestRender(), syncTransform(), and the private per-frame render() fully implemented, So that multiple layers can be registered, rendered each frame, toggled visible/invisible, and cleaned up without GPU state loss.


Context

Prior Art (Stories 1.1 & 1.2 — Complete)

Stories 1.1 and 1.2 delivered the complete scaffold in src/modules/webgl-layer-framework.ts:

  • Pure exports: buildCameraBounds, detectWebGL2, getLayerZIndex — fully implemented and tested
  • init(): Fully implemented — DOM wrapping, canvas creation, Three.js renderer/scene/camera, ResizeObserver, D3 zoom subscription, pendingConfigs processing
  • register(): Fully implemented — queues pre-init, creates Group and registers post-init
  • requestRender(): Stub (calls this.render() directly — no RAF coalescing yet)
  • syncTransform(): Stub (no-op)
  • setVisible(): Stub (no-op)
  • clearLayer(): Stub (no-op)
  • unregister(): Stub (no-op)
  • render() private: Stub (no-op)
  • 21 tests passing; lint clean

Files to Modify

  • src/modules/webgl-layer-framework.ts — implement all stub methods listed above
  • src/modules/webgl-layer-framework.test.ts — add Story 1.3 tests (RAF coalescing, syncTransform, render order, setVisible, clearLayer, unregister)

Acceptance Criteria

AC1: register(config) before init() → config is queued in pendingConfigs[] and processed by init() without error (already implemented in Story 1.2; verify remains correct)

AC2: register(config) after init() → a THREE.Group with config.renderOrder is created, config.setup(group) is called once, the group is added to scene, and the registration is stored in layers: Map

AC3: setVisible('terrain', false)layer.group.visible === false; config.dispose is NOT called (no GPU teardown, NFR-P6); canvas is hidden only if ALL layers are invisible

AC4: setVisible('terrain', true) after hiding → layer.group.visible === true; requestRender() is triggered; toggle completes in <4ms (NFR-P3)

AC5: clearLayer('terrain')group.clear() is called (removes all Mesh children); layer registration in layers: Map remains intact; renderer.dispose() is NOT called

AC6: requestRender() called three times in rapid succession → only one requestAnimationFrame is scheduled (RAF coalescing confirmed); rafId is reset to null after the frame executes

AC7: render() private execution order → syncTransform() is called first; then each visible layer's config.render(group) callback is dispatched (invisible layer callbacks are skipped); then renderer.render(scene, camera) is called last

AC8: syncTransform() with globals viewX=0, viewY=0, scale=1, graphWidth=960, graphHeight=540 → camera left/right/top/bottom match buildCameraBounds(0, 0, 1, 960, 540) exactly; camera.updateProjectionMatrix() is called

AC9: unregister('terrain')config.dispose(group) is called; the id is removed from layers: Map; if the unregistered layer was the last one, canvas.style.display is set to "none"

AC10: Framework coverage ≥80% (NFR-M5) → npx vitest run --coverage src/modules/webgl-layer-framework.test.ts reports ≥80% statement coverage for webgl-layer-framework.ts

AC11: THREE.Group is the sole abstraction boundary (NFR-M1) → scene, renderer, and camera are never exposed to layer callbacks; all three callbacks receive only group: THREE.Group


Technical Notes

requestRender() — RAF Coalescing

Replace the direct this.render() call (Story 1.2 stub) with the RAF-coalesced pattern:

requestRender(): void {
  if (this._fallback) return;
  if (this.rafId !== null) return;           // already scheduled — coalesce
  this.rafId = requestAnimationFrame(() => {
    this.rafId = null;
    this.render();
  });
}

Why coalescing matters: D3 zoom fires many events per second; ResizeObserver also calls requestRender(). Without coalescing, every event triggers a renderer.render() call. With coalescing, all calls within the same frame collapse to one GPU draw.

syncTransform() — D3 → Camera Sync

Reads window globals (viewX, viewY, scale, graphWidth, graphHeight) and applies buildCameraBounds() to the orthographic camera:

syncTransform(): void {
  if (this._fallback || !this.camera) return;
  const viewX = (globalThis as any).viewX ?? 0;
  const viewY = (globalThis as any).viewY ?? 0;
  const scale = (globalThis as any).scale ?? 1;
  const graphWidth = (globalThis as any).graphWidth ?? 960;
  const graphHeight = (globalThis as any).graphHeight ?? 540;
  const bounds = buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight);
  this.camera.left = bounds.left;
  this.camera.right = bounds.right;
  this.camera.top = bounds.top;
  this.camera.bottom = bounds.bottom;
  this.camera.updateProjectionMatrix();
}

Guard note: globalThis as any is required because viewX, viewY, scale, graphWidth, graphHeight are legacy window globals from the pre-TypeScript codebase. They are not typed. Use ?? 0 / ?? 1 / ?? 960 / ?? 540 defaults so tests can run in Node without setting them.

render() — Per-Frame Dispatch

private render(): void {
  if (this._fallback || !this.renderer || !this.scene || !this.camera) return;
  this.syncTransform();
  for (const layer of this.layers.values()) {
    if (layer.group.visible) {
      layer.config.render(layer.group);
    }
  }
  this.renderer.render(this.scene, this.camera);
}

Order is enforced: syncTransform → per-layer render callbacks → renderer.render. Never swap.

setVisible() — GPU-Preserving Toggle

setVisible(id: string, visible: boolean): void {
  if (this._fallback) return;
  const layer = this.layers.get(id);
  if (!layer) return;
  layer.group.visible = visible;
  const anyVisible = [...this.layers.values()].some(l => l.group.visible);
  if (this.canvas) this.canvas.style.display = anyVisible ? "block" : "none";
  if (visible) this.requestRender();
}

Critical: config.dispose must NOT be called here. No GPU teardown. Only group.visible is toggled (Three.js skips invisible objects in draw dispatch automatically).

clearLayer() — Wipe Geometry, Preserve Registration

clearLayer(id: string): void {
  if (this._fallback) return;
  const layer = this.layers.get(id);
  if (!layer) return;
  layer.group.clear();  // removes all Mesh children; Three.js Group.clear() does NOT dispose GPU memory
}

Note: group.clear() does NOT call .dispose() on children. Story 2.x's undrawRelief calls this to empty geometry without GPU teardown — preserving VBO/texture memory per NFR-P6.

unregister() — Full Cleanup

unregister(id: string): void {
  if (this._fallback) return;
  const layer = this.layers.get(id);
  if (!layer || !this.scene) return;
  layer.config.dispose(layer.group);   // caller disposes GPU memory (geometry, material, texture)
  this.scene.remove(layer.group);
  this.layers.delete(id);
  const anyVisible = [...this.layers.values()].some(l => l.group.visible);
  if (this.canvas && !anyVisible) this.canvas.style.display = "none";
}

Removing biome-ignore Comments (T7)

Story 1.2 retained biome-ignore lint/correctness/noUnusedPrivateClassMembers on camera and rafId. Both are now fully used in this story:

  • camera is read in syncTransform() and render()
  • rafId is read and written in requestRender()

Remove both biome-ignore comments as part of this story.

Test Strategy — Story 1.3 Tests

All new tests inject stub state onto private fields (same pattern as Stories 1.1 and 1.2). No real WebGL context needed.

RAF coalescing test: vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(1 as any) to assert it is called only once for three rapid requestRender() calls.

syncTransform test: Stub camera with a plain object; set globalThis.viewX = 0 etc. via vi.stubGlobal(); call syncTransform(); assert camera bounds match buildCameraBounds(0,0,1,960,540).

render() order test: Spy on syncTransform, a layer's render callback, and renderer.render. Assert call order.

setVisible test: Already partially covered in Story 1.1; Story 1.3 adds the "canvas hidden when ALL invisible" edge case and the "requestRender triggered on show" case.

unregister test: Verify dispose() called, layer removed from Map, scene.remove() called.


Tasks

  • T1: Implement requestRender() with RAF coalescing (replace Story 1.2 stub)

    • T1a: Guard on _fallback
    • T1b: Early return if rafId !== null
    • T1c: requestAnimationFrame call storing ID in rafId; reset to null in callback before calling render()
  • T2: Implement syncTransform() reading window globals

    • T2a: Guard on _fallback and !this.camera
    • T2b: Read globalThis.viewX/viewY/scale/graphWidth/graphHeight with ?? defaults
    • T2c: Call buildCameraBounds() and write all four camera bounds
    • T2d: Call this.camera.updateProjectionMatrix()
  • T3: Implement private render() with ordered dispatch

    • T3a: Guard on _fallback, !this.renderer, !this.scene, !this.camera
    • T3b: Call this.syncTransform()
    • T3c: Loop this.layers.values() dispatching layer.config.render(group) for visible layers only
    • T3d: Call this.renderer.render(this.scene, this.camera) (via local const captures for TypeScript type safety)
  • T4: Implement setVisible(id, visible)

    • T4a: Guard on _fallback
    • T4b: Toggle layer.group.visible
    • T4c: Check if ANY layer is still visible; update canvas.style.display
    • T4d: Call requestRender() when visible === true
  • T5: Implement clearLayer(id)

    • T5a: Guard on _fallback
    • T5b: Call layer.group.clear() — do NOT call renderer.dispose()
  • T6: Implement unregister(id)

    • T6a: Guard on _fallback
    • T6b: Call layer.config.dispose(layer.group)
    • T6c: Call scene.remove(layer.group) (via local const capture)
    • T6d: Delete from this.layers
    • T6e: Update canvas display if no layers remain visible
  • T7: Remove remaining biome-ignore lint/correctness/noUnusedPrivateClassMembers comments from camera and rafId fields

  • T8: Add Story 1.3 tests to webgl-layer-framework.test.ts:

    • T8a: requestRender() — RAF coalescing: 3 calls → only 1 requestAnimationFrame()
    • T8b: requestRender()rafId resets to null after frame executes
    • T8c: syncTransform() — camera bounds match buildCameraBounds(0,0,1,960,540)
    • T8d: syncTransform() — uses ?? defaults when globals absent
    • T8e: render()syncTransform() called before layer callbacks, renderer.render() called last
    • T8f: render() — invisible layer's config.render() NOT called
    • T8g: setVisible(false)group.visible = false; dispose NOT called (NFR-P6)
    • T8h: setVisible(false) for ALL layers — canvas display = "none"
    • T8i: setVisible(true)requestRender() triggered
    • T8j: clearLayer()group.clear() called; layer remains in layers Map
    • T8k: clearLayer()renderer.dispose() NOT called (NFR-P6)
    • T8l: unregister()dispose() called; scene.remove() called; id removed from Map
    • T8m: unregister() last layer — canvas display = "none"
    • Also updated existing Story 1.1 test requestRender() does not throw to stub RAF globally
  • T9: npm run lint — zero errors

  • T10: npx vitest run src/modules/webgl-layer-framework.test.ts — all 34 tests pass; statement coverage 85.13% ≥ 80% (NFR-M5 ✓)

  • T11: Set story status to review


Dev Notes

Globals Referenced

Global Type Default in tests Source
viewX number 0 D3 zoom transform X translate
viewY number 0 D3 zoom transform Y translate
scale number 1 D3 zoom scale
graphWidth number 960 Map canvas logical width
graphHeight number 540 Map canvas logical height

All accessed via (globalThis as any).NAME ?? default — never destructure or assume presence (guard for Node test env).

What Story 1.3 Does NOT Cover

  • draw-relief-icons.ts refactor → Story 2.2
  • Performance benchmarking → Story 3.1
  • E2E / browser tests → out of scope for Epic 1

Coverage Target

NFR-M5 requires ≥80% statement coverage. After Story 1.3, all public methods and critical private paths are exercised. The remaining uncovered lines should be limited to edge cases in platform-specific paths (ResizeObserver callbacks, WebGL context loss handlers).


Dev Agent Record

Implementation Notes

  • render() TypeScript type safety: Used local const captures (const renderer = this.renderer; const scene = this.scene; const camera = this.camera;) immediately after the null-guard, before calling this.syncTransform(). This is required because TypeScript re-widens class instance field types after any method call — local consts preserve the narrowed (non-null) types for the final renderer.render(scene, camera) call.
  • unregister() local capture: Same pattern used for scene — captured before layer.config.dispose() call to preserve TypeScript narrowing.
  • syncTransform() local capture: const camera = this.camera; captured after guard, before variable assignments. No function calls between guard and camera use, so TypeScript narrows correctly; the capture is an additional safety measure.
  • Existing test update (T8 extra): The Story 1.1 test requestRender() does not throw when called multiple times was updated to add vi.stubGlobal("requestAnimationFrame", vi.fn().mockReturnValue(0)) since the stub method previously directly called render() (a no-op), but the real implementation now calls requestAnimationFrame which is absent in the Node.js test environment.
  • Uncovered lines (15%): Line 88 (|| 960 fallback in init() clientWidth branch) and lines 256/262-265 (ResizeObserver callback body). Both require real DOM resize events — not testable in Node unit tests. These represent expected coverage gaps acceptable per NFR-M5.

Files Modified

  • src/modules/webgl-layer-framework.ts — implemented requestRender(), syncTransform(), render(), setVisible(), clearLayer(), unregister(); removed 2 biome-ignore comments (camera, rafId)
  • src/modules/webgl-layer-framework.test.ts — updated 1 existing test (RAF stub); added new describe block WebGL2LayerFrameworkClass — lifecycle & render loop (Story 1.3) with 13 tests

Test Results

✓ modules/webgl-layer-framework.test.ts (34 tests) 9ms
  ✓ buildCameraBounds (5)
  ✓ detectWebGL2 (3)
  ✓ getLayerZIndex (1)
  ✓ WebGL2LayerFrameworkClass (7)
  ✓ WebGL2LayerFrameworkClass — init() (5)
  ✓ WebGL2LayerFrameworkClass — lifecycle & render loop (Story 1.3) (13)
Test Files  1 passed (1) | Tests  34 passed (34)

Coverage (v8):
  webgl-layer-framework.ts | 85.13% Stmts | 70.73% Branch | 84.21% Funcs | 91.26% Lines
  NFR-M5 (≥80% statement coverage): ✓ PASS

npm run lint: Checked 80 files — no fixes applied.