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:
@@ -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/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user