render3d im Browser: WASM/WebGPU-3D-Viewport hinter ?engine=wasm

Feature web (wasm-bindgen) + cdylib analog render2d; WebModelRenderer
mit Canvas-Surface, set_model (walls/slabs wie der native Push) und
set_camera. Projektion liefert bereits [0,1]-Clip-Z, math.rs unveraendert.
wgpu-22-requestDevice-Shim in src/engine/requestDeviceShim.ts geteilt.
Neuer Hook useWasm3dRenderer + Wasm3DViewport (Orbit/Pan/Zoom wie three.js-
Sicht); Viewport3D dispatcht per ?engine=wasm bzw. localStorage, three.js
bleibt Default. Build-Script build:engine3d (wasm-pack, src/engine/pkg3d).
Verifiziert headful per scripts/probe-engine3d.mjs (37 % Geometrie-Pixel);
headless praesentiert Chromium keine WebGPU-Frames (auch bei render2d).
This commit is contained in:
2026-07-03 07:41:37 +02:00
parent 87d12b976f
commit 0e484746de
13 changed files with 914 additions and 2 deletions
+4
View File
@@ -2,3 +2,7 @@
# `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/
# Dito für den render3d-Browser-Renderer (`npm run build:engine3d`); Quellcode
# in src-tauri/render3d/src/web.rs.
pkg3d/
+30
View File
@@ -0,0 +1,30 @@
/**
* Kompatibilitäts-Shim für die WASM-Engines (render2d/render3d, beide wgpu 22):
* wgpu 22 sendet in `requestDevice` noch das Limit `maxInterStageShaderComponents`,
* das AUS DER WebGPU-Spec ENTFERNT wurde. Neuere Chromium-Builds lehnen einen
* 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 die Engines auf ein wgpu (+ glyphon)
* hochgezogen werden, das dieses Limit nicht mehr mitschickt.
*/
let requestDevicePatched = false;
export 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;
}
+5
View File
@@ -777,6 +777,11 @@ export const de = {
"rt.color": "Farbe",
"rt.preset": "Vorlage",
"rt.preset.none": "— Vorlage —",
// ── 3D-Viewport (native Engine, WASM/WebGPU) ────────────────────────────────
"viewport3d.engineUnavailable": "3D-Engine nicht verfügbar",
"viewport3d.engineUnavailableHint":
"WebGPU konnte nicht initialisiert werden. In der Statusleiste auf WebGL2 umschalten, um die 3D-Ansicht weiter zu nutzen.",
} as const;
export type TranslationKey = keyof typeof de;
+5
View File
@@ -768,4 +768,9 @@ export const en: Record<TranslationKey, string> = {
"rt.color": "Color",
"rt.preset": "Preset",
"rt.preset.none": "— Preset —",
// ── 3D viewport (native engine, WASM/WebGPU) ────────────────────────────────
"viewport3d.engineUnavailable": "3D engine unavailable",
"viewport3d.engineUnavailableHint":
"WebGPU could not be initialized. Switch to WebGL2 in the status bar to keep using the 3D view.",
};
+36 -1
View File
@@ -44,6 +44,7 @@ import {
wallVerticalExtent,
} from "../model/wall";
import { MaterialRuntime } from "../materials/runtime";
import { Wasm3DViewport } from "./Wasm3DViewport";
import type { RenderMode, View3d } from "../ui/TopBar";
import type { ToolDraft } from "../tools/types";
import { applyAngleConstraint } from "../tools/snapping";
@@ -85,7 +86,41 @@ export interface ViewportContextInfo {
wallId: string | null;
}
export function Viewport3D({
/**
* Engine-Umschalter (Gegenstück zu PlanView `wantWasm` / App `rendererMode`):
* `?engine=wasm` in der URL gewinnt; ohne Param zählt die in der Statusleiste
* persistierte Wahl (localStorage `cad.rendererMode`, siehe App.tsx). Zusätzlich
* muss WebGPU vorhanden sein — fehlt es trotz Flag, bleibt die three.js-Sicht
* (stiller Fallback + Warnung, wie in PlanView). Einmal je Seitenladung
* ausgewertet: ein Wechsel läuft ohnehin über einen Reload (setRendererMode).
*/
const WASM_ENGINE_ACTIVE: boolean = (() => {
if (typeof window === "undefined" || typeof navigator === "undefined") return false;
const fromUrl = new URLSearchParams(window.location.search).get("engine");
const wanted = fromUrl
? fromUrl === "wasm"
: window.localStorage.getItem("cad.rendererMode") === "wasm";
if (!wanted) return false;
if (!("gpu" in navigator)) {
console.warn("engine=wasm angefragt, aber navigator.gpu fehlt → three.js-Fallback (3D)");
return false;
}
return true;
})();
/**
* Öffentliche 3D-Sicht: wählt zwischen der nativen Engine (render3d als
* WASM/WebGPU, Wasm3DViewport) und der three.js-Szene (ThreeViewport3D,
* unveränderter Standardpfad). Der Engine-Pfad ist ein Integrations-Spike und
* nutzt vorerst nur das Projekt (Massenmodell + Orbit); alle weiteren Props
* (Auswahl, Render-Modi, Editier-Griffe) wirken nur im three.js-Pfad.
*/
export function Viewport3D(props: Parameters<typeof ThreeViewport3D>[0]) {
if (WASM_ENGINE_ACTIVE) return <Wasm3DViewport project={props.project} />;
return <ThreeViewport3D {...props} />;
}
function ThreeViewport3D({
project,
visibleCodes,
floorDisplay = SHOW_ALL,
+244
View File
@@ -0,0 +1,244 @@
// 3D-Viewport auf der nativen Engine: render3d (Rust/wgpu) als WASM/WebGPU-
// Canvas. Aktiv hinter demselben Engine-Umschalter wie die 2D-Plan-Ansicht
// (`?engine=wasm` bzw. Statusleisten-Wahl, siehe Viewport3D.tsx); die three.js-
// Sicht bleibt der unveränderte Standardpfad.
//
// INTEGRATIONS-SPIKE (Umfang bewusst schmal): Massenmodell (Wände mit Öffnungs-
// Teilquadern + Deckenplatten, alle Geschosse gestapelt) + Orbit-Navigation.
// Auswahl/Raycast, Render-Modi, Kamera-Presets und Editier-Griffe der three.js-
// Sicht folgen später — bis dahin gilt: LINKS ohne Funktion, RECHTS unterdrückt
// nur das Browser-Menü.
//
// MAUS-SCHEMA wie die three.js-Sicht (docs/design/context-menu.md):
// MITTE ziehen = Orbit, Shift+MITTE = Pan, RAD = Zoom.
// Die Orbit-/Pan-Mathematik folgt den OrbitControls-Konventionen (Winkel je
// Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen.
import { useEffect, useRef } from "react";
import type { Project } from "../model/types";
import { projectToModel3d } from "../plan/toWalls3d";
import { t } from "../i18n";
import { useWasm3dRenderer } from "./useWasm3dRenderer";
import type { Camera3d } from "./useWasm3dRenderer";
/** Vertikaler Öffnungswinkel (Radiant) — wie die three.js-PerspectiveCamera (50°). */
const FOV_Y = (50 * Math.PI) / 180;
/** Pitch-Klemme gegen den Gimbal-Flip (identisch zu render3d::math::orbit_eye). */
const PITCH_LIMIT = Math.PI / 2 - 0.01;
/** Orbit-Zustand der Kamera: Kugelkoordinaten um ein Blickziel (world, Y-up). */
interface OrbitState {
/** Azimut um die Y-Achse (Radiant). */
yaw: number;
/** Erhebung über die XZ-Ebene (Radiant, geklemmt). */
pitch: number;
/** Abstand vom Ziel (Meter). */
dist: number;
/** Blickziel (world). */
target: [number, number, number];
}
/** Fertige Kamera aus dem Orbit-Zustand (Konvention wie render3d::math::orbit_eye). */
function orbitCamera(o: OrbitState): Camera3d {
const pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, o.pitch));
const r = o.dist * Math.cos(pitch);
return {
eye: [
o.target[0] + r * Math.cos(o.yaw),
o.target[1] + o.dist * Math.sin(pitch),
o.target[2] + r * Math.sin(o.yaw),
],
target: [o.target[0], o.target[1], o.target[2]],
up: [0, 1, 0],
perspective: true,
fovY: FOV_Y,
orthoHalfHeight: o.dist * 0.5,
};
}
/**
* Formatfüllender Start-Orbit: Bounding-Box des geflachten Modells (Wände +
* Decken, world: x = Modell-x, z = Modell-y, y = Höhe) einpassen — Blick von
* schräg oben (wie das Persp-Preset), Distanz aus Radius + Sichtwinkel.
*/
function fitOrbit(project: Project): OrbitState {
const model = projectToModel3d(project);
let minX = Infinity;
let minY = Infinity;
let minZ = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let maxZ = -Infinity;
const grow = (x: number, y: number, z: number) => {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
};
for (const w of model.walls) {
grow(w.start[0], w.baseElevation, w.start[1]);
grow(w.end[0], w.baseElevation + w.height, w.end[1]);
}
for (const s of model.slabs) {
for (const p of s.outline) {
grow(p[0], s.zBottom, p[1]);
grow(p[0], s.zTop, p[1]);
}
}
// Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start).
if (!Number.isFinite(minX)) {
return { yaw: Math.PI / 4, pitch: 0.5, dist: 14, target: [2.5, 1, 2] };
}
const target: [number, number, number] = [
(minX + maxX) / 2,
(minY + maxY) / 2,
(minZ + maxZ) / 2,
];
const radius =
Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2 || 1;
// Abstand so, dass die Bounding-Kugel ins vertikale Sichtfeld passt (+ Rand).
const dist = Math.max(4, (radius / Math.tan(FOV_Y / 2)) * 1.2);
return { yaw: Math.PI / 4, pitch: 0.5, dist, target };
}
export function Wasm3DViewport({ project }: { project: Project }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const { render, updateModel, ready, failed } = useWasm3dRenderer(canvasRef, true);
// Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen
// direkt neu, ohne die Komponente je Frame zu re-rendern.
const orbitRef = useRef<OrbitState | null>(null);
// Frisches Projekt für den (stabilen) Init-Effekt unten.
const projectRef = useRef(project);
projectRef.current = project;
// Modell bei Bereitschaft + jeder Projektänderung neu einspielen. Der Orbit
// wird nur beim ERSTEN Mal eingepasst — danach bleibt die Kamera beim
// Editieren stehen (gleiches Verhalten wie der three.js-Kamera-Snapshot).
useEffect(() => {
if (!ready) return;
updateModel(project);
if (!orbitRef.current) orbitRef.current = fitOrbit(project);
render(orbitCamera(orbitRef.current));
}, [ready, project, updateModel, render]);
// Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu
// zeichnen (die Surface-Anpassung übernimmt der Hook im render()).
useEffect(() => {
const canvas = canvasRef.current;
if (!ready || !canvas) return;
const ro = new ResizeObserver(() => {
if (orbitRef.current) render(orbitCamera(orbitRef.current));
});
ro.observe(canvas);
return () => ro.disconnect();
}, [ready, render]);
// Maus-Navigation (MITTE = Orbit, Shift+MITTE = Pan, RAD = Zoom).
useEffect(() => {
const canvas = canvasRef.current;
if (!ready || !canvas) return;
let drag: { mode: "orbit" | "pan"; x: number; y: number } | null = null;
const redraw = () => {
if (orbitRef.current) render(orbitCamera(orbitRef.current));
};
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 1) return; // nur MITTE navigiert (links = Auswahl, s. o.)
e.preventDefault();
drag = { mode: e.shiftKey ? "pan" : "orbit", x: e.clientX, y: e.clientY };
canvas.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
const o = orbitRef.current;
if (!drag || !o) return;
const dx = e.clientX - drag.x;
const dy = e.clientY - drag.y;
drag.x = e.clientX;
drag.y = e.clientY;
const h = canvas.clientHeight || 1;
if (drag.mode === "orbit") {
// Winkel je Pixel wie OrbitControls: voller Umlauf über die Viewport-Höhe.
o.yaw += (2 * Math.PI * dx) / h;
o.pitch = Math.max(
-PITCH_LIMIT,
Math.min(PITCH_LIMIT, o.pitch + (2 * Math.PI * dy) / h),
);
} else {
// Pan in der Bildebene: Welt-Meter je Pixel aus Distanz + Sichtwinkel;
// Ziel entgegen der Mausrichtung verschieben (Szene folgt dem Cursor).
const worldPerPx = (2 * o.dist * Math.tan(FOV_Y / 2)) / h;
const pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, o.pitch));
// Kamera-Basis aus dem Orbit: f = Blickrichtung, s = rechts, u = Kamera-oben.
const f = [
-Math.cos(pitch) * Math.cos(o.yaw),
-Math.sin(pitch),
-Math.cos(pitch) * Math.sin(o.yaw),
];
const s = [Math.sin(o.yaw), 0, -Math.cos(o.yaw)];
const u = [
s[1] * f[2] - s[2] * f[1],
s[2] * f[0] - s[0] * f[2],
s[0] * f[1] - s[1] * f[0],
];
o.target[0] += (-s[0] * dx + u[0] * dy) * worldPerPx;
o.target[1] += (-s[1] * dx + u[1] * dy) * worldPerPx;
o.target[2] += (-s[2] * dx + u[2] * dy) * worldPerPx;
}
redraw();
};
const endDrag = (e: PointerEvent) => {
if (!drag) return;
drag = null;
if (canvas.hasPointerCapture(e.pointerId)) {
canvas.releasePointerCapture(e.pointerId);
}
};
const onWheel = (e: WheelEvent) => {
const o = orbitRef.current;
if (!o) return;
e.preventDefault();
// Exponentielles Dolly (rad-deltaY ≈ ±100 je Rastung → ~14 % je Schritt).
o.dist = Math.max(0.2, Math.min(2000, o.dist * Math.exp(e.deltaY * 0.0013)));
redraw();
};
// Browser-Kontextmenü unterdrücken (RECHTS ist fürs App-Menü reserviert).
const onContextMenu = (e: MouseEvent) => e.preventDefault();
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
canvas.addEventListener("wheel", onWheel, { passive: false });
canvas.addEventListener("contextmenu", onContextMenu);
return () => {
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", endDrag);
canvas.removeEventListener("pointercancel", endDrag);
canvas.removeEventListener("wheel", onWheel);
canvas.removeEventListener("contextmenu", onContextMenu);
};
}, [ready, render]);
return (
<div className="viewport">
{/* Canvas-Anzeigegröße kommt aus `.viewport canvas` (styles.css); die
Renderauflösung (DPR) gleicht der Hook je Frame ab. */}
<canvas ref={canvasRef} data-engine="render3d" />
{failed && (
<div className="viewport-fallback">
<strong>{t("viewport3d.engineUnavailable")}</strong>
<span>{t("viewport3d.engineUnavailableHint")}</span>
</div>
)}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
/**
* React-Hook für den nativen render3d-Renderer als WASM/WebGPU-Viewport.
*
* Gegenstück zu {@link useWasmPlanRenderer} (2D): lädt das WASM-Modul aus
* src/engine/pkg3d (`npm run build:engine3d`), bindet den Renderer an ein
* Canvas und reicht Modell + Kamera durch. Die eigentliche Zeichenlogik lebt
* in Rust (src-tauri/render3d, Feature `web`) — dieselbe Extrusion/Pipeline
* wie das native wgpu-Fenster.
*
* Modell-Sync: `updateModel(project)` flacht das Projekt über
* {@link projectToModel3d} (Wände mit Öffnungs-Teilquadern + Deckenplatten)
* und schiebt es als JSON an `set_model` — exakt dasselbe Format wie der
* native Tauri-Push (nativeSync.ts).
*
* Fällt bei fehlendem WebGPU oder Init-Fehler auf `ready:false` + `failed:true`
* zurück; der Aufrufer (Wasm3DViewport) zeigt dann den Hinweis-Fallback.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { Project } from "../model/types";
import { projectToModel3d } from "../plan/toWalls3d";
// wgpu-22-Limit-Shim (maxInterStageShaderComponents), geteilt mit useWasmPlanRenderer.
import { patchRequestDeviceLimits } from "../engine/requestDeviceShim";
/** Fertige Kamera für einen Frame (world-Meter, Y-up — wie render3d::types::Camera). */
export interface Camera3d {
eye: [number, number, number];
target: [number, number, number];
up: [number, number, number];
/** true = perspektivisch (fovY), false = orthografisch (orthoHalfHeight). */
perspective: boolean;
/** Vertikaler Öffnungswinkel in RADIANT (nur perspektivisch). */
fovY: number;
/** Halbe Sichthöhe in Metern (nur orthografisch). */
orthoHalfHeight: number;
}
/** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine3d`). */
interface WebModelRendererLike {
set_model(json: string): void;
set_camera(
eyeX: number,
eyeY: number,
eyeZ: number,
targetX: number,
targetY: number,
targetZ: number,
upX: number,
upY: number,
upZ: number,
perspective: boolean,
fovY: number,
orthoHalfHeight: number,
): void;
resize(w: number, h: number): void;
render(): void;
}
/**
* WASM-Modul EINMAL laden + initialisieren (memoisiert). `mod.default()` darf
* nur einmal laufen — ein zweiter Init (React-StrictMode-Doppelmount) verwirft
* die Closures des ersten („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/pkg3d/render3d.js");
await mod.default(); // lädt render3d_bg.wasm
return mod;
})();
}
return enginePromise;
}
/**
* Renderer je Canvas EINMAL erzeugen (memoisiert). Ein Canvas kann nur EINEN
* WebGPU-Kontext tragen; ein zweiter `WebModelRenderer.new` auf demselben
* Canvas (StrictMode-Remount) würde kollidieren — die gecachte Promise lässt
* den Doppelmount denselben Renderer teilen.
*/
const rendererCache = new WeakMap<HTMLCanvasElement, Promise<WebModelRendererLike>>();
function getRenderer(canvas: HTMLCanvasElement): Promise<WebModelRendererLike> {
let p = rendererCache.get(canvas);
if (!p) {
p = (async () => {
patchRequestDeviceLimits(); // wgpu-22-Limit-Shim (src/engine/requestDeviceShim)
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.WebModelRenderer.new(canvas)) as WebModelRendererLike;
})();
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 useWasm3dRenderer(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
enabled: boolean,
) {
const rendererRef = useRef<WebModelRendererLike | null>(null);
const [ready, setReady] = useState(false);
const [failed, setFailed] = 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);
setFailed(false);
} catch (e) {
console.warn("render3d WASM/WebGPU-Init fehlgeschlagen", e);
rendererRef.current = null;
setReady(false);
if (!disposed) setFailed(true);
}
})();
return () => {
disposed = true;
setReady(false);
};
}, [enabled, canvasRef]);
/** Modell bei Projektänderung neu setzen (re-extrudiert in Rust). */
const updateModel = useCallback((project: Project) => {
const r = rendererRef.current;
if (!r || !project) return;
try {
r.set_model(JSON.stringify(projectToModel3d(project)));
} catch (e) {
console.warn("render3d set_model fehlgeschlagen:", e);
}
}, []);
/** Einen Frame mit der gegebenen Kamera zeichnen (Canvas-Größe + DPR abgleichen). */
const render = useCallback(
(cam: Camera3d) => {
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_camera(
cam.eye[0],
cam.eye[1],
cam.eye[2],
cam.target[0],
cam.target[1],
cam.target[2],
cam.up[0],
cam.up[1],
cam.up[2],
cam.perspective,
cam.fovY,
cam.orthoHalfHeight,
);
r.render();
} catch (e) {
console.warn("render3d render fehlgeschlagen:", e);
}
},
[canvasRef],
);
return { render, updateModel, ready, failed };
}