Files
DOSSIER-STANDALONE/docs/design/webgl2-plan-renderer.md
karim 3d2d4d6321 2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
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).
2026-07-02 00:12:39 +02:00

18 KiB
Raw Permalink Blame History

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)

#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):

  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 1050 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 50100 ms SVG render 15 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