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:
@@ -130,8 +130,10 @@ struct TextState {
|
||||
|
||||
/// Schrift des Plans: dieselbe Familie wie der Browser-Text (`--font` in
|
||||
/// styles.css beginnt mit "Inter"); cosmic-text laedt die Systemfonts und
|
||||
/// faellt bei fehlender Familie auf sans-serif zurueck.
|
||||
const TEXT_FAMILY: &str = "Inter";
|
||||
/// faellt bei fehlender Familie auf sans-serif zurueck. Standardwert des
|
||||
/// `Renderer.text_family`-Feldes — im Web-Pfad (kein System-Font-Zugriff) wird
|
||||
/// er auf die Familie der eingebetteten Font-Bytes umgestellt (siehe `load_font`).
|
||||
const DEFAULT_TEXT_FAMILY: &str = "Inter";
|
||||
|
||||
/// Der native 2D-Renderer: haelt beide Pipelines, den (dynamischen) Uniform-
|
||||
/// Puffer und die aktuell hochgeladene Szene. Pan/Zoom aktualisiert nur die
|
||||
@@ -158,6 +160,12 @@ pub struct Renderer {
|
||||
msaa_size: (u32, u32),
|
||||
/// Glyphen-Textpass (lazily beim ersten Frame mit Texten erstellt).
|
||||
text: Option<TextState>,
|
||||
/// Schriftfamilie fuer die Textzeilen (Default `DEFAULT_TEXT_FAMILY`). Wird im
|
||||
/// Web-Pfad auf die Familie der eingebetteten Font-Bytes umgestellt.
|
||||
text_family: String,
|
||||
/// Noch nicht in die FontSystem-DB geladene Font-Bytes (Web-Pfad: es gibt
|
||||
/// keine Systemfonts). Werden beim ersten `ensure_text` eingespielt.
|
||||
pending_font: Option<Vec<u8>>,
|
||||
/// Loeschfarbe (Zeichenblatt), Default hell wie im WebGL-Pfad (#f0f0f0).
|
||||
pub clear_color: wgpu::Color,
|
||||
/// Papier-Massstab-Nenner (1:N) fuer echte mm-Strichbreiten.
|
||||
@@ -392,6 +400,8 @@ impl Renderer {
|
||||
msaa_view: None,
|
||||
msaa_size: (0, 0),
|
||||
text: None,
|
||||
text_family: DEFAULT_TEXT_FAMILY.to_string(),
|
||||
pending_font: None,
|
||||
// #f0f0f0 (== --sheet). 0.941 entspricht dem WebGL-clearColor.
|
||||
clear_color: wgpu::Color {
|
||||
r: 0.941,
|
||||
@@ -458,6 +468,13 @@ impl Renderer {
|
||||
});
|
||||
}
|
||||
|
||||
/// Reicht Font-Bytes (TrueType/OpenType) nach, die beim ersten `ensure_text`
|
||||
/// in die FontSystem-DB geladen werden. Noetig im Web-Pfad, wo cosmic-text
|
||||
/// KEINE Systemfonts findet — ohne das bliebe der Grundriss textlos.
|
||||
pub fn load_font(&mut self, bytes: Vec<u8>) {
|
||||
self.pending_font = Some(bytes);
|
||||
}
|
||||
|
||||
/// Erstellt den Glyphen-Textpass beim ersten Bedarf (FontSystem laedt die
|
||||
/// Systemfonts einmalig). Der TextRenderer MUSS mit demselben
|
||||
/// MultisampleState wie die Geometrie-Pipelines gebaut werden (MSAA 4x),
|
||||
@@ -466,6 +483,20 @@ impl Renderer {
|
||||
if self.text.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut font_system = glyphon::FontSystem::new();
|
||||
// Nachgereichte Font-Bytes (Web-Pfad) in die DB laden und die tatsaechlich
|
||||
// registrierte Familie als Text-Familie uebernehmen (die Inter-Variable-
|
||||
// Datei meldet sich z.B. als "Inter Variable", nicht "Inter").
|
||||
if let Some(bytes) = self.pending_font.take() {
|
||||
let db = font_system.db_mut();
|
||||
let before: Vec<_> = db.faces().map(|f| f.id).collect();
|
||||
db.load_font_data(bytes);
|
||||
if let Some(face) = db.faces().find(|f| !before.contains(&f.id)) {
|
||||
if let Some((name, _)) = face.families.first() {
|
||||
self.text_family = name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
let cache = glyphon::Cache::new(device);
|
||||
let viewport = glyphon::Viewport::new(device, &cache);
|
||||
let mut atlas = glyphon::TextAtlas::new(device, queue, &cache, self.format);
|
||||
@@ -480,7 +511,7 @@ impl Renderer {
|
||||
None,
|
||||
);
|
||||
self.text = Some(TextState {
|
||||
font_system: glyphon::FontSystem::new(),
|
||||
font_system,
|
||||
swash_cache: glyphon::SwashCache::new(),
|
||||
viewport,
|
||||
atlas,
|
||||
@@ -615,6 +646,9 @@ impl Renderer {
|
||||
.map_or(false, |s| !s.texts.is_empty());
|
||||
if has_texts || self.text.is_some() {
|
||||
self.ensure_text(device, queue);
|
||||
// Familie VOR dem mutablen Borrow von self.text kopieren (ensure_text
|
||||
// hat sie ggf. auf die geladene Font-Familie umgestellt).
|
||||
let family = self.text_family.clone();
|
||||
let ts = self.text.as_mut().expect("ensure_text");
|
||||
let cvb = corrected_view_box(view_box, vw, vh);
|
||||
let meet = meet_scale(view_box, vw, vh);
|
||||
@@ -629,7 +663,7 @@ impl Renderer {
|
||||
let mut placed: Vec<Placed> = Vec::new();
|
||||
if let Some(scene) = &self.scene {
|
||||
let attrs = glyphon::Attrs::new()
|
||||
.family(glyphon::Family::Name(TEXT_FAMILY));
|
||||
.family(glyphon::Family::Name(&family));
|
||||
for t in &scene.texts {
|
||||
let font_px = t.size_mm * mm_px;
|
||||
// Unlesbar kleine Glyphen ueberspringen (spart Shaping/Atlas).
|
||||
|
||||
@@ -21,6 +21,12 @@ pub mod types;
|
||||
#[cfg(feature = "render")]
|
||||
pub mod gpu;
|
||||
|
||||
// Browser-Bindings (wasm32 + WebGPU), nur mit Feature "web". Setzt "render" voraus
|
||||
// (dieselbe GPU-Schicht wie das native Fenster), fuegt aber KEINE winit-Abhaengigkeit
|
||||
// hinzu — die Canvas dient direkt als wgpu-Surface.
|
||||
#[cfg(feature = "web")]
|
||||
pub mod web;
|
||||
|
||||
pub use demo::{demo_scene, initial_view_box};
|
||||
pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4};
|
||||
pub use tessellate::{
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// Browser-Bindings (wasm32 + WebGPU): fuehrt die native GPU-Schicht (`gpu::Renderer`)
|
||||
// als WASM-Modul in die App-UI, mit einer HTML-Canvas als wgpu-Surface. Bewusst
|
||||
// duenn — die gesamte Zeichenlogik (Tessellierung, Pipelines, Text) ist mit dem
|
||||
// nativen Fenster geteilt; hier liegt nur die Canvas-/WebGPU-Anbindung + die
|
||||
// wasm-bindgen-Fassade.
|
||||
//
|
||||
// Ablauf im Frontend (siehe src/plan/useWasmPlanRenderer.ts):
|
||||
// const r = await WebPlanRenderer.new(canvas, undefined);
|
||||
// r.set_scene(JSON.stringify(planToRenderScene(plan)));
|
||||
// r.set_view_box(x, y, w, h); r.resize(px_w, px_h); r.render();
|
||||
//
|
||||
// Szenenformat = dieselbe serde-Struct wie der native Push (render2d::types::Scene),
|
||||
// als JSON-String hereingereicht. Pan/Zoom aendern nur die ViewBox (kein Re-Tess).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::HtmlCanvasElement;
|
||||
|
||||
use crate::gpu::Renderer;
|
||||
use crate::types::{Scene, ViewBox};
|
||||
|
||||
/// Eingebettete Plan-Schrift (Inter). cosmic-text findet auf wasm KEINE
|
||||
/// Systemfonts — ohne eingebettete Bytes bliebe der Grundriss textlos. Die
|
||||
/// Familie meldet sich als "Inter Variable"; `Renderer::load_font` uebernimmt
|
||||
/// den tatsaechlichen Namen automatisch.
|
||||
const INTER_TTF: &[u8] = include_bytes!("../assets/Inter.ttf");
|
||||
|
||||
/// Der Browser-Renderer: haelt Surface + Device/Queue + den geteilten `Renderer`
|
||||
/// und die aktuelle ViewBox. Wird per `WebPlanRenderer.new(canvas)` aus JS erzeugt.
|
||||
#[wasm_bindgen]
|
||||
pub struct WebPlanRenderer {
|
||||
surface: wgpu::Surface<'static>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
renderer: Renderer,
|
||||
view_box: ViewBox,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WebPlanRenderer {
|
||||
/// Erzeugt den Renderer asynchron (Adapter/Device via WebGPU). `font_bytes`
|
||||
/// optional — ohne Angabe wird die eingebettete Inter-Schrift genutzt.
|
||||
/// Aus JS: `await WebPlanRenderer.new(canvasEl, undefined)`.
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub async fn new(
|
||||
canvas: HtmlCanvasElement,
|
||||
font_bytes: Option<Vec<u8>>,
|
||||
) -> Result<WebPlanRenderer, JsValue> {
|
||||
// Panics als lesbare Konsolen-Fehler statt "unreachable" ausgeben.
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
let width = canvas.width().max(1);
|
||||
let height = canvas.height().max(1);
|
||||
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::BROWSER_WEBGPU,
|
||||
..Default::default()
|
||||
});
|
||||
let surface = instance
|
||||
.create_surface(wgpu::SurfaceTarget::Canvas(canvas))
|
||||
.map_err(|e| JsValue::from_str(&format!("create_surface: {e:?}")))?;
|
||||
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
force_fallback_adapter: false,
|
||||
compatible_surface: Some(&surface),
|
||||
})
|
||||
.await
|
||||
.ok_or_else(|| JsValue::from_str("kein WebGPU-Adapter (navigator.gpu?)"))?;
|
||||
|
||||
let (device, queue) = adapter
|
||||
.request_device(
|
||||
&wgpu::DeviceDescriptor {
|
||||
label: Some("render2d.web.device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: wgpu::Limits::downlevel_webgl2_defaults()
|
||||
.using_resolution(adapter.limits()),
|
||||
memory_hints: wgpu::MemoryHints::Performance,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| JsValue::from_str(&format!("request_device: {e:?}")))?;
|
||||
|
||||
// WebGPU-Canvas liefert i.d.R. bgra8unorm (kein sRGB-Format fuer die
|
||||
// Surface). Wir nehmen das erste angebotene Format — MSAA-Textur nutzt
|
||||
// dasselbe (siehe Renderer::ensure_msaa).
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
let format = caps
|
||||
.formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| !f.is_srgb())
|
||||
.unwrap_or(caps.formats[0]);
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
alpha_mode: caps.alpha_modes[0],
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
};
|
||||
surface.configure(&device, &config);
|
||||
|
||||
let mut renderer = Renderer::new(&device, format);
|
||||
// Font einspielen: uebergebene Bytes ODER die eingebettete Inter-Schrift.
|
||||
renderer.load_font(font_bytes.unwrap_or_else(|| INTER_TTF.to_vec()));
|
||||
|
||||
web_sys::console::log_1(&JsValue::from_str(&format!(
|
||||
"render2d WebGPU-Viewport initialisiert ({}x{}, {:?})",
|
||||
width, height, format
|
||||
)));
|
||||
|
||||
Ok(WebPlanRenderer {
|
||||
surface,
|
||||
device,
|
||||
queue,
|
||||
config,
|
||||
renderer,
|
||||
// Platzhalter bis set_view_box; verhindert Division durch 0.
|
||||
view_box: ViewBox::new(0.0, 0.0, width as f32, height as f32),
|
||||
})
|
||||
}
|
||||
|
||||
/// Setzt die zu zeichnende Szene aus einem JSON-String (Format =
|
||||
/// render2d::types::Scene, identisch zum nativen Push). Re-tesselliert + laedt
|
||||
/// die GPU-Puffer; ruft NICHT selbst `render` — das macht der Aufrufer.
|
||||
pub fn set_scene(&mut self, json: &str) -> Result<(), JsValue> {
|
||||
let scene: Scene = serde_json::from_str(json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Szene parsen: {e}")))?;
|
||||
self.renderer.upload_scene(&self.device, &scene);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Aktuellen Ausschnitt (viewBox-Einheiten, Bildschirm-Raum) setzen. Aendert
|
||||
/// nur die Ortho-Matrix im naechsten `render` (kein Re-Tessellieren).
|
||||
pub fn set_view_box(&mut self, x: f32, y: f32, w: f32, h: f32) {
|
||||
self.view_box = ViewBox::new(x, y, w, h);
|
||||
}
|
||||
|
||||
/// Papier-Massstab-Nenner (1:N) fuer echte mm-Strichbreiten setzen.
|
||||
pub fn set_paper_scale(&mut self, n: f32) {
|
||||
if n > 0.0 {
|
||||
self.renderer.paper_scale_n = n;
|
||||
}
|
||||
}
|
||||
|
||||
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
let (w, h) = (width.max(1), height.max(1));
|
||||
if self.config.width == w && self.config.height == h {
|
||||
return;
|
||||
}
|
||||
self.config.width = w;
|
||||
self.config.height = h;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
|
||||
/// Zeichnet EINEN Frame in die Surface-Textur.
|
||||
pub fn render(&mut self) {
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
Ok(f) => f,
|
||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::warn_1(&JsValue::from_str(&format!(
|
||||
"render2d Surface-Fehler: {e:?}"
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let view = frame
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
self.renderer.render(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&view,
|
||||
self.view_box,
|
||||
(self.config.width, self.config.height),
|
||||
);
|
||||
frame.present();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user