render2d im Browser: WASM/WebGPU-Viewport hinter ?engine=wasm

Integrations-Spike: die native 2D-Engine (src-tauri/render2d) laeuft jetzt als
WASM/WebGPU-Canvas in der App-UI, mit derselben Szene (planToRenderScene) und
ViewBox wie der WebGL2-Pfad; das SVG-Overlay (Text/Griffe/Auswahl) bleibt drueber.

- render2d: neues Feature "web" (wasm-bindgen/web-sys), Fassade WebPlanRenderer
  (new/set_scene/set_view_box/set_paper_scale/resize/render) auf Canvas-Surface.
  Geteilte GPU-Schicht (gpu.rs), native Fensterschicht (winit) unberuehrt.
- Font: cosmic-text findet auf wasm keine Systemfonts -> Inter-Bytes eingebettet
  (assets/Inter.ttf), Renderer.load_font + text_family aus der geladenen Familie.
- Frontend: useWasmPlanRenderer (gleiche Schnittstelle wie useGlPlanRenderer);
  PlanView waehlt die Engine per ?engine=wasm + navigator.gpu, sonst WebGL2/SVG.
- Build: npm-Script build:engine (wasm-pack, target web -> src/engine/pkg,
  gitignored). chromium-shell.sh mit WebGPU-Flags (--enable-unsafe-webgpu/Vulkan).
- Kompat-Shim: wgpu 22 sendet das spec-entfernte Limit maxInterStageShaderComponents,
  das neuere Chromium ablehnen -> im Hook vor requestDevice entfernt.

Verifiziert: cargo test -p render2d gruen (18), tsc gruen, wasm-Build gruen;
Headless-Chromium (?engine=wasm) zeichnet den Grundriss (Waende/Schraffur/
Tuerschwenk/Treppe/Raumtext) im WASM-Viewport, Fallback ohne Flag unveraendert.
This commit is contained in:
2026-07-02 22:30:47 +02:00
parent 6eead6d493
commit 665cbce1f9
12 changed files with 607 additions and 19 deletions
+4
View File
@@ -0,0 +1,4 @@
# wasm-pack-Ausgabe des render2d-Browser-Renderers (Feature "web"). Wird per
# `npm run build:engine` erzeugt und NICHT eingecheckt (Build-Artefakt, groß, an
# Toolchain gebunden). Der Quellcode liegt in src-tauri/render2d/src/web.rs.
pkg/
+33 -11
View File
@@ -18,6 +18,7 @@ import { docToLines, type SvgLine } from "../text/renderHtml";
import type { EdgeGrip, Vec2 } from "../model/types";
import type { DraftShape, SnapResult, ToolHandlers, ToolId, ToolMods } from "../tools/types";
import { useGlPlanRenderer } from "./useGlPlanRenderer";
import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum)
const PAD = 60; // Rand in viewBox-Einheiten
@@ -410,19 +411,40 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const svgRef = useRef<SVGSVGElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// WebGL2-GPU-Renderer ist der STANDARD-Pfad. Der SVG-Renderer bleibt als
// automatischer Fallback (nur falls WebGL2/Shader nicht verfügbar) — es gibt
// KEINE User-Umschaltung mehr. `?gl=0` erzwingt zu Debug-Zwecken reines SVG.
const wantGpu =
// Engine-Auswahl. STANDARD-Pfad: WebGL2 (useGlPlanRenderer); der SVG-Renderer
// bleibt automatischer Fallback (nur falls WebGL2/Shader nicht verfügbar), `?gl=0`
// erzwingt reines SVG. INTEGRATIONS-SPIKE: Mit `?engine=wasm` UND vorhandenem
// WebGPU (navigator.gpu) übernimmt statt WebGL2 der native render2d-Renderer als
// WASM/WebGPU-Viewport (useWasmPlanRenderer) — dieselbe Szene, dieselbe ViewBox,
// dasselbe SVG-Overlay (Text/Griffe/Auswahl) darüber. Fehlt WebGPU trotz Flag:
// stiller Fallback auf WebGL2 (+ Warnung).
const engineParam =
typeof window === "undefined"
? null
: new URLSearchParams(window.location.search).get("engine");
const hasWebGpu = typeof navigator !== "undefined" && "gpu" in navigator;
const wantWasm = engineParam === "wasm" && hasWebGpu;
useEffect(() => {
if (engineParam === "wasm" && !hasWebGpu) {
console.warn("engine=wasm angefragt, aber navigator.gpu fehlt → WebGL2-Fallback");
}
}, [engineParam, hasWebGpu]);
// WebGL2 nur, wenn NICHT der WASM-Pfad aktiv ist (und `?gl!=0`).
const wantGl =
!wantWasm &&
(typeof window === "undefined"
? true
: new URLSearchParams(window.location.search).get("gl") !== "0";
const { render: renderGl, updateGeometry, ready: glReady } = useGlPlanRenderer(
canvasRef,
wantGpu,
);
// GL WIRKLICH aktiv? Steuert das Umschalten SVG↔GL: bei false rendert der volle
// SVG-Pfad (Fallback), bei true übernimmt die GL-Ebene + Text-Overlay.
: new URLSearchParams(window.location.search).get("gl") !== "0");
// Beide Hooks unbedingt aufrufen (Hook-Regeln); je nach Flag ist genau einer aktiv.
const glHook = useGlPlanRenderer(canvasRef, wantGl);
const wasmHook = useWasmPlanRenderer(canvasRef, wantWasm);
const activeEngine = wantWasm ? wasmHook : glHook;
const { render: renderGl, updateGeometry, ready: glReady } = activeEngine;
// „GPU-Canvas montieren?" — beide GPU-Pfade brauchen das Canvas (an der ABSICHT
// montiert, nicht am ready, sonst könnte die Engine mangels Canvas nie starten).
const wantGpu = wantGl || wantWasm;
// Engine WIRKLICH aktiv? Steuert das Umschalten SVG↔GPU: bei false rendert der
// volle SVG-Pfad (Fallback), bei true übernimmt die GPU-Ebene + Text-Overlay.
const useGpuRenderer = wantGpu && glReady;
// Stabiler Papier-Massstab-Nenner N (1:N) als REFERENZ für die Print-Strich-
+179
View File
@@ -0,0 +1,179 @@
/**
* React-Hook für den nativen render2d-Renderer als WASM/WebGPU-Viewport.
*
* Spiegelt die Schnittstelle von {@link useGlPlanRenderer} ({ render,
* updateGeometry, ready }), damit PlanView beide Engines ohne weitere Verzweigung
* am selben Canvas + über dieselben Effekte treiben kann. Die eigentliche
* Zeichenlogik lebt in Rust (src-tauri/render2d, Feature `web`); hier wird nur das
* WASM-Modul geladen, der Renderer an das Canvas gebunden und die Szene/ViewBox
* durchgereicht.
*
* Fällt bei fehlendem WebGPU oder Init-Fehler still auf `ready:false` zurück; der
* Aufrufer (PlanView) behält dann den WebGL2-/SVG-Pfad.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { Plan } from "./generatePlan";
import { planToRenderScene } from "./toRenderScene";
interface ViewBox {
x: number;
y: number;
w: number;
h: number;
}
/**
* Kompatibilitäts-Shim: wgpu 22 sendet in `requestDevice` noch das Limit
* `maxInterStageShaderComponents`, das AUS DER WebGPU-Spec ENTFERNT wurde. Neuere
* Chromium-Builds lehnen ein nicht-`undefined`-Wert dafür mit `OperationError`
* ab (→ Device-Erzeugung schlägt fehl). Wir entfernen den Schlüssel EINMAL global
* aus dem Descriptor, bevor wgpu ihn stellt. Entfällt, sobald render2d auf ein
* wgpu (+ glyphon) hochgezogen wird, das dieses Limit nicht mehr mitschickt.
*/
let requestDevicePatched = false;
function patchRequestDeviceLimits(): void {
if (requestDevicePatched) return;
const AdapterCtor = (globalThis as any).GPUAdapter;
if (!AdapterCtor?.prototype?.requestDevice) return;
const orig = AdapterCtor.prototype.requestDevice;
AdapterCtor.prototype.requestDevice = function (
this: unknown,
desc?: any,
) {
if (desc?.requiredLimits && "maxInterStageShaderComponents" in desc.requiredLimits) {
const requiredLimits = { ...desc.requiredLimits };
delete requiredLimits.maxInterStageShaderComponents;
desc = { ...desc, requiredLimits };
}
return orig.call(this, desc);
};
requestDevicePatched = true;
}
/** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine`). */
interface WebPlanRendererLike {
set_scene(json: string): void;
set_view_box(x: number, y: number, w: number, h: number): void;
set_paper_scale(n: number): void;
resize(w: number, h: number): void;
render(): void;
}
/**
* WASM-Modul EINMAL laden + initialisieren (memoisiert). Wichtig: `mod.default()`
* darf nur einmal laufen — ein zweiter Init (z.B. durch React-StrictMode-Doppel-
* mount) verwirft die Closures des ersten und wirft „closure invoked recursively
* or after being dropped".
*/
let enginePromise: Promise<any> | null = null;
function loadEngine(): Promise<any> {
if (!enginePromise) {
enginePromise = (async () => {
const mod: any = await import("../engine/pkg/render2d.js");
await mod.default(); // lädt render2d_bg.wasm
return mod;
})();
}
return enginePromise;
}
/**
* Renderer je Canvas EINMAL erzeugen (memoisiert). Ein Canvas kann nur EINEN
* WebGPU-Kontext tragen; ein zweiter `WebPlanRenderer.new` auf demselben Canvas
* (StrictMode-Remount) würde kollidieren. Die Promise wird gecacht, damit der
* Doppelmount denselben Renderer teilt.
*/
const rendererCache = new WeakMap<HTMLCanvasElement, Promise<WebPlanRendererLike>>();
function getRenderer(canvas: HTMLCanvasElement): Promise<WebPlanRendererLike> {
let p = rendererCache.get(canvas);
if (!p) {
p = (async () => {
patchRequestDeviceLimits(); // wgpu-22-Limit-Shim (siehe oben)
const mod = await loadEngine();
// Canvas VOR dem Surface-Erzeugen auf die Zielpixel (DPR) bringen.
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
return (await mod.WebPlanRenderer.new(canvas, undefined)) as WebPlanRendererLike;
})();
rendererCache.set(canvas, p);
// Bei Fehlschlag den Cache leeren, damit ein späterer Versuch neu startet.
p.catch(() => rendererCache.delete(canvas));
}
return p;
}
export function useWasmPlanRenderer(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
enabled: boolean,
) {
const rendererRef = useRef<WebPlanRendererLike | null>(null);
const [ready, setReady] = useState(false);
// WASM-Renderer an das Canvas binden (memoisiert, StrictMode-fest).
useEffect(() => {
if (!enabled || !canvasRef.current) {
setReady(false);
return;
}
let disposed = false;
const canvas = canvasRef.current;
void (async () => {
try {
const renderer = await getRenderer(canvas);
if (disposed) return;
rendererRef.current = renderer;
setReady(true);
} catch (e) {
console.warn("render2d WASM/WebGPU-Init fehlgeschlagen → Fallback", e);
rendererRef.current = null;
setReady(false);
}
})();
return () => {
disposed = true;
setReady(false);
};
}, [enabled, canvasRef]);
/** Szene beim Plan-Wechsel neu setzen (re-tesselliert in Rust, gecacht). */
const updateGeometry = useCallback((plan: Plan) => {
const r = rendererRef.current;
if (!r || !plan) return;
try {
r.set_scene(JSON.stringify(planToRenderScene(plan)));
} catch (e) {
console.warn("render2d set_scene fehlgeschlagen:", e);
}
}, []);
/** Einen Frame zeichnen (Canvas-Größe + dpr hier abgleichen). */
const render = useCallback(
(viewBox: ViewBox, paperScaleN = 100) => {
const r = rendererRef.current;
const canvas = canvasRef.current;
if (!r || !canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.round(rect.width * dpr));
const h = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
try {
r.resize(w, h);
r.set_paper_scale(paperScaleN);
r.set_view_box(viewBox.x, viewBox.y, viewBox.w, viewBox.h);
r.render();
} catch (e) {
console.warn("render2d render fehlgeschlagen:", e);
}
},
[canvasRef],
);
return { render, updateGeometry, ready };
}