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:
+33
-11
@@ -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-
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user