# 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** | `` element placed before/after the main SVG element | No layer interleaving possible — canvas cannot be interleaved within SVG layer groups | | **B — WebGL inside ``** | Three.js canvas nested inside an SVG `` | 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, ``, or `
`. 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 `` 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 / ``) --- ## 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 `
` 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 `` 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 `` 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 `
` 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 `` 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 `` 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 ``**: Three.js renders into FBO, FMG reads it as ImageBitmap (or uses CanvasTexture trick), injects into SVG `` 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 - `` — root SVG element; D3 zoom and resize targets it - `` — 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) | # | `` | Renders | SVG Feature Used | | --- | ------------------------------------------------------------ | ---------------------------------- | --------------------------------------------------------------------------------------- | | 1 | `ocean` + `oceanLayers`, `oceanPattern` | Depth gradient, ocean texture | ``, ``, `url(#oceanic)` pattern | | 2 | `lakes` + 6 sublayers | Lake fills by type | `` | | 3 | `landmass` | Base land color | `` + fill color | | 4 | `texture` | Land texture overlay | `` | | 5 | `terrs` + `oceanHeights`, `landHeights` | Elevation contour bands | `` | | 6 | `biomes` | Biome color fills | `` | | 7 | `cells` | Voronoi cell grid | Single `` | | 8 | `gridOverlay` | Hex/square grid | `` + `url(#pattern_*)` pattern | | 9 | `coordinates` + `coordinateGrid`, `coordinateLabels` | Lat/lon graticule + labels | ``, **``** | | 10 | `compass` | Compass rose | **``** | | 11 | `rivers` | River bezier curves | `` | | 12 | `terrain` | Relief icons | **``** per icon | | 13 | `relig` | Religion fills | `` | | 14 | `cults` | Culture fills | `` | | 15 | `regions` + `statesBody`, `statesHalo` | State fills + halo | ``, **``** | | 16 | `provs` + `provincesBody`, `provinceLabels` | Province fills + labels | ``, **``** | | 17 | `zones` | Zone fills | `` | | 18 | `borders` + `stateBorders`, `provinceBorders` | Political borders | `` | | 19 | `routes` + `roads`, `trails`, `searoutes` | Transport routes | `` | | 20 | `temperature` | Temperature cells + values | colored fills, **``** | | 21 | `coastline` + `sea_island`, `lake_island` | Coastline shapes | `` | | 22 | `ice` | Glaciers, icebergs | `` | | 23 | `prec` + `wind` | Precipitation circles, wind arrows | ``, **`` (unicode glyphs)** | | 24 | `population` + `rural`, `urban` | Population bars | `` | | 25 | `emblems` + `burgEmblems`, `provinceEmblems`, `stateEmblems` | Heraldic CoAs | **`` COA symbols from `#defs-emblems`** | | 26 | `icons` + `burgIcons`, `anchors` | Burg icons, port anchors | **`` from `#defElements`** | | 27 | `labels` + `states`, `addedLabels`, `burgLabels` | All map text labels | **``** (curved state labels), **``** (burg labels) | | 28 | `armies` | Regiment markers | **`` (emoji/icon)**, `` (banner) | | 29 | `markers` | Point of interest icons | `` | | 30 | `fogging-cont` | Fog of war | `` + mask | | 31 | `ruler` | Measurement tools | ``, ``, **``** | | 32 | `debug` | Editor overlays | temporary | ### 14.3 SVG Features that Cannot Trivially Move to WebGL | Feature | Layers Using It | WebGL Migration Cost | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `` + `` (curved text) | `#labels>#states`, `#addedLabels` | **VERY HIGH** — SDF font atlas or DOM overlay required; `` has no WebGL equivalent | | `` (normal) | `#burgLabels`, `#coordinateLabels`, `#provinceLabels`, `#temperature`, `#prec>#wind`, `#armies`, `#scaleBar`, `#legend` | HIGH — CSS2DRenderer overlay or SDF atlas | | `` + defs symbols | `#terrain`, `#compass`, `#emblems`, `#icons`, `#burgIcons`, `#anchors` | HIGH — must pre-rasterize all symbols to texture atlases | | `` | `#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 | | `` | `#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 `