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,261 @@
|
||||
/**
|
||||
* WebGL2-Render-Loop des GPU-Grundrisses.
|
||||
*
|
||||
* Zeichnet zwischengespeicherte GPU-Geometrie (aus glPlanCompile) und aktualisiert
|
||||
* beim Pan/Zoom NUR die Projektionsmatrix (kein Re-Tessellieren). Getrieben vom
|
||||
* Aufrufer (PlanView-Pan/Zoom-Handler), nicht per requestAnimationFrame.
|
||||
*/
|
||||
|
||||
import type { GpuGeometry, ShaderProgram, ViewBox } from './glPlanTypes';
|
||||
|
||||
/** viewBox-Einheiten je Meter (identisch zu PlanView/toScreen). */
|
||||
const PX_PER_M = 90;
|
||||
|
||||
/** 4×4-Einheitsmatrix (Spalten-Major, WebGL-Konvention). */
|
||||
function mat4Identity(): Float32Array {
|
||||
const m = new Float32Array(16);
|
||||
m[0] = 1;
|
||||
m[5] = 1;
|
||||
m[10] = 1;
|
||||
m[15] = 1;
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orthografische Projektion [left,right]×[bottom,top] → Clip [-1,1].
|
||||
* Spalten-Major.
|
||||
*/
|
||||
function mat4Ortho(
|
||||
left: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
top: number,
|
||||
): Float32Array {
|
||||
const m = mat4Identity();
|
||||
const w = right - left;
|
||||
const h = top - bottom;
|
||||
m[0] = 2 / w;
|
||||
m[5] = 2 / h;
|
||||
m[10] = -1;
|
||||
m[12] = -(right + left) / w;
|
||||
m[13] = -(top + bottom) / h;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** viewBox-Gleichheit (Cache-Invalidierung). */
|
||||
function viewBoxEqual(a: ViewBox, b: ViewBox): boolean {
|
||||
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projektionsmatrix für einen viewBox, aspekt-korrigiert wie SVG
|
||||
* `preserveAspectRatio="xMidYMid meet"`: der viewBox wird auf das
|
||||
* Canvas-Seitenverhältnis GEDEHNT (zentriert), damit die Skalierung in X und Y
|
||||
* gleich ist (keine Verzerrung, Perpendikel bleiben senkrecht).
|
||||
*
|
||||
* Eingabe-Positionen sind bereits im BILDSCHIRM-Raum (toScreen), daher keine
|
||||
* zusätzliche Meter-Skalierung. Bildschirm-Y zeigt nach unten → top=vb.y,
|
||||
* bottom=vb.y+vb.h (kleineres Y oben → Clip +1).
|
||||
*/
|
||||
export function computeOrthoMatrix(
|
||||
viewBox: ViewBox,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
): Float32Array {
|
||||
let { x, y, w, h } = viewBox;
|
||||
if (canvasW > 0 && canvasH > 0 && w > 0 && h > 0) {
|
||||
const cAspect = canvasW / canvasH;
|
||||
const vAspect = w / h;
|
||||
if (cAspect > vAspect) {
|
||||
// Canvas breiter → viewBox in der Breite dehnen, X zentrieren.
|
||||
const nw = h * cAspect;
|
||||
x -= (nw - w) / 2;
|
||||
w = nw;
|
||||
} else {
|
||||
// Canvas höher → viewBox in der Höhe dehnen, Y zentrieren.
|
||||
const nh = w / cAspect;
|
||||
y -= (nh - h) / 2;
|
||||
h = nh;
|
||||
}
|
||||
}
|
||||
// top = y (Bildschirm-Y oben, kleiner), bottom = y + h.
|
||||
return mat4Ortho(x, x + w, y + h, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* WebGL2-GPU-Renderer. Verwaltet Shader-Programme + Projektionsmatrix, zeichnet
|
||||
* Füll- und Linien-Batches effizient über Matrix-Uniforms.
|
||||
*/
|
||||
export class PlanGpuRenderer {
|
||||
private gl: WebGL2RenderingContext;
|
||||
private fillProgram: ShaderProgram;
|
||||
private lineProgram: ShaderProgram;
|
||||
private lastViewBox: ViewBox | null = null;
|
||||
private lastCanvas: { w: number; h: number } = { w: 0, h: 0 };
|
||||
private projMatrix: Float32Array;
|
||||
|
||||
constructor(
|
||||
gl: WebGL2RenderingContext,
|
||||
fillProg: ShaderProgram,
|
||||
lineProg: ShaderProgram,
|
||||
) {
|
||||
this.gl = gl;
|
||||
this.fillProgram = fillProg;
|
||||
this.lineProgram = lineProg;
|
||||
this.projMatrix = mat4Identity();
|
||||
|
||||
// Zeichenblatt-Hintergrund (hell, Default #f0f0f0 = --sheet), keine Tiefe;
|
||||
// Alpha-Blending für weiche Kanten/transluzente Räume.
|
||||
gl.clearColor(0.941, 0.941, 0.941, 1);
|
||||
gl.disable(gl.DEPTH_TEST);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
}
|
||||
|
||||
/** Löschfarbe (Zeichenblatt) setzen — theme-sicher aus --sheet. */
|
||||
setClearColor(r: number, g: number, b: number): void {
|
||||
this.gl.clearColor(r, g, b, 1);
|
||||
}
|
||||
|
||||
/** Projektionsmatrix nur bei viewBox-/Canvas-Änderung neu berechnen. */
|
||||
private updateProjection(viewBox: ViewBox, canvasW: number, canvasH: number): void {
|
||||
if (
|
||||
!this.lastViewBox ||
|
||||
!viewBoxEqual(viewBox, this.lastViewBox) ||
|
||||
this.lastCanvas.w !== canvasW ||
|
||||
this.lastCanvas.h !== canvasH
|
||||
) {
|
||||
this.projMatrix = computeOrthoMatrix(viewBox, canvasW, canvasH);
|
||||
this.lastViewBox = { ...viewBox };
|
||||
this.lastCanvas = { w: canvasW, h: canvasH };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichnet einen Frame.
|
||||
* @param geometry GPU-Geometrie (Fill + Line); null → nur löschen.
|
||||
* @param viewBox Aktueller Ausschnitt (Bildschirm-Einheiten).
|
||||
* @param viewport Zeichenpuffer-Größe in Geräte-Pixeln.
|
||||
* @param paperScaleN Papier-Massstab-Nenner (1:N) für echte mm-Strichbreiten.
|
||||
*/
|
||||
render(
|
||||
geometry: GpuGeometry | null,
|
||||
viewBox: ViewBox,
|
||||
viewport: { width: number; height: number },
|
||||
paperScaleN = 100,
|
||||
): void {
|
||||
const gl = this.gl;
|
||||
gl.viewport(0, 0, viewport.width, viewport.height);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
if (!geometry) return;
|
||||
|
||||
this.updateProjection(viewBox, viewport.width, viewport.height);
|
||||
|
||||
// Umrechnung Papier-mm → Geräte-px, EXAKT wie der SVG-printStrokeVb-Pfad:
|
||||
// Breite_vb = mm·N/1000·PX_PER_M (viewBox-Einheiten, skaliert mit Zoom)
|
||||
// Breite_px = Breite_vb · meetSkala (Geräte-px je viewBox-Einheit)
|
||||
// → mm·(N/1000·PX_PER_M·meet). meet nutzt die Geräte-px-Viewport-Größe, enthält
|
||||
// damit dpr. So gilt: 0.35 mm bei 1:100 = echte 0.35 mm Papier (== SVG).
|
||||
const meet = Math.min(viewport.width / viewBox.w, viewport.height / viewBox.h);
|
||||
const mmToDevicePx = (paperScaleN / 1000) * PX_PER_M * meet;
|
||||
|
||||
this.drawFills(geometry);
|
||||
this.drawLines(geometry, viewport, mmToDevicePx);
|
||||
}
|
||||
|
||||
/** Gefüllte Polygone (Poché). */
|
||||
private drawFills(geometry: GpuGeometry): void {
|
||||
const gl = this.gl;
|
||||
const { positionBuffer, indexBuffer, batches } = geometry.fill;
|
||||
if (!positionBuffer || !indexBuffer || !batches.length) return;
|
||||
const prog = this.fillProgram;
|
||||
if (!prog?.program) return;
|
||||
|
||||
gl.useProgram(prog.program);
|
||||
if (prog.uniforms.viewProj) {
|
||||
gl.uniformMatrix4fv(prog.uniforms.viewProj, false, this.projMatrix);
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
const pos = prog.attribs.position;
|
||||
if (pos !== undefined && pos >= 0) {
|
||||
gl.enableVertexAttribArray(pos);
|
||||
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 8, 0);
|
||||
}
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
|
||||
|
||||
for (const b of batches) {
|
||||
if (prog.uniforms.color) gl.uniform4fv(prog.uniforms.color, b.color);
|
||||
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.startIndex * 4);
|
||||
}
|
||||
if (pos !== undefined && pos >= 0) gl.disableVertexAttribArray(pos);
|
||||
}
|
||||
|
||||
/** Striche mit echter Papier-mm-Breite (massstabs- + zoomrichtig). */
|
||||
private drawLines(
|
||||
geometry: GpuGeometry,
|
||||
viewport: { width: number; height: number },
|
||||
mmToDevicePx: number,
|
||||
): void {
|
||||
const gl = this.gl;
|
||||
const { vertexBuffer, indexBuffer, batches } = geometry.line;
|
||||
if (!vertexBuffer || !indexBuffer || !batches.length) return;
|
||||
const prog = this.lineProgram;
|
||||
if (!prog?.program) return;
|
||||
|
||||
gl.useProgram(prog.program);
|
||||
if (prog.uniforms.viewProj) {
|
||||
gl.uniformMatrix4fv(prog.uniforms.viewProj, false, this.projMatrix);
|
||||
}
|
||||
if (prog.uniforms.viewportPx) {
|
||||
gl.uniform2f(prog.uniforms.viewportPx, viewport.width, viewport.height);
|
||||
}
|
||||
// strokeScale = mm → Geräte-px; die Basis-Breite (strokePx) trägt die mm.
|
||||
if (prog.uniforms.strokeScale) {
|
||||
gl.uniform1f(prog.uniforms.strokeScale, mmToDevicePx);
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||
const stride = 6 * 4; // [x,y, nx,ny, side, miter]
|
||||
const pos = prog.attribs.position;
|
||||
const nrm = prog.attribs.normal;
|
||||
const side = prog.attribs.side;
|
||||
const miter = prog.attribs.miter;
|
||||
if (pos !== undefined && pos >= 0) {
|
||||
gl.enableVertexAttribArray(pos);
|
||||
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, stride, 0);
|
||||
}
|
||||
if (nrm !== undefined && nrm >= 0) {
|
||||
gl.enableVertexAttribArray(nrm);
|
||||
gl.vertexAttribPointer(nrm, 2, gl.FLOAT, false, stride, 2 * 4);
|
||||
}
|
||||
if (side !== undefined && side >= 0) {
|
||||
gl.enableVertexAttribArray(side);
|
||||
gl.vertexAttribPointer(side, 1, gl.FLOAT, false, stride, 4 * 4);
|
||||
}
|
||||
if (miter !== undefined && miter >= 0) {
|
||||
gl.enableVertexAttribArray(miter);
|
||||
gl.vertexAttribPointer(miter, 1, gl.FLOAT, false, stride, 5 * 4);
|
||||
}
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
|
||||
|
||||
// strokePx-Uniform trägt die ECHTE Papier-mm-Breite; der Shader multipliziert
|
||||
// mit strokeScale (= mm→Geräte-px) und klemmt bei ~0.6 px (Haarlinie sichtbar,
|
||||
// MSAA via antialias:true), damit sehr feine Gewichte beim Rauszoomen nicht
|
||||
// verschwinden.
|
||||
for (const b of batches) {
|
||||
if (prog.uniforms.color) gl.uniform4fv(prog.uniforms.color, b.color);
|
||||
if (prog.uniforms.strokePx) gl.uniform1f(prog.uniforms.strokePx, b.strokeMm);
|
||||
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.startIndex * 4);
|
||||
}
|
||||
if (pos !== undefined && pos >= 0) gl.disableVertexAttribArray(pos);
|
||||
if (nrm !== undefined && nrm >= 0) gl.disableVertexAttribArray(nrm);
|
||||
if (side !== undefined && side >= 0) gl.disableVertexAttribArray(side);
|
||||
if (miter !== undefined && miter >= 0) gl.disableVertexAttribArray(miter);
|
||||
}
|
||||
|
||||
/** Cache invalidieren (z. B. nach Größenänderung). */
|
||||
invalidateViewBox(): void {
|
||||
this.lastViewBox = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user