# 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; /** * 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 ( {hatches} {plan.primitives.map((p, i) => )} ); } ``` **After** (GPU + SVG fallback): ```tsx function PlanView({ plan, ... }) { const rendererRef = useRef(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 (
{/* GPU canvas (or SVG fallback if GL unavailable) */} {/* Thin SVG overlay: text, grips, snap-markers, tool preview */} {/* text, grips, snaps only; geometry stays in WebGL */}
); } ``` --- ## 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() ? : {/* existing SVG rendering */}; ``` **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`