# WebGL2 GPU-Accelerated 2D Plan Renderer — Architecture

## Executive Summary

A WebGL2 canvas renderer for PlanView's heavy geometry (polygons, lines, hatches) with CPU fallback. Geometry tessellates once per plan and caches; pan/zoom only updates a transform-matrix uniform. Screen-space stroke width via vertex shader normal expansion. Thin SVG overlay handles text, grips, snap markers, tool preview.

**No npm dependencies** — raw WebGL2 + TypeScript.

---

## Current State (SVG Bottleneck)

**PlanView.tsx** renders `Primitive[]` (polygon/line/arc/text) → SVG DOM:
- ~2500 LOC: pan/zoom via viewBox, toScreen() scaling (1 meter = 90 viewBox units)
- `Primitive` types (generatePlan.ts:133):
  - **polygon**: `pts: Vec2[]`, fill/stroke/strokeWidthMm, hatch (solid/insulation/diagonal/crosshatch)
  - **line**: a/b endpoints, className, weightMm, dash[], optional color
  - **arc**: center, from/to points, r, className, weightMm, dash[]
  - **text**: anchor, RichTextDoc, roomStamp metadata
- Bottleneck: **pan/zoom re-renders entire SVG DOM** → Cairo rasterizes geometry at 144 Hz

**Key constants:**
- `PX_PER_M = 90` (viewBox units per meter; Modell-Y up → SVG-Y down via negation)
- `PAD = 60` (margin in viewBox units)
- `mmToPx(mm) = (mm / 25.4) * dpi()` (stroke width: constant screen-px via non-scaling-stroke)
- `ZOOM_MAX/MIN = 50/0.2` (pan/zoom bounds)

---

## Architecture: PlanRenderer (WebGL2 + Fallback SVG)

### Module Structure

```
src/plan/
├── PlanRenderer.ts          (Main GPU/CPU dispatcher)
├── glPlan/
│   ├── glPlanCompile.ts     (Tessellation & buffer upload)
│   ├── glPlanShaders.ts     (Vertex/fragment sources + compilation)
│   ├── glPlanRender.ts      (Draw loop: matrix uniform, state mgmt)
│   └── glPlanTypes.ts       (TypeScript interfaces for GPU data)
└── PlanView.tsx             (React wrapper, unchanged API)
```

### High-Level Flow

```
PlanView.tsx
  ↓ [receives plan: Plan]
  ↓
PlanRenderer (new abstraction)
  ↓
  ├─→ GPU path [if WebGL2 available && flag=true]
  │    ├─ glPlanCompile() → upload tessellated geometry to VRAM
  │    ├─ glPlanRender() → draw with pan/zoom matrix uniform
  │    └─ [fast pan/zoom via matrix only]
  │
  └─→ Fallback: SVG [if WebGL fails || flag=false]
       └─ existing PlanView render path (toScreen + DOM)
```

---

## GPU Path: Tessellation & Shaders

### 1. Tessellation Strategy

#### **Polygon** → Fans + Ear Clipping
- **Input**: Primitive.polygon = { pts: Vec2[], fill, stroke, strokeWidthMm, hatch }
- **Output**: Indexed triangle mesh
- **Algorithm**: Earcut2D (existing JS library logic, inlined to avoid npm)
  - Convert polygon pts to 2D float32 array in **world space** (meters)
  - Earcut → triangle indices
  - Store: `{ vertices: Float32Array, indices: Uint32Array, color: vec4, hasHatch: bool }`
  - **Hatch rendering**: Bake hatch as texture or re-implement in fragment shader (MVP: solid fill only; hatch deferred)

#### **Line** → Quad Expansion (Screen-Space Width)
- **Input**: Primitive.line = { a, b, weightMm, cls, dash?, color }
- **Output**: Degenerate quad (2 triangles) with screen-space normal offset
- **Strategy**:
  1. Vertex shader receives `{ pos: vec2, side: float }` (side = ±1 for left/right edge)
  2. Transform pos to clip space via matrix uniform
  3. Compute screen-space perpendicular via `dFdx/dFdy` or pre-compute normal in CPU
  4. Expand by `(weightMm / 25.4) * dpi * (screenPixelsPerClipUnit)` in clip space
  5. Fragment shader: solid color (no dash MVP; dashing deferred or CPU pre-tessellation)

#### **Arc** → Line Segments (Polyline → Quads)
- **Input**: Primitive.arc = { center, from, to, r, weightMm, cls, dash }
- **Output**: Tessellate arc to ~30 line segments (adaptive based on radius/zoom), expand each as quad
- Fallback: SVG arc for MVP

#### **Text, Grips, Snap-Markers, Tool-Preview**
- **Stays in SVG overlay** (thin, non-bottleneck)
- Render above WebGL canvas at z-order 1

---

### 2. Shader Sources (GLSL 3.00 ES)

#### **Vertex Shader: Solid Fill (polygon)**
```glsl
#version 300 es
precision highp float;

uniform mat4 viewProjection; // pan/zoom as 2×3 affine (expand to mat4)

layout(location=0) in vec2 position;      // world-space (meters)
layout(location=1) in vec4 color;         // fill color

out VS_OUT {
  flat vec4 vertexColor;
} vs_out;

void main() {
  vec4 clipPos = viewProjection * vec4(position, 0.0, 1.0);
  gl_Position = clipPos;
  vs_out.vertexColor = color;
}
```

#### **Vertex Shader: Screen-Space Stroked Line**
```glsl
#version 300 es
precision highp float;

uniform mat4 viewProjection;    // world → clip space
uniform vec2 screenSize;        // canvas (width, height) in pixels
uniform float strokeWidthMm;    // millimeters
uniform float dpi;              // 96 * devicePixelRatio

layout(location=0) in vec2 position;      // world-space endpoint
layout(location=1) in float sideFlag;     // ±1.0 (left/right edge)
layout(location=2) in vec4 lineColor;     // stroke color

out VS_OUT {
  flat vec4 vertexColor;
} vs_out;

void main() {
  vec4 clipPos = viewProjection * vec4(position, 0.0, 1.0);
  
  // Convert stroke width (mm) → screen pixels
  float strokePx = (strokeWidthMm / 25.4) * dpi;
  // Convert screen pixels → normalized device coords (NDC)
  // NDC ∈ [-1,1]²; screen (0,screenSize) → NDC [-1,1]
  float strokeNdc = (strokePx / screenSize.x) * 2.0;
  
  // Expand in clip space (simple; assumes aspect ≈ 1)
  vec4 expanded = clipPos + vec4(sideFlag * strokeNdc, 0.0, 0.0, 0.0);
  
  gl_Position = expanded;
  vs_out.vertexColor = lineColor;
}
```

#### **Fragment Shader (both)**
```glsl
#version 300 es
precision highp float;

in VS_OUT {
  flat vec4 vertexColor;
} fs_in;

out vec4 fragColor;

void main() {
  fragColor = fs_in.vertexColor;
}
```

---

### 3. GPU Data Structures (TypeScript)

**glPlanTypes.ts:**
```typescript
export interface GLGeometryBatch {
  /** Vertex buffer: interleaved (x, y, [z if 3D], ...) in world space. */
  vertexBuffer: WebGLBuffer;
  vertexCount: number;

  /** Index buffer (triangles for fill, degenerate quads for strokes). */
  indexBuffer: WebGLBuffer;
  indexCount: number;

  /** Vertex Array Object (VAO) binds VBO + IBO. */
  vao: WebGLVertexArrayObject;

  /** Per-batch metadata. */
  batches: Array<{
    kind: "polygon" | "line" | "arc";
    indexStart: number;
    indexCount: number;
    color: [r: number, g: number, b: number, a: number]; // RGBA [0,1]
    hasHatch: boolean;
    hatchPattern?: "solid" | "insulation" | "diagonal" | "crosshatch";
    strokeWidthMm?: number;
  }>;
}

export interface GLPlanRenderState {
  // Pan/zoom transform: world (meters) → clip space
  viewMatrix: Matrix3 | Matrix4;  // 2×3 affine
  projMatrix: Matrix4;            // orthographic

  // Viewport size & DPI for screen-space stroke width
  screenWidth: number;
  screenHeight: number;
  dpi: number;

  // Compiled shaders
  solidFillProgram: WebGLProgram;
  strokeProgram: WebGLProgram;

  // Geometry cache (tessellated once per plan)
  geometryBatch: GLGeometryBatch | null;
}
```

---

## MVP API: PlanRenderer Class

### Interface

```typescript
export class PlanRenderer {
  /**
   * Create renderer with WebGL2 context + fallback config.
   */
  constructor(
    canvas: HTMLCanvasElement,
    options?: {
      enableGpu?: boolean;      // default: true
      enableGpuFallback?: boolean; // SVG fallback if GL fails
    }
  );

  /**
   * Compile and cache geometry from primitives.
   * Call once per plan change.
   */
  compilePlan(plan: Plan): Promise<void>;

  /**
   * Set pan/zoom transform matrix.
   * Call on every view change (pan, zoom, fit).
   */
  setViewMatrix(viewBox: { x, y, w, h }, canvasSize: { w, h }): void;

  /**
   * Render one frame: clear, draw batches, composite.
   * Called from requestAnimationFrame loop.
   */
  render(): void;

  /**
   * Release WebGL resources.
   */
  dispose(): void;

  /**
   * Query GPU availability / fallback state.
   */
  isGpuReady(): boolean;
  isFallbackActive(): boolean;
}
```

### Usage in PlanView

**Before** (SVG only):
```tsx
function PlanView({ plan, ... }) {
  return (
    <svg ref={svgRef}>
      <defs>{hatches}</defs>
      {plan.primitives.map((p, i) => <PrimitiveShape ... />)}
    </svg>
  );
}
```

**After** (GPU + SVG fallback):
```tsx
function PlanView({ plan, ... }) {
  const rendererRef = useRef<PlanRenderer | null>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    rendererRef.current = new PlanRenderer(canvas, { enableGpu: true });
    rendererRef.current.compilePlan(plan);
  }, [plan]);

  useEffect(() => {
    rendererRef.current?.setViewMatrix(view, { w: canvasWidth, h: canvasHeight });
  }, [view, canvasWidth, canvasHeight]);

  useEffect(() => {
    const frame = () => {
      rendererRef.current?.render();
      rafId = requestAnimationFrame(frame);
    };
    rafId = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(rafId);
  }, []);

  return (
    <div style={{ position: "relative" }}>
      {/* GPU canvas (or SVG fallback if GL unavailable) */}
      <canvas ref={canvasRef} style={{ position: "absolute" }} />
      {/* Thin SVG overlay: text, grips, snap-markers, tool preview */}
      <svg ref={svgRef} style={{ position: "absolute", zIndex: 1 }}>
        {/* text, grips, snaps only; geometry stays in WebGL */}
      </svg>
    </div>
  );
}
```

---

## Data Flow: From Primitives → GPU

### 1. **Compile Phase** (glPlanCompile.ts)

```typescript
export function compilePlan(gl: WebGL2RenderingContext, plan: Plan): GLGeometryBatch {
  const batches: BatchInfo[] = [];
  const vertices: number[] = [];
  const indices: number[] = [];
  let indexOffset = 0;

  for (const prim of plan.primitives) {
    if (prim.kind === "polygon") {
      const { verts, inds } = tessellatePolygon(prim.pts);
      const color = parseColor(prim.fill);
      batches.push({
        kind: "polygon",
        indexStart: indexOffset,
        indexCount: inds.length,
        color,
        hasHatch: prim.hatch.pattern !== "none",
        hatchPattern: prim.hatch.pattern,
      });
      vertices.push(...verts);
      indices.push(...inds.map((i) => i + indexOffset));
      indexOffset += verts.length / 2;
    } else if (prim.kind === "line") {
      const { verts, inds } = tessellateLineQuad(prim.a, prim.b);
      const color = parseColor(prim.color || "black");
      batches.push({
        kind: "line",
        indexStart: indexOffset,
        indexCount: inds.length,
        color,
        strokeWidthMm: prim.weightMm,
      });
      vertices.push(...verts);
      indices.push(...inds.map((i) => i + indexOffset));
      indexOffset += verts.length / 2;
    }
    // arc → polyline → quads (deferred for MVP)
  }

  const vbo = gl.createBuffer()!;
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);

  const ibo = gl.createBuffer()!;
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(indices), gl.STATIC_DRAW);

  const vao = gl.createVertexArray()!;
  gl.bindVertexArray(vao);
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 8, 0); // position
  gl.enableVertexAttribArray(0);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);

  return { vertexBuffer: vbo, indexBuffer: ibo, vao, batches, vertexCount: vertices.length, indexCount: indices.length };
}
```

### 2. **Render Phase** (glPlanRender.ts)

```typescript
export function renderPlan(
  gl: WebGL2RenderingContext,
  state: GLPlanRenderState,
  batch: GLGeometryBatch
): void {
  gl.clearColor(1, 1, 1, 1); // white background
  gl.clear(gl.COLOR_BUFFER_BIT);

  gl.useProgram(state.solidFillProgram);
  const mvpLoc = gl.getUniformLocation(state.solidFillProgram, "viewProjection");
  const mvp = mat4.multiply(state.projMatrix, state.viewMatrix);
  gl.uniformMatrix4fv(mvpLoc, false, mvp);

  gl.bindVertexArray(batch.vao);

  for (const b of batch.batches) {
    const colorLoc = gl.getUniformLocation(state.solidFillProgram, "vertexColor");
    gl.uniform4f(colorLoc, b.color[0], b.color[1], b.color[2], b.color[3]);

    gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.indexStart * 4);
  }
}
```

---

## Tessellation Details

### Earcut (Polygon Triangulation)

**Inlined earcut logic (no npm):**
```typescript
function tessellatePolygon(pts: Vec2[]): { verts: number[]; inds: number[] } {
  // Convert Vec2[] → flat float array
  const coords = pts.flatMap((p) => [p.x, p.y]);

  // Earcut2D: robust polygon triangulation
  // → Returns index array (triplets = triangles)
  const triangles = earcut(coords);

  // Vertex buffer: just positions (x, y) in world space (meters)
  const verts = coords;

  return { verts, inds: triangles };
}

// Simplified earcut (full version ~200 LOC; reference libtess2 or earcut.js)
function earcut(data: number[], hole?: number[], dim?: number): number[] {
  // ... iterative ear clipping, complexity O(n²) worst-case
  // Returns Uint32Array of triangle indices
}
```

### Line Quad Expansion

```typescript
function tessellateLineQuad(
  a: Vec2, b: Vec2,
  widthMm: number = 0.5
): { verts: number[]; inds: number[] } {
  // World-space endpoints; width (mm) will be expanded in vertex shader

  // Create a degenerate quad: 2 triangles
  // Vertices: [a_left, a_right, b_left, b_right]
  // (normal expansion happens in VS)

  const verts = [
    a.x, a.y, 0.0, // vertex 0: a, left flag
    a.x, a.y, 1.0, // vertex 1: a, right flag
    b.x, b.y, 0.0, // vertex 2: b, left flag
    b.x, b.y, 1.0, // vertex 3: b, right flag
  ];

  // Two triangles: (0, 1, 2) and (1, 3, 2)
  const inds = [0, 1, 2, 1, 3, 2];

  return { verts, inds };
}
```

---

## Pan/Zoom Matrix Transform

### View Box → Clip Space

```typescript
function buildViewMatrix(
  viewBox: { x, y, w, h },
  canvasSize: { w, h }
): Matrix4 {
  // 1. World space (meters, origin at model 0,0) → viewBox units (PX_PER_M=90)
  const scale = PX_PER_M; // 1 meter → 90 viewBox units

  // 2. ViewBox viewport: x,y,w,h in viewBox units → NDC [-1,+1]²
  // Orthographic projection (no perspective).
  const ortho = mat4.ortho(
    viewBox.x,
    viewBox.x + viewBox.w,
    viewBox.y,
    viewBox.y + viewBox.h,
    -1, 1
  );

  // 3. Scale from viewBox units → world (invert PX_PER_M)
  const scaleMatrix = mat4.scale(mat4.identity(), [1/scale, 1/scale, 1]);

  return mat4.multiply(ortho, scaleMatrix);
}
```

Whenever PlanView calls `setView(viewBox)` or `onWheel()` → call `setViewMatrix()` → GPU re-renders with new matrix uniform (no tessellation).

---

## Fallback Strategy: SVG Renderer Flag

**Global flag** in PlanView or app state:
```typescript
const [useGpuRenderer, setUseGpuRenderer] = useState(true);
```

**Render path branching:**
```typescript
return useGpuRenderer && rendererRef.current?.isGpuReady()
  ? <canvas ref={canvasRef} />
  : <svg ref={svgRef}>{/* existing SVG rendering */}</svg>;
```

**When GL fails** (e.g., no WebGL2 support, Out-Of-Memory):
1. Renderer catches error in `compilePlan()`
2. Sets internal `fallbackActive = true`
3. Returns gracefully (app renders SVG path instead)
4. User sees same plan, slower but functional

---

## Implementation Order (MVP → Iteration)

### Phase 1: Core (Week 1)
1. **glPlanTypes.ts** — TypeScript interfaces for GPU state
2. **glPlanShaders.ts** — Compile vertex/fragment shaders, handle GL errors
3. **glPlanCompile.ts** — Tessellation (earcut inlined), buffer upload
4. **glPlanRender.ts** — Draw loop, matrix uniform, clear/present
5. **PlanRenderer.ts** — Main class, dispatcher (GPU vs SVG fallback)
6. **PlanView.tsx** — Wire renderer, canvas overlay, canvas lifecycle

### Phase 2: Hatches & Lines (Week 2)
- Improve line tessellation: proper screen-space width (dFdx/dFdy or pre-computed normals)
- Hatch patterns: texture-based or procedural fragment shader (diagonal/insulation)
- Arc tessellation: polyline → quads

### Phase 3: Polish (Week 3)
- Stroke dashing via geometry or fragment shader
- Greyed opacity blending
- Hit testing integration (point-in-triangle for GPU)
- Performance profiling, batch merging

---

## Performance Targets

| Operation | SVG (Current) | GPU (Target) | Notes |
|-----------|---------------|--------------|-------|
| **Tessellation** | — | 10–50 ms | Once per plan |
| **Pan/Zoom 60 Hz** | 16 ms (re-render SVG) | <1 ms (matrix uniform) | Matrix upload negligible |
| **Pan/Zoom 144 Hz** | 7 ms (bottleneck) | <0.5 ms | 28× speedup expected |
| **Geometry: 1000 polygons** | 50–100 ms SVG render | 1–5 ms GPU draw | CPU tessellation pipelined |

User: AMD RX 7800 XT → easily capable of 4K+ geometry at 144 Hz.

---

## Known Deferred Items (Post-MVP)

- **Hatches**: Solid fill only MVP; insulation/diagonal/crosshatch in Phase 2 via texture or procedural shader
- **Dashing**: Not in MVP (complex with screen-space strokes); either CPU pre-tessellation or fragment shader alpha-discard
- **Arcs**: Fallback to SVG for MVP; GPU polyline expansion in Phase 2
- **Text, Grips, Snaps**: Stay in SVG overlay indefinitely (no GPU benefit; text rendering nontrivial)
- **Hit Testing**: Keep in CPU/SVG for MVP; GPU pick-buffer deferred
- **Color/Opacity Blending**: Basic for MVP; advanced (multiply, screen, dodge) deferred

---

## References

- **Earcut.js**: https://github.com/mapbox/earcut — polygon triangulation (logic to inline)
- **three.js line expansion**: https://github.com/mrdoob/three.js/blob/master/src/renderers/webgl/WebGLGeometries.js
- **OpenGL Perspective Division**: https://en.wikibooks.org/wiki/OpenGL_Programming/Modern_OpenGL_Tutorial_Polygon_offset
- **Screen-Space Stroke Width**: https://forum.libcinder.org/topic/smooth-line-rendering-using-geometry-shaders
- PlanView source: `/home/karim/cad/src/plan/PlanView.tsx` (2500 LOC)
- Primitive types: `/home/karim/cad/src/plan/generatePlan.ts:133`
