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).
This commit is contained in:
@@ -0,0 +1,581 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user