Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung (konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text- Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback, falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform) fuer fluessige Interaktion ohne React-Re-Render je Frame. Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM (Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext- Import, Tauri-Compute-Boundary-PoC).
18 KiB
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)
Primitivetypes (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
- polygon:
- 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:
- Vertex shader receives
{ pos: vec2, side: float }(side = ±1 for left/right edge) - Transform pos to clip space via matrix uniform
- Compute screen-space perpendicular via
dFdx/dFdyor pre-compute normal in CPU - Expand by
(weightMm / 25.4) * dpi * (screenPixelsPerClipUnit)in clip space - Fragment shader: solid color (no dash MVP; dashing deferred or CPU pre-tessellation)
- Vertex shader receives
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)
#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
#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)
#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:
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
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):
function PlanView({ plan, ... }) {
return (
<svg ref={svgRef}>
<defs>{hatches}</defs>
{plan.primitives.map((p, i) => <PrimitiveShape ... />)}
</svg>
);
}
After (GPU + SVG fallback):
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)
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)
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):
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
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
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:
const [useGpuRenderer, setUseGpuRenderer] = useState(true);
Render path branching:
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):
- Renderer catches error in
compilePlan() - Sets internal
fallbackActive = true - Returns gracefully (app renders SVG path instead)
- User sees same plan, slower but functional
Implementation Order (MVP → Iteration)
Phase 1: Core (Week 1)
- glPlanTypes.ts — TypeScript interfaces for GPU state
- glPlanShaders.ts — Compile vertex/fragment shaders, handle GL errors
- glPlanCompile.ts — Tessellation (earcut inlined), buffer upload
- glPlanRender.ts — Draw loop, matrix uniform, clear/present
- PlanRenderer.ts — Main class, dispatcher (GPU vs SVG fallback)
- 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