brainstorm

This commit is contained in:
Azgaar 2026-03-12 03:04:03 +01:00
parent 3047aefd40
commit dc212eed8a
2 changed files with 932 additions and 4 deletions

View file

@ -1,12 +1,13 @@
---
stepsCompleted: [1]
inputDocuments: []
stepsCompleted: [1, 2]
inputDocuments:
- "_bmad-output/planning-artifacts/research/technical-WebGL-SVG-layered-rendering-research-2026-03-12.md"
session_topic: "WebGL + SVG Layered Rendering Architecture for Relief Icons"
session_goals: "Explore all viable approaches for achieving correct layer ordering when mixing WebGL (Three.js) and SVG rendering for the relief icons layer; specifically evaluate and expand on the multi-SVG/multi-DOM-element architecture; surface edge cases, risks, and non-obvious possibilities"
selected_approach: "AI-Recommended"
selected_approach: "Progressive Technique Flow"
techniques_used: []
ideas_generated: []
context_file: ""
context_file: "_bmad-output/planning-artifacts/research/technical-WebGL-SVG-layered-rendering-research-2026-03-12.md"
---
# Brainstorming Session — WebGL Relief Icons Rendering Architecture

View file

@ -0,0 +1,927 @@
# Technical Research Report: WebGL + SVG Layered Rendering Architecture
**Date:** 2026-03-12
**Project:** Fantasy-Map-Generator
**Topic:** Browser compositing strategies for mixing Three.js WebGL and D3 SVG while preserving layer ordering
**Status:** Complete
---
## 1. Research Scope
### Problem Statement
The Fantasy-Map-Generator relief icons layer is currently rendered via SVG (slow at scale). The goal is to replace or augment it with Three.js WebGL for GPU-accelerated rendering. Three candidate architectures were identified:
| Option | Description | Known Issue |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| **A — Canvas beside SVG** | `<canvas>` element placed before/after the main SVG element | No layer interleaving possible — canvas cannot be interleaved within SVG layer groups |
| **B — WebGL inside `<foreignObject>`** | Three.js canvas nested inside an SVG `<foreignObject>` | Forces FBO compositing on every frame; slower than pure SVG in practice |
| **C — DOM-Split architecture** | The single SVG is decomposed into multiple sibling DOM elements (canvas + SVG fragments), positioned absolutely and ordered via z-index | A major architectural change that may restore layer interleaving |
This report evaluates the browser-level feasibility of Option C and any additional approaches.
---
## 2. Browser Compositing: CSS Stacking Contexts
**Source:** MDN Web Docs — "Stacking context", CSS Compositing specification
### 2.1 What Creates a Stacking Context
Each of the following CSS conditions on an element creates a **stacking context** (a compositing boundary within which children are atomically composed together before being composited with siblings):
| CSS Property / Condition | Value |
| ---------------------------------------------- | -------------------------------------------------------------------- |
| `position` (non-static) + `z-index` (non-auto) | Any `position: relative/absolute/fixed/sticky` with explicit z-index |
| `opacity` | `< 1` |
| `transform` | Any value except `none` |
| `filter` | Any value except `none` |
| `backdrop-filter` | Any value except `none` |
| `clip-path` | Any value except `none` |
| `mask` / `mask-image` / `mask-border` | Any non-none value |
| `mix-blend-mode` | Any value except `normal` |
| `isolation` | `isolate` |
| `will-change` | Any value that would create a stacking context if set |
| `contain` | `layout`, `paint`, `strict`, or `content` |
| `perspective` | Any value except `none` |
### 2.2 Key Rules for DOM-Split Architecture
1. **Stacking contexts are atomic**: All children of a stacking context are painted together before compositing with sibling stacking contexts. Z-index values are only meaningful within the _same parent_ stacking context.
2. **Multiple sibling elements** at the same level of the DOM, each with `position: absolute` and different `z-index` values, form a Z-ordered stack — regardless of whether they are SVG, `<canvas>`, or `<div>`.
3. **`isolation: isolate`** can be used on a wrapper container to explicitly create a stacking context boundary, preventing `mix-blend-mode` from propagating upward and creating predictable compositing behavior.
4. **`will-change: transform`** hints to the browser compositor that the element should get its own layer. This is useful for canvas elements that update frequently — it avoids invalidating surrounding layers.
### 2.3 Implication for Option C
Option C is **architecturally valid** from the browser stacking context model:
- Each visual "layer" becomes a sibling DOM node at the same ancestry level
- `position: absolute; inset: 0;` makes all siblings coextensive (covering the same rectangle)
- `z-index` controls paint order
- The browser compositor stacks them correctly, regardless of element type
---
## 3. OffscreenCanvas + ImageBitmapRenderingContext
**Source:** MDN Web Docs — OffscreenCanvas API
### 3.1 Status
`OffscreenCanvas` is **Baseline Widely Available** as of 2024:
| Browser | Minimum Version |
| ------- | --------------- |
| Chrome | 69 |
| Firefox | 105 |
| Safari | 16.4 |
| Edge | 79 |
### 3.2 Key Patterns
**Pattern A — Worker WebGL → ImageBitmap → Visible Canvas (zero-copy transfer)**
```javascript
// main thread
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker("relief-worker.js");
worker.postMessage({canvas: offscreen}, [offscreen]);
// worker thread (relief-worker.js)
self.onmessage = ({data}) => {
const gl = data.canvas.getContext("webgl2");
// render loop...
// Frames automatically appear on the visible canvas
};
```
This pushes the entire WebGL render loop off the main thread. The visible `<canvas>` element stays in the DOM at the correct z-index. The GPU work happens in a Worker.
**Pattern B — OffscreenCanvas + transferToImageBitmap (pull-style)**
```javascript
// main thread
const offscreen = new OffscreenCanvas(width, height);
const gl = offscreen.getContext("webgl2");
// render into offscreen...
const bitmap = offscreen.transferToImageBitmap();
visibleCanvas.getContext("bitmaprenderer").transferFromImageBitmap(bitmap);
```
This is synchronous on the main thread. The `ImageBitmapRenderingContext` (`bitmaprenderer`) is a lightweight context that just paints a pre-composited bitmap — no per-pixel work on the main thread.
### 3.3 WebGL Best Practices (from research)
- Prefer `webgl2` context
- Set `alpha: false` **only** if no transparency is needed; `alpha: false` can have a significant performance cost on some GPUs
- Use `gl.flush()` if rendering without `requestAnimationFrame` (e.g., render-on-demand MapGenerator pattern)
- Batch draw calls: minimize state changes between draw calls
- Use `texStorage2D` + `texSubImage2D` for texture atlas uploads
- Avoid FBO invalidation (the main cost of Option B / `<foreignObject>`)
---
## 4. Three.js Primitives for Relief Icon Rendering
**Source:** Three.js documentation — InstancedMesh, Sprite, CSS2DRenderer, CSS3DRenderer
### 4.1 InstancedMesh
`InstancedMesh(geometry, material, count)` enables drawing N copies of the same geometry in a **single draw call**:
```javascript
const mesh = new THREE.InstancedMesh(iconGeometry, iconMaterial, maxIcons);
// Per-instance position/scale/rotation:
mesh.setMatrixAt(i, matrix); // then mesh.instanceMatrix.needsUpdate = true
// Per-instance color tint:
mesh.setColorAt(i, color); // then mesh.instanceColor.needsUpdate = true
```
Key: The relief icon atlas (a single texture with all icon types as tiles) can be used with `InstancedMesh` where a per-instance attribute selects the UV offset into the atlas. This requires `ShaderMaterial` or `RawShaderMaterial` with custom UV attribute.
### 4.2 Sprite
`Sprite` is always camera-facing (billboard). Accepts `SpriteMaterial` with texture:
```javascript
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({map: texture}));
sprite.center.set(0.5, 0.5); // anchor point
```
Sprites are **per-instance objects** — expensive for thousands of icons. `InstancedMesh` is preferred.
### 4.3 CSS2DRenderer / CSS3DRenderer (Official Three.js Addon)
Three.js has official addon renderers that solve the **inverse problem** (overlaying DOM elements on WebGL):
- `CSS2DRenderer` creates a `<div>` overlay, absolutely positioned, matching the WebGL canvas size
- `CSS3DObject` / `CSS3DSprite` HTML elements are synced to 3D world-space positions
- They use the same camera/projection — no manual coordinate mapping needed
**Relevance for Option C**: This pattern reveals that Three.js _already uses_ a multi-layer approach for mixing rendering technologies. The same coordinate synchronization technique can be applied in reverse — syncing multiple sibling DOM elements (canvas layers + SVG layers) to the same logical coordinate space.
---
## 5. Mapbox GL v3 — "Slots" Layer Ordering Pattern
**Source:** Mapbox GL JS v3 migration guide
### 5.1 The Slots Concept
Mapbox GL v3 introduced "slots" as a way to interleave custom layers within a single WebGL render context without splitting the DOM:
```javascript
map.on("style.load", () => {
map.addLayer({id: "my-layer", slot: "middle"});
// Slots: 'bottom' | 'middle' | 'top'
});
```
Layers are rendered in slot order: all `bottom` layers first, then `middle`, then `top` — within a single WebGL context.
### 5.2 Implications
- This pattern demonstrates that even experts at Mapbox chose a **slot abstraction inside one WebGL context** rather than DOM splitting for their primary layer ordering solution
- DOM splitting is not used in production WebGL mapping libraries — they manage draw order explicitly within a single WebGL context
- The slot system is limited to predefined positions (bottom/middle/top) in Mapbox, not arbitrary interleavings
---
## 6. deck.gl — Reactive Layer Architecture
**Source:** deck.gl developer guide — Composite Layers, Layer Extensions
### 6.1 Layer Descriptor Model
deck.gl layers are **cheap, immutable descriptors** — creating a new `Layer` instance does not create GPU resources. The GPU state (buffers, textures, shaders) is managed separately and survives layer re-creation via shallow-diffing of props.
### 6.2 CompositeLayer Pattern
```javascript
class LabeledIconLayer extends CompositeLayer {
renderLayers() {
return [
new IconLayer({ ...icon props... }),
new TextLayer({ ...text props... }),
];
}
}
```
Composite layers solve the "label + icon" problem (multiple primitives per data point) without DOM involvement.
### 6.3 Layer Extensions
`LayerExtension` injects custom shader code (via GLSL injection hooks) into existing layers without subclassing. This is the mechanism for adding per-biome/per-icon-type shader behavior to an `InstancedMesh`-equivalent layer.
### 6.4 Note on Interleaving
The deck.gl interleaving documentation page (`/docs/developer-guide/interleaving`) returned 404. From context, deck.gl integrates with Mapbox/MapLibre by registering a "custom layer" inside the base map's render loop — meaning deck.gl layers participate in the slot system when `interleaved: true` is passed to the `DeckGL` constructor.
---
## 7. CSS will-change and GPU Compositor Layer Promotion
**Source:** MDN Web Docs — `will-change`
### 7.1 Usage
```css
.webgl-relief-layer {
will-change: transform;
}
```
- Hints browser to promote element to its own GPU compositor layer
- Creates a stacking context (important for z-ordering)
- **Warning**: Use sparingly — each compositor layer consumes GPU memory
- Best practice: set via JS immediately before animation/update, remove (set to `auto`) after
### 7.2 Dynamic Application
```javascript
canvas.style.willChange = "transform";
// ... perform update ...
canvas.style.willChange = "auto";
```
For the FMG use-case (render-on-demand, not continuous animation), manually toggling `will-change` around the render call can reduce compositor overhead.
---
## 8. CSS isolation: isolate
**Source:** MDN Web Docs — `isolation`
```css
.map-container {
isolation: isolate;
}
```
- `isolation: isolate` **forces** a new stacking context on the element
- Baseline Widely Available (Chrome 41+, Safari 8+)
- Most useful when `mix-blend-mode` is applied to children — `isolation: isolate` on the container prevents blend modes from compositing against elements outside the container
- For FMG map layers: `isolation: isolate` on the map container prevents any child `mix-blend-mode` (e.g., on the heightmap layer) from bleeding into UI elements outside the map
---
## 9. Alternative Approaches Discovered
Beyond the original three options:
### Option D — Render-to-Texture (Snapshot Pattern)
1. Three.js renders relief icons into a `WebGLRenderTarget` (off-screen FBO)
2. The FBO texture is exported as `ImageBitmap` using `gl.readPixels` or `THREE.WebGLRenderer.readRenderTargetPixels`
3. The bitmap is placed into an SVG `<image>` element at the correct layer position
4. This makes the WebGL output a **static image** from SVG's perspective — no special compositing needed
**Trade-off**: Every time icons change (zoom/pan), a re-render + readback is needed. `gl.readPixels` is synchronous and can stall the GPU pipeline. Acceptable for render-on-demand (FMG rarely re-renders all layers simultaneously).
### Option E — Pure InstancedMesh in Single Canvas, Sync Coordinates
The entire map is moved from SVG to a single `<canvas>` with Three.js rendering all layers. SVG-specific features (text labels, vector coastlines) can use Three.js `CSS3DRenderer` overlay.
**Trade-off**: Complete rewrite. Loss of SVG export capability and SVG-level accessibility.
### Option F — WebGL Points/Particles for Relief Icons
Replace Three.js Scene/Mesh with a `THREE.Points` + `PointsMaterial(sizeAttenuation: true)` + custom sprite sheet UVs. Each relief icon is a point particle with:
- Position: `BufferGeometry.setAttribute('position', ...)`
- UV offset into atlas: `BufferGeometry.setAttribute('uv', ...)`
- Custom vertex shader for per-point rotation/scale
Single draw call, extreme simplicity. No instanced matrix overhead.
### Option G — CSS3DRenderer Overlay (Hybrid DOM + WebGL)
Three.js `CSS3DRenderer` creates a `<div>` layer synchronized with the WebGL camera. By embedding SVG content inside `CSS3DObject` instances, SVG elements could theoretically track with map pan/zoom via CSS3D transforms without any canvas at all.
**Trade-off**: CSS 3D transforms are limited (no SVG-level precision), and browser compositing adds overhead. Not suitable for thousands of icons.
---
## 10. Synthesis: Evaluation Matrix
| Option | Layer Ordering | GPU Performance | Main-Thread Cost | Implementation Complexity | Risk |
| -------------------- | ------------------- | ------------------------------------ | ---------------- | ------------------------- | ----------------------------------- |
| A: Canvas beside SVG | ❌ Fixed at outside | ✅ Single canvas | Low | Low | None — already tried |
| B: foreignObject | ✅ Correct | ❌ FBO every frame | High | Low | Confirmed problematic |
| C: DOM Split | ✅ Correct | ✅ Good (one canvas per WebGL layer) | Medium | **High** | Browser behavior with many canvases |
| D: Render-to-Texture | ✅ SVG-native | Medium (readPixels stall) | Low (snapshot) | Medium | Stall on large maps |
| E: Pure Canvas | ✅ (trivial in 3D) | ✅ Best | Low | Very High | Full rewrite + SVG export loss |
| F: Points/Particles | ✅ Same as canvas | ✅ Best single-draw | Low | Low | Orthographic camera needed |
| G: CSS3D Overlay | ✅ Correct | Medium | Medium | Medium | CSS3D precision limits |
---
## 11. Key Technical Constraints for Fantasy-Map-Generator
From codebase analysis:
1. **Render-on-demand pattern**: FMG re-renders layers when map data changes (not on every animation frame). This means `requestAnimationFrame` loops are not used.
2. **SVG + D3 coordinate system**: All current layer positions use SVG viewport coordinates. Any WebGL canvas must map to the same coordinate space.
3. **Layer order is significant**: At least these layers must interleave correctly: `terrs` (terrain), `rivers`, `routes`, `relief`, `borders`, `burgIcons`, `markers`.
4. **TypedArray cell data**: The packed graph data is already GPU-friendly (TypedArrays). Uploading to WebGL buffers is straightforward.
5. **Two canvas limit concern**: Browsers typically allow 8-16 WebGL contexts before older ones are lost. Each `<canvas>` using WebGL counts. Option C with multiple WebGL canvases may hit this limit.
---
## 12. Recommended Focus Areas for Brainstorming
Based on research, the three most promising directions for deep exploration:
1. **Option C (DOM Split) — low canvas count variant**: Use a single WebGL canvas for all GPU-accelerated layers (not one per layer). Multiple SVG fragments and one WebGL canvas are interleaved by z-index. The single canvas renders only the union of GPU layers.
2. **Option F (Points/Particles) — single canvas, relief only**: Keep the existing SVG stack intact, add one `<canvas>` element with WebGL `gl.POINTS` rendering relief icons. Position the canvas using `position: absolute` and `z-index` in the correct slot. This is the minimal change.
3. **Option D (Snapshot) — render-to-texture + SVG `<image>`**: Three.js renders into FBO, FMG reads it as ImageBitmap (or uses CanvasTexture trick), injects into SVG `<image>` tag at correct layer. Leverages SVG's native layer ordering.
---
## 13. Wave 2 Research: Single-Canvas WebGL Layer Management
_Added in response to follow-up question: "Can we render all layers into a single WebGL Canvas with sublayers (z-index) inside, toggle them, reorder them, then have 1 SVG on top for interactive elements?"_
### 13.1 Mapbox GL JS — Proof of Concept (Production Scale)
Mapbox GL renders its entire map (ocean, terrain, labels, symbols, raster tiles, lines) in **one single WebGL context**. Layer management APIs:
| API | Description |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| `map.moveLayer(id, beforeId)` | Reorders a layer in the draw stack within the same slot; cross-slot moves are silently ignored |
| `map.setLayoutProperty(id, 'visibility', 'none'\|'visible')` | O(1) visibility toggle — GPU buffers (VBOs, textures) stay allocated |
| `map.addLayer(layerObj, beforeId)` | Inserts a new layer at a specific position in the draw stack |
**Key constraint:** Mapbox's slot system (`bottom`, `middle`, `top`) buckets layers — `moveLayer` only works within a slot. This implies even at production scale, a hierarchical ordering model is needed for complex layer stacks.
**Conclusion:** Single WebGL canvas with full layer ordering is used in production at massive scale. It absolutely works technically.
### 13.2 deck.gl — Layer Management Patterns (Optimal GPU Preservation)
deck.gl uses a declarative layer array (`deck.setProps({ layers: [...] })`). Key learnings:
| Pattern | Mechanism | GPU Impact |
| --------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| **Toggle visibility** | `new Layer({ visible: false })` prop | **GPU state preserved** — VBOs, shader programs stay allocated; recommended over conditional rendering |
| **Conditional rendering** (remove from array) | Layer removed from layers array | GPU state **destroyed** — expensive re-upload on next show; NOT recommended |
| **Reorder layers** | Re-order entries in layers array + call `setProps` | deck.gl diffs by layer `id`, matches existing GPU state; zero re-upload |
| **Z-fighting prevention** | `getPolygonOffset: ({ layerIndex }) => [0, -layerIndex * 100]` | Automatic polygon offset per layer index; handles coplanar layers |
**Critical insight:** The `visible: false` prop pattern preserves GPU state (avoids costly re-upload on show/hide toggle). This pattern is the correct one to use when designing FMG layer toggles in any WebGL migration.
### 13.3 Three.js — Layer Management APIs
| API | Description |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Object3D.visible = false` | Hides the object; GPU buffers stay allocated; equivalent to deck.gl `visible: false` |
| `Object3D.renderOrder = n` | Integer draw order override; lower numbers rendered first (painter's algorithm); works on `Group` objects (all children sorted together) |
| `Object3D.layers` (Layers bitmask) | 32 bitmask slots for **camera culling**, not draw order; `camera.layers.enable(n)` + `object.layers.set(n)` — camera renders only objects sharing at least one layer bit. Useful for selective rendering passes, not for z-ordering. |
**For FMG use case:** `renderOrder` is the correct API for z-ordering multiple logical layers within one Three.js scene. `visible` is the correct API for layer toggles.
```typescript
// In Three.js, assign each FMG layer a Group with renderOrder:
const oceanlayers = new THREE.Group();
oceanlayers.renderOrder = 1; // bottom
const rivers = new THREE.Group();
rivers.renderOrder = 11;
const relief = new THREE.Group();
relief.renderOrder = 12;
const burgIcons = new THREE.Group();
burgIcons.renderOrder = 26;
// Toggle:
burgIcons.visible = false; // GPU buffers stay allocated, instant toggle
// Reorder:
rivers.renderOrder = 15; // Instantly reorders in next frame
```
---
## 14. FMG SVG Architecture Deep Dive (Complete Layer Inventory)
_Confirmed via full codebase analysis of public/main.js and src/index.html._
### 14.1 Container Structure
- `<svg id="map">` — root SVG element; D3 zoom and resize targets it
- `<g id="viewbox">` — single transform container; **ALL map layers live here**; D3 zoom applies `translate(x y) scale(z)` to this element
- `#scaleBar`, `#legend`, `#vignette`**outside** `#viewbox`; fixed on screen (screen coordinates, not map coordinates)
### 14.2 Complete Layer Stack (32 layers, bottom to top)
| # | `<g id>` | Renders | SVG Feature Used |
| --- | ------------------------------------------------------------ | ---------------------------------- | --------------------------------------------------------------------------------------- |
| 1 | `ocean` + `oceanLayers`, `oceanPattern` | Depth gradient, ocean texture | `<path>`, `<rect>`, `url(#oceanic)` pattern |
| 2 | `lakes` + 6 sublayers | Lake fills by type | `<path>` |
| 3 | `landmass` | Base land color | `<rect>` + fill color |
| 4 | `texture` | Land texture overlay | `<image>` |
| 5 | `terrs` + `oceanHeights`, `landHeights` | Elevation contour bands | `<path>` |
| 6 | `biomes` | Biome color fills | `<path>` |
| 7 | `cells` | Voronoi cell grid | Single `<path>` |
| 8 | `gridOverlay` | Hex/square grid | `<rect>` + `url(#pattern_*)` pattern |
| 9 | `coordinates` + `coordinateGrid`, `coordinateLabels` | Lat/lon graticule + labels | `<path>`, **`<text>`** |
| 10 | `compass` | Compass rose | **`<use xlink:href="#defs-compass-rose">`** |
| 11 | `rivers` | River bezier curves | `<path>` |
| 12 | `terrain` | Relief icons | **`<use href="#defs-relief-*">`** per icon |
| 13 | `relig` | Religion fills | `<path>` |
| 14 | `cults` | Culture fills | `<path>` |
| 15 | `regions` + `statesBody`, `statesHalo` | State fills + halo | `<path>`, **`<clipPath>`** |
| 16 | `provs` + `provincesBody`, `provinceLabels` | Province fills + labels | `<path>`, **`<text>`** |
| 17 | `zones` | Zone fills | `<path>` |
| 18 | `borders` + `stateBorders`, `provinceBorders` | Political borders | `<path>` |
| 19 | `routes` + `roads`, `trails`, `searoutes` | Transport routes | `<path>` |
| 20 | `temperature` | Temperature cells + values | colored fills, **`<text>`** |
| 21 | `coastline` + `sea_island`, `lake_island` | Coastline shapes | `<path>` |
| 22 | `ice` | Glaciers, icebergs | `<path>` |
| 23 | `prec` + `wind` | Precipitation circles, wind arrows | `<circle>`, **`<text>` (unicode glyphs)** |
| 24 | `population` + `rural`, `urban` | Population bars | `<line>` |
| 25 | `emblems` + `burgEmblems`, `provinceEmblems`, `stateEmblems` | Heraldic CoAs | **`<use href="#...">` COA symbols from `#defs-emblems`** |
| 26 | `icons` + `burgIcons`, `anchors` | Burg icons, port anchors | **`<use href="#icon-*">` from `#defElements`** |
| 27 | `labels` + `states`, `addedLabels`, `burgLabels` | All map text labels | **`<text><textPath xlink:href="#">`** (curved state labels), **`<text>`** (burg labels) |
| 28 | `armies` | Regiment markers | **`<text>` (emoji/icon)**, `<image>` (banner) |
| 29 | `markers` | Point of interest icons | `<image href="...">` |
| 30 | `fogging-cont` | Fog of war | `<rect>` + mask |
| 31 | `ruler` | Measurement tools | `<path>`, `<circle>`, **`<text>`** |
| 32 | `debug` | Editor overlays | temporary |
### 14.3 SVG Features that Cannot Trivially Move to WebGL
| Feature | Layers Using It | WebGL Migration Cost |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `<text>` + `<textPath>` (curved text) | `#labels>#states`, `#addedLabels` | **VERY HIGH** — SDF font atlas or DOM overlay required; `<textPath>` has no WebGL equivalent |
| `<text>` (normal) | `#burgLabels`, `#coordinateLabels`, `#provinceLabels`, `#temperature`, `#prec>#wind`, `#armies`, `#scaleBar`, `#legend` | HIGH — CSS2DRenderer overlay or SDF atlas |
| `<use>` + defs symbols | `#terrain`, `#compass`, `#emblems`, `#icons`, `#burgIcons`, `#anchors` | HIGH — must pre-rasterize all symbols to texture atlases |
| `<clipPath>` | `#regions>#statesHalo` | MEDIUM — WebGL stencil buffer; doable but different model |
| `fill="url(#pattern*)"` | `#ocean>#oceanPattern`, `#gridOverlay` | MEDIUM — WebGL texture tiling shader |
| `fill="url(#hatch*)"` | Various political layers | MEDIUM — WebGL texture tiling shader |
| `<image>` | `#texture`, `#markers`, `#armies` | LOW — Three.js `PlaneGeometry` + `TextureLoader` |
### 14.4 SVG Export System (Critical Path)
File: `public/modules/io/export.js``exportToSvg()` / `getMapURL()`
The export function:
1. **Clones `#map` SVG element** via `cloneNode(true)`
2. Serializes the entire SVG DOM tree to XML string
3. Copies emblem COA symbols, compass, burg icons, grid patterns, hatch patterns into export defs
4. Converts raster image `href`s to base64 for self-contained export
5. Inlines all computed CSS styles
6. Copies all font face data-URIs as inline `<style>`
**If any layer moves to WebGL canvas:** that canvas pixel data is **not** in the SVG DOM — it would be invisible in the SVG export unless explicitly read back via `canvas.toDataURL()` and injected as a `<image>` element. This produces a rasterized inset, not vectors.
**This is the single most important constraint for any full WebGL migration.**
### 14.5 Interactive Event Architecture
All map interactions route through `public/modules/ui/editors.js``clicked()`:
```
click on SVG element → clicked() walks DOM ancestry → dispatches to editor:
#emblems → editEmblem()
#rivers → editRiver(el.id)
#routes → editRoute(el.id)
#labels → editLabel()
#burgLabels | #burgIcons → editBurg()
#ice → editIce(el)
#markers → editMarker()
etc.
```
The SVG overlay approach (one thin SVG on top of WebGL canvas) for interactivity is architecturally sound — `pointer-events:none` on canvas, SVG captures all clicks. However, the SVG hit-test shapes must exactly match the WebGL-rendered visual shapes, which requires keeping both systems in sync.
---
## 15. Single-Canvas Full WebGL Migration: Honest Assessment
### 15.1 What Gets Faster (and by How Much)
| Layer Type | Current (SVG) | WebGL Estimate | Speedup |
| --------------------------------------- | ------------------------------------- | --------------------------------------------------- | --------------------------------- |
| Terrain fills (`terrs`, `biomes`, etc.) | 10k+ SVG polygon paint | Triangulated `BufferGeometry` fills | 20100× |
| Relief icons (`terrain`) | SVG `<use>` elements, per-element DOM | `InstancedMesh` + texture atlas, 1 draw call | **100200×** |
| Rivers/borders/coastlines | SVG `<path>` lines | Three.js `LineSegments` `BufferGeometry` | 1050× |
| State/culture/province fills | Complex SVG paths + clip masks | Pre-triangulated WebGL meshes | 2050× |
| Labels (all `<text>`) | SVG text (fast in SVG) | CSS2DRenderer DOM overlay (same speed) or SDF atlas | 0× (no gain) or complex migration |
| Emblems COAs | SVG `<use>` symbols | Pre-rasterized sprite texture (loss of quality) | Hard |
| Map pan/zoom | D3 transform on `#viewbox` | Camera matrix uniform, sub-ms | Equivalent |
**Bottom line on performance:** The layers consuming the most paint time (10k+ polygons, relief icons) would be **dramatically faster** in WebGL. The overhead layers (labels, emblems) would either stay the same (DOM overlay) or require complex SDF solutions.
### 15.2 What Breaks (Severity)
| Feature | Severity | Required Solution |
| ----------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **SVG export** (editable vector file) | 🔴 HARD BLOCKER | Must maintain parallel SVG structure OR rasterize WebGL layers to `<image>` in export (breaks editability) OR build WebGL→SVG serializer |
| **Curved text labels** (`<textPath>`) | 🔴 HIGH | No WebGL equivalent; must use DOM overlay (CSS2DRenderer) or SDF font atlas + custom path-text renderer |
| **Emblem `<use>` / COA system** | 🔴 HIGH | SVG symbol system is deeply integrated; would need to pre-rasterize all ~1000+ possible charges to texture atlas |
| **`<clipPath>` halo effects** | 🟠 MEDIUM | WebGL stencil buffer (doable, but different model) |
| **Pattern fills** (halos, grid, hatching) | 🟠 MEDIUM | WebGL texture tiling shaders |
| **Click dispatch via DOM ancestry** | 🟠 MEDIUM | SVG overlay captures events; thin SVG matching shapes must be maintained |
| **All 32 layers must be reimplemented** | 🔴 TOTAL REWRITE | Months of engineering work |
### 15.3 Honest Recommendation
**Full single-canvas WebGL migration is technically correct but not advisable for FMG** due to the SVG export blocker. The map's core value proposition to users is downloadable, editable, shareable `.svg` files — beautiful vector maps. Replacing this with PNG-only output would be a fundamental regression.
**Recommended approach instead — Hybrid GPU acceleration:**
1. **Keep the SVG stack** (30 layers, labels, emblems, interactions, export — all stay)
2. **Add one WebGL canvas behind the SVG** for the 3 highest-impact performance layers:
- Relief icons (`#terrain`) → `InstancedMesh` → 100× faster (**the original goal**)
- Possibly terrain fills (`#terrs`) → pre-triangulated fills → 30× faster
3. **SVG export** — for WebGL-rendered layers, inject `canvas.toDataURL()` as `<image>` in the SVG export; these layers become rasterized in the exported SVG (acceptable trade-off — terrain fills and relief icons don't need to be editable vectors)
4. **Layer ordering** — use CSS z-index between the WebGL canvas and SVG fragments (Option C-revised from wave 1 research)
This gives **80% of the performance gain at 20% of the migration cost**, without destroying SVG export.
---
## 17. Wave 3 Research: Framework Selection for FMG's Arbitrary Data Model
### 17.1 The Coordinate System Problem
Before evaluating frameworks, the single most decisive factor is **coordinate system compatibility**. FMG uses a pixel-space origin at top-left, Y-increasing downward, with `graphWidth=960` and `graphHeight=540` as the default canvas dimensions. All cell centers, vertex coordinates, burg positions, and relief icon placements are expressed in this space. **No GeoJSON, no latitude/longitude, no Mercator projection anywhere in the codebase.**
This eliminates entire classes of frameworks at a glance.
---
### 17.2 Mapbox GL JS
**Source:** Mapbox GL JS API docs — `Map`, `CustomLayerInterface`, `addLayer`
#### Coordinate System Compatibility
Mapbox GL is architecturally inseparable from the Web Mercator projection. Every map instance requires a `center: [lng, lat]` and zoom level `022`. All built-in layers (fill, line, circle, symbol) consume **GeoJSON sources** with WGS84 longitude/latitude values. The `CustomLayerInterface.render(gl, matrix)` method gives access to the raw WebGL context and a projection matrix — but that matrix is a **Mercator warp matrix** (world space → NDC via Mercator projection). Feeding pixel-space coordinates to it would produce wildly incorrect results unless you construct a matching inverse transform, making pixel-space coordinates expensive to work with and requiring constant maintenance as Mapbox's internals evolve.
Theoretically you _could_ treat the Mercator world as a coordinate proxy and map FMG pixel coordinates to geographic coordinates (e.g., map [0,960] → [-180,180] and [0,540] → [-90,90]), but:
1. Mercator is not linear in Y — the grid cells closer to "poles" would be distorted.
2. Every coordinate conversion (bi-directional) would need to happen for all 10,000 cells on every map re-render.
3. Features like `queryRenderedFeatures`, collision detection, and label placement would all behave incorrectly.
#### Additional Costs
- **Requires API key**: Mapbox GL JS requires `mapboxgl.accessToken` set to a valid paid API key. FMG is a free, open-source application. This is a direct financial and licensing concern.
- **Bundle size**: ~900KB gzipped.
- **rAF rendering loop**: Mapbox maintains a continuous WebGL rendering loop; integrating on-demand rendering (FMG's model — render when user makes a change) is awkward.
- **Style system overhead**: Full style specification parsing, tile loading, glyph/sprite management — unused infrastructure for FMG.
**Verdict: ❌ Incompatible. Do not use.**
---
### 17.3 MapLibre GL
**Source:** MapLibre GL is the community-maintained open-source fork of Mapbox GL JS v1.
MapLibre GL shares Mapbox GL's architecture entirely: Mercator projection, GeoJSON tile-based data model, `CustomLayerInterface` with the same projection matrix constraints. It does not require an API key (significant improvement over Mapbox), and its bundle is slightly smaller (~700KB gzipped), but the fundamental geographic data model incompatibility with FMG remains.
**Verdict: ❌ Same data model incompatibility as Mapbox GL. No API key is an improvement, but the coordinate system mismatch is disqualifying.**
---
### 17.4 deck.gl
**Sources:** deck.gl docs — `OrthographicView`, `Views and Projections`, `Performance Optimization` (binary data)
#### Coordinate System Compatibility
deck.gl is **explicitly designed for non-geospatial 2D data** via its `OrthographicView`. The `Views and Projections` guide states directly: _"If using non-geospatial data, you will need to manually create a view that is appropriate for info-vis, e.g.: `new OrthographicView()`"_
The `OrthographicView` view state parameters:
- `target: [480, 270, 0]` — viewport center in world units (FMG pixel space, set to center of map)
- `zoom: 0` — maps **1 world unit to 1 screen pixel** by default; increasing zoom by 1 doubles scale
- `flipY: true` (default) — enables **top-left origin, Y-increases downward**, exactly matching FMG/SVG convention
This means FMG's raw pixel coordinates can be fed directly to deck.gl layers in `OrthographicView` mode without any conversion.
#### Binary / TypedArray Data
deck.gl supports `Float32Array` input at two levels:
**Level 1 — Binary blob with stride accessor** (medium overhead):
```ts
const DATA = {src: Float32Array, length: N};
new ScatterplotLayer({
data: DATA,
getPosition: (_, {index, data}) => data.src.subarray(index * 2, index * 2 + 2)
});
```
**Level 2 — Pre-built GPU attributes** (zero CPU overhead):
```ts
// Positions pre-packed as Float32Array: x0,y0,x1,y1,...
new Layer({
data: {length: N, attributes: {getPosition: {value: positionsFloat32, size: 2}}}
});
```
Level 2 completely bypasses deck.gl's CPU attribute generation. This is directly compatible with FMG's `vertices.p` array (array of `[number, number]` pairs that can be converted to `Float32Array` once at map generation and cached).
#### Standalone (No Basemap) Usage
deck.gl can be used with no geographic basemap:
```ts
new Deck({
canvas: myCanvas,
views: new OrthographicView({ flipY: true }),
viewState: { target: [graphWidth/2, graphHeight/2, 0], zoom: 0 },
layers: [...]
});
```
No basemap, no map style, no tile server, no API key.
#### Built-in Layers Relevant to FMG
| deck.gl Layer | FMG Use Case | Notes |
| ------------------- | ------------------------------------ | ---------------------------------------------------------------------- |
| `SolidPolygonLayer` | Cell fills (biomes, states, terrain) | CPU earcut triangulation per update |
| `ScatterplotLayer` | Burgs, markers, icons | Instanced rendering |
| `PathLayer` | Rivers (solid-width stroke), borders | Only uniform width; for variable-width rivers a custom layer is needed |
| `TextLayer` | Labels | WebGL text via SDF glyphs; limited curved-text support |
| `BitmapLayer` | Texture overlays | Useful for pre-rendered backgrounds |
**Critical limitation for polygon fills:** `SolidPolygonLayer` accepts `getPolygon` returning `[x, y][]` per-cell. FMG stores vertex rings as indices into `vertices.p`: `cells.v[i].map(v => vertices.p[v])`. Converting this is a `O(totalVertices)` JS array mapping per update. At 10,000 cells it's manageable for single renders but still allocates JS arrays.
**Critical limitation for rivers:** Rivers are variable-width closed polygons (Catmull-Rom + left/right offset arrays). deck.gl's `PathLayer` is uniform-width stroked lines. River rendering would require a custom deck.gl `ShaderLayer`, which is effectively raw luma.gl WebGL code.
#### Rendering Model
deck.gl uses `requestAnimationFrame` internally. Calling `deckInstance.setProps({layers})` triggers a re-render on the next frame. For FMG's on-demand model this is acceptable (12ms frame latency) but requires awareness that renders do not happen synchronously.
#### Costs
- **Bundle size**: ~480KB gzipped for `@deck.gl/core` + `@deck.gl/layers`. This is an entirely new dependency on top of Three.js which is already present.
- **Rendering model tension**: rAF-based loop vs. FMG's event-driven renders.
- **Polygon triangulation**: `SolidPolygonLayer` does earcut on CPU on data change — same work as Three.js + earcut, but with the overhead of the deck.gl framework layer.
- **River rendering**: No built-in support; custom layer needed.
**Verdict: ✅ Technically compatible with FMG data model. Excellent API. However, it's a large new dependency on top of Three.js.**
---
### 17.5 Three.js (Already in Project)
**Sources:** Existing FMG codebase — `src/renderers/draw-relief-icons.ts`; Three.js API docs
#### Coordinate System Compatibility
Three.js already uses the **correct coordinate system** for FMG:
```typescript
// From src/renderers/draw-relief-icons.ts
camera = new THREE.OrthographicCamera(0, graphWidth, 0, graphHeight, -1, 1);
// top=0, bottom=graphHeight → Y-down, matches perfect SVG/FMG coordinate system
```
This camera setup was established in the existing relief icon renderer. Every future WebGL layer can reuse it as-is.
#### TypedArray / BufferGeometry Data
`THREE.BufferGeometry` accepts typed arrays directly as vertex attributes:
```typescript
const positions = new Float32Array(triangleCount * 6); // x0,y0, x1,y1, x2,y2 ...
// Fill from vertex rings: cells.v[i].map(v => vertices.p[v]) → earcut triangles
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(positions, 2));
```
FMG's `vertices.p` (array of `[x,y]` pairs) maps directly to `Float32Array` with zero coordinate conversion. The only additional step for polygon fills is **earcut triangulation** (a ~3KB dependency outputting flat `[x0,y0,x1,y1,x2,y2,...]` triangle arrays, usable directly as the position buffer).
#### Layer Management
Three.js provides complete layer management:
```typescript
const scene = new THREE.Scene();
// Ordering
mesh.renderOrder = 2; // controls draw order
// Visibility toggle (no re-upload to GPU)
mesh.visible = false;
// Group reordering
scene.children.splice(fromIdx, 1);
scene.children.splice(toIdx, 0, mesh);
```
This is functionally equivalent to deck.gl's `visible` prop and `renderOrder` (as verified in Wave 2 research).
#### On-Demand Rendering
```typescript
// Render ONLY when needed — completely natural in Three.js
function redrawWebGL() {
renderer.render(scene, camera);
}
// Called by user action, not rAF loop
```
This matches FMG's event-driven model perfectly. Three.js doesn't force a render loop.
#### Custom Shaders
`THREE.ShaderMaterial` provides full GLSL control when built-in materials are insufficient:
```typescript
new THREE.ShaderMaterial({
vertexShader: `...`,
fragmentShader: `...`,
uniforms: {u_colorMap: {value: biomeTexture}}
});
```
Example: biome fills using a 1D color lookup texture (avoids per-vertex color arrays), or contour lines using height-threshold fragment shader.
#### Relevant Three.js Primitives for FMG Layers
| Three.js Primitive | FMG Use Case | Notes |
| --------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `InstancedMesh` | Relief icons (`#terrain`) | **Already implemented**; 100200× over SVG `<use>` |
| `Mesh` + `BufferGeometry` | Cell fills (biomes, states, terrain) | Earcut triangulation once per generation |
| `LineSegments` + `BufferGeometry` | Coastlines, rivers (outline), borders | Uniform width; variable-width via geometry |
| `Mesh` (custom vertex layout) | Rivers (variable-width polygon) | Pre-computed Catmull-Rom + offset polygon in existing `layers.js`; just pass vertices to BufferGeometry |
| `ShaderMaterial` | Height contours, animated effects | Full GLSL control |
| `CSS2DRenderer` | Text labels (overlay) | DOM-based; no GPU acceleration but avoids regression |
#### Costs
- **Already in project**: Zero new bundle weight.
- **Polygon triangulation**: Needs `earcut` (~3KB); One-time cost per map generation, result cached.
- **No curved text**: Labels with `<textPath>` have no Three.js equivalent; CSS2DRenderer is the workaround (same speed as SVG, no regression).
- **Some manual work**: Scene management, layer add/remove is more explicit than deck.gl's declarative API.
**Verdict: ✅ Best fit for FMG. Already present, already correctly calibrated for pixel-space, natural on-demand rendering model, direct TypedArray data path.**
---
### 17.6 Custom WebGL2 (or via luma.gl)
Custom WebGL2 offers maximum flexibility and zero framework overhead. For FMG's use case, the key primitives are:
- `gl.drawArraysInstanced()` → relief icons (same as `InstancedMesh` but manual)
- `gl.drawElements()` with index buffer → polygon fills
- GLSL uniforms for color tables, zoom transforms
The main cost is maintenance burden: projection matrices, state management, draw-call ordering, shader compilation, context loss/restore handling, and future feature development all require custom WebGL code. Three.js handles all of this with battle-tested infrastructure.
`luma.gl` (the WebGL abstraction underlying deck.gl) would reduce the raw WebGL burden, but adds the same ~480KB bundle as deck.gl itself.
**Verdict: ⚠️ Viable, but Three.js with `ShaderMaterial` captures 95% of custom shader flexibility without abandoning Three.js's infrastructure. Only worth pursuing if Three.js's constraints become blocking.**
---
### 17.7 Cross-Framework Comparison
| Criterion | Mapbox GL | MapLibre GL | deck.gl | **Three.js** | Custom WebGL2 |
| -------------------------------- | ---------------------- | ---------------------- | -------------------------------- | ----------------------------- | --------------------- |
| **FMG pixel-space coords** | ❌ Mercator only | ❌ Mercator only | ✅ OrthographicView | ✅ Already working | ✅ Full control |
| **TypedArray data (no GeoJSON)** | ❌ GeoJSON req. | ❌ GeoJSON req. | ✅ Binary attributes | ✅ BufferGeometry | ✅ Direct VBOs |
| **Already in project** | ❌ | ❌ | ❌ | ✅ v0.183.2 | N/A (no dep) |
| **Bundle overhead** | ~900KB | ~700KB | ~480KB | **0** (present) | 0 |
| **API key / cost** | ✅ Required | ❌ Not required | ❌ Not required | ❌ Not required | ❌ Not required |
| **On-demand rendering** | ⚠️ Loop-based | ⚠️ Loop-based | ⚠️ rAF (1-frame lag) | ✅ Native | ✅ Native |
| **Layer visibility toggle** | ✅ | ✅ | ✅ `visible` prop | ✅ `.visible` | Manual |
| **Layer draw ordering** | ✅ Slots / beforeId | ✅ | ✅ `renderOrder` | ✅ `renderOrder` | Manual |
| **Polygon fills** | GeoJSON fill layer | GeoJSON fill layer | `SolidPolygonLayer` (CPU earcut) | `BufferGeometry` + earcut | VBO + earcut |
| **Instanced icons** | Symbol layer | Symbol layer | `ScatterplotLayer` | `InstancedMesh` ✅ | `drawArraysInstanced` |
| **Variable-width rivers** | ❌ N/A | ❌ N/A | ❌ Custom layer needed | ✅ Pre-built polygon → `Mesh` | ✅ VBO |
| **Custom shaders** | `CustomLayerInterface` | `CustomLayerInterface` | `ShaderLayer` / luma.gl | `ShaderMaterial` | Direct GLSL |
| **Text labels** | Symbol layer | Symbol layer | `TextLayer` (limited) | `CSS2DRenderer` | Canvas2D texture |
| **Maintenance overhead** | Low (managed) | Low (managed) | Low-Medium | **Medium** (established) | High |
| **FMG data codec needed?** | Yes (→ GeoJSON WGS84) | Yes (→ GeoJSON WGS84) | Minor (→ Float32Array) | **Minimal** (vertices direct) | None |
---
## 18. Final Framework Recommendation
### 18.1 Primary Recommendation: Three.js (Expand Existing Usage)
**Primary recommendation: continue using Three.js. Expand its usage to additional layers.**
Rationale:
1. **Zero new dependency**: Three.js 0.183.2 is already installed and configured. Adding polygon fills to the WebGL scene costs nothing in bundle size.
2. **Coordinate system already correct**: `OrthographicCamera(0, graphWidth, 0, graphHeight, -1, 1)` established in `draw-relief-icons.ts` is the exact pixel-space camera needed. No translation layer required.
3. **Direct typed array path**: FMG stores all geometry in `Uint16Array`/`Float32Array`/`number[][]`. These map directly to `BufferGeometry` attributes. The only extra step for fills is earcut triangulation — a single ~3KB library, computed once per map generation.
4. **On-demand render model**: Three.js renders exactly when `renderer.render(scene, camera)` is called. No frame loop, no stale-frame latency. This matches FMG's event-driven update model (map regeneration, layer toggle, user edit).
5. **Variable-width rivers**: Rivers in FMG are already pre-computed as variable-width closed polygons (Catmull-Rom + left/right offsets in `layers.js`). These vertex arrays can be passed directly to `BufferGeometry` — no special framework support needed.
6. **Layer management equivalence**: Three.js's `renderOrder`, `visible`, and `scene.children` ordering provide the same functional layer management as deck.gl's production-proven API — just more explicit.
7. **Custom shaders available**: `ShaderMaterial` with GLSL unlocks height-based contour rendering, animated water effects, biome color ramp textures, etc. — without abandoning Three.js infrastructure.
### 18.2 Why Not deck.gl?
deck.gl's `OrthographicView` is technically compatible with FMG's data model, and its binary attribute API is excellent. However:
- It would add ~480KB to a bundle where Three.js is already present and sufficient
- `SolidPolygonLayer` does CPU earcut triangulation on every data change — the same operation needed in Three.js, with additional framework overhead
- River rendering (variable-width polygon) requires a custom deck.gl `ShaderLayer` — equivalent work to using `ShaderMaterial` in Three.js but with luma.gl's API instead of Three.js's well-documented one
- FMG's on-demand render model requires working around deck.gl's rAF loop
- The declarative API convenience doesn't justify the bundle addition when Three.js already handles the use case
deck.gl would be the correct choice if FMG were starting from scratch with no existing WebGL dependency. Given Three.js is already present and correctly configured, deck.gl adds cost without adding capability.
### 18.3 Why Not Mapbox GL or MapLibre GL?
Both are eliminated by the coordinate system mismatch. Their built-in layers require GeoJSON WGS84 coordinates. FMG's data is arbitrary pixel space. The `CustomLayerInterface` provides low-level WebGL access but against a Mercator projection matrix, making pixel-space rendering a constant source of complexity. Additionally, Mapbox GL requires a paid API key, which is incompatible with an open-source free tool.
### 18.4 Revised Phased Implementation Path (Three.js)
Building on the hybrid recommendation from Section 15.3:
| Phase | Layer | Technique | Est. Speedup | Complexity |
| ------------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------- | ------------ | ---------------------------- |
| **Phase 0** (done) | Relief icons (`#terrain`) | `InstancedMesh` + texture atlas | 100200× | ✅ Complete |
| **Phase 1** | Terrain fills (`#terrs`, `#biomes`) | `BufferGeometry` + earcut, per-cell biome colors | 2050× | Low |
| **Phase 2** | Heightmap contours | `ShaderMaterial` height-threshold, or `LineSegments` from pre-traced chains | 3080× | Medium |
| **Phase 3** | State/culture/province fills | `BufferGeometry` + earcut, per-cell state color | 2050× | Low (reuse Phase 1 pipeline) |
| **Phase 4** | Rivers | Pass pre-built variable-width polygon vertices to `Mesh` (reuse existing `layers.js` geometry) | 1030× | Medium |
| **Optional** | Coastlines/borders | `LineSegments` or `ShaderMaterial` anti-aliased lines | 1040× | Low-Medium |
**Key engineering invariant:** The SVG stack stays intact for all 32 layers. WebGL layers are rendered to a canvas positioned _behind_ the SVG via CSS `z-index`. SVG export injects `canvas.toDataURL()` as `<image>` for WebGL-rendered layers (rasterized in export, acceptable trade-off for terrain/fill layers).
### 18.5 Data Pipeline for Polygon Fills (Phase 1 Reference)
```typescript
// 1. Collect all vertex rings per biome/state group
// cells.v[i] = vertex ring for cell i (array of vertex IDs)
// vertices.p[v] = [x, y] coordinates of vertex v
// 2. One-time triangulation (at map generation, cached)
import Earcut from "earcut"; // 3KB
const rings = cells.v[cellIdx].map(v => vertices.p[v]); // [[x0,y0],[x1,y1],...]
const flatCoords = rings.flat(); // [x0,y0,x1,y1,...]
const triangles = Earcut.triangulate(flatCoords, null, 2); // indices into flatCoords
// 3. Accumulate per-biome into Float32Array position buffer
// Group cells by biome, build one Mesh per biome (batches all draws to 12 draw calls for 12 biomes)
// 4. Create BufferGeometry
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(positionsFloat32, 2));
geo.setIndex(new THREE.BufferAttribute(indexUint32, 1));
// 5. MeshBasicMaterial or ShaderMaterial
const mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({color: biomeColor}));
mesh.renderOrder = LAYER_ORDER.TERRAIN_FILLS; // z-ordering
scene.add(mesh);
```
This reduces 10,000 SVG polygon elements to **≤12 WebGL draw calls** (one per biome), with geometry calculated once and GPU-resident.
---
## 16. Updated Sources
| Topic | URL |
| --------------------------------- | ------------------------------------------------------------------------- |
| Mapbox moveLayer | https://docs.mapbox.com/mapbox-gl-js/api/map/#map#movelayer |
| Mapbox setLayoutProperty | https://docs.mapbox.com/mapbox-gl-js/api/map/#map#setlayoutproperty |
| Mapbox CustomLayerInterface | https://docs.mapbox.com/mapbox-gl-js/api/properties/#customlayerinterface |
| deck.gl layer `visible` prop | https://deck.gl/docs/api-reference/core/layer#visible |
| deck.gl using layers guide | https://deck.gl/docs/developer-guide/using-layers |
| deck.gl Views and Projections | https://deck.gl/docs/developer-guide/views |
| deck.gl OrthographicView | https://deck.gl/docs/api-reference/core/orthographic-view |
| deck.gl Performance (binary data) | https://deck.gl/docs/developer-guide/performance |
| Three.js Object3D | https://threejs.org/docs/#api/en/core/Object3D |
| Three.js Layers bitmask | https://threejs.org/docs/#api/en/core/Layers |
---
## 13. Sources
| Topic | URL |
| ------------------------ | --------------------------------------------------------------------------------------- |
| CSS Stacking Context | https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context |
| WebGL Best Practices | https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices |
| OffscreenCanvas API | https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas |
| CSS will-change | https://developer.mozilla.org/en-US/docs/Web/CSS/will-change |
| CSS isolation | https://developer.mozilla.org/en-US/docs/Web/CSS/isolation |
| Mapbox GL v3 Migration | https://docs.mapbox.com/mapbox-gl-js/guides/migrate-to-v3/ |
| Three.js InstancedMesh | https://threejs.org/docs/#api/en/objects/InstancedMesh |
| Three.js Sprite | https://threejs.org/docs/#api/en/objects/Sprite |
| deck.gl Composite Layers | https://deck.gl/docs/developer-guide/custom-layers/composite-layers |
| deck.gl Layer Extensions | https://deck.gl/docs/developer-guide/custom-layers/layer-extensions |