b4c4a2cc6b
glyphon 0.11 ist die neueste zu wgpu 29 passende Version (wgpu 30 existiert bereits, glyphon pinnt aber ^29). Das 22er-Limit maxInterStageShaderComponents wird von 29 nicht mehr in requiredLimits gesendet — src/engine/requestDeviceShim.ts komplett entfernt, beide Hook-Importstellen (useWasmPlanRenderer, useWasm3dRenderer) angepasst. pollster 0.3→0.4, naga 22→29 mitgezogen. Alle Draw-/Text-Pfade (draw_sequence, glyphon ColorMode::Web, widthScreen-Polylinien, Headless/Golden) unverändert funktionsfähig. Verifiziert: cargo check nativ (render2d/render3d/src-tauri) grün, cargo test render2d 17/17 + Golden bit-exakt, render3d 29/29, wasm32 --features web für beide grün, npx tsc -b + npm run build grün.
914 lines
39 KiB
Rust
914 lines
39 KiB
Rust
// wgpu-Pipelines des nativen 2D-Renderers (nur mit Feature "render").
|
|
//
|
|
// Zwei Pipelines analog zum WebGL-Pfad:
|
|
// - Fuell-Pipeline: gefuellte Polygone, Ortho-Matrix-Uniform.
|
|
// - Linien-Pipeline: Quad-Expansion, Breite in echten Papier-mm (Shader).
|
|
//
|
|
// Der Renderer ist fenster-/surface-agnostisch: er bekommt ein `Device`, eine
|
|
// `Queue` und ein `TextureView` (die Surface oder eine Offscreen-Textur) und
|
|
// zeichnet dort hinein. Die Fenster-Anbindung (winit) liegt separat im Spike-Bin.
|
|
//
|
|
// Pro-Batch-Uniforms ueber DYNAMISCHE Offsets: alle Globals-Bloecke (je Fuell-/
|
|
// Linien-Batch einer) liegen hintereinander in EINEM Uniform-Puffer (auf die
|
|
// GPU-Mindest-Ausrichtung gepolstert). Vor dem Zeichnen wird der ganze Puffer
|
|
// EINMAL geschrieben; jeder Draw-Call bindet seinen Block per dynamischem Offset.
|
|
// Das umgeht die Regel "kein write_buffer auf gebundenen Puffer im offenen Pass".
|
|
|
|
use bytemuck::{Pod, Zeroable};
|
|
use wgpu::util::DeviceExt;
|
|
|
|
use crate::ortho::{compute_ortho_matrix, corrected_view_box, meet_scale, mm_to_device_px, Mat4};
|
|
use crate::shaders::{ARC_WGSL, FILL_WGSL, LINE_WGSL};
|
|
use crate::tessellate::{compile_scene, to_screen, ArcInstanceData, BatchKind, GpuGeometry, MAX_ARC_DASH};
|
|
use crate::types::{Scene, Text, TextAlign, ViewBox};
|
|
|
|
/// MSAA-Faktor: 4x Multisampling fuer glatte Linien-/Fuellkanten (wie im Browser).
|
|
const SAMPLE_COUNT: u32 = 4;
|
|
|
|
/// Uniform-Block, 1:1 zum WGSL-`Globals`-Struct. std140-kompatibel ausgelegt
|
|
/// (16-Byte-Alignment der mat4/vec4, Skalare am Ende gepackt).
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy, Pod, Zeroable)]
|
|
struct Globals {
|
|
view_proj: [f32; 16],
|
|
color: [f32; 4],
|
|
viewport_px: [f32; 2],
|
|
stroke_px: f32,
|
|
stroke_scale: f32,
|
|
}
|
|
|
|
impl Default for Globals {
|
|
fn default() -> Self {
|
|
Self {
|
|
view_proj: crate::ortho::identity(),
|
|
color: [0.0, 0.0, 0.0, 1.0],
|
|
viewport_px: [1.0, 1.0],
|
|
stroke_px: 0.0,
|
|
stroke_scale: 1.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Frame-Uniform der analytischen Bogen-Pipeline (1:1 zu `ArcGlobals` in WGSL).
|
|
/// EIN Block je Frame (fuer ALLE Boegen gleich) — anders als die per-Batch-
|
|
/// `Globals` braucht er keinen dynamischen Offset. std140: mat4 + vec2 + 2 Skalare
|
|
/// = 80 Byte (16-Byte-Vielfaches).
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy, Pod, Zeroable)]
|
|
struct ArcGlobals {
|
|
view_proj: [f32; 16],
|
|
viewport_px: [f32; 2],
|
|
px_per_screen: f32,
|
|
stroke_scale: f32,
|
|
}
|
|
|
|
/// EINE Bogen-Instanz fuer die GPU (Vertex-Puffer, step_mode Instance), 1:1 zum
|
|
/// Instanz-Layout in `ARC_WGSL`. Zoom-invariant: Bildschirm-Raum-Geometrie +
|
|
/// Dash in Modell-Metern. 18 f32 = 72 Byte.
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy, Pod, Zeroable)]
|
|
struct ArcInstance {
|
|
center: [f32; 2],
|
|
/// (r_screen, a0, sweep, r_model)
|
|
geom: [f32; 4],
|
|
color: [f32; 4],
|
|
/// (width_mm, dash_total, dash_count, _pad)
|
|
wdash: [f32; 4],
|
|
/// bis zu MAX_ARC_DASH An/Aus-Laengen (Modell-Meter).
|
|
dash: [f32; 4],
|
|
}
|
|
|
|
impl ArcInstance {
|
|
fn from_data(a: &ArcInstanceData) -> Self {
|
|
// MAX_ARC_DASH ist 4 (== vec4 im Shader); Compile-Time abgesichert.
|
|
const _: () = assert!(MAX_ARC_DASH == 4);
|
|
Self {
|
|
center: a.center,
|
|
geom: [a.r_screen, a.a0, a.sweep, a.r_model],
|
|
color: a.color,
|
|
wdash: [a.width_mm, a.dash_total, a.dash_count as f32, 0.0],
|
|
dash: a.dash,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rundet `size` auf das naechste Vielfache von `align` (>=1) auf.
|
|
fn align_up(size: u64, align: u64) -> u64 {
|
|
if align <= 1 {
|
|
return size;
|
|
}
|
|
((size + align - 1) / align) * align
|
|
}
|
|
|
|
/// GPU-seitige Puffer + Batch-Metadaten einer tessellierten Szene.
|
|
struct SceneBuffers {
|
|
fill_vbo: Option<wgpu::Buffer>,
|
|
fill_ibo: Option<wgpu::Buffer>,
|
|
line_vbo: Option<wgpu::Buffer>,
|
|
line_ibo: Option<wgpu::Buffer>,
|
|
/// Instanz-Puffer der analytischen Boegen (je Bogen EINE Instanz); None wenn leer.
|
|
arc_vbo: Option<wgpu::Buffer>,
|
|
/// Anzahl Bogen-Instanzen (Draw: 6 Vertices je Instanz).
|
|
arc_count: u32,
|
|
geo: GpuGeometry,
|
|
/// Textzeilen der Szene (Modell-Anker + Papier-mm-Groesse). Werden nicht
|
|
/// tesselliert, sondern pro Frame ueber den Glyphen-Atlas gesetzt (die
|
|
/// Pixelgroesse haengt vom Zoom ab).
|
|
texts: Vec<Text>,
|
|
}
|
|
|
|
/// Zustand des Glyphen-Textpasses (glyphon): Systemfonts + Rasterizer-Cache +
|
|
/// GPU-Atlas + Renderer. Lazily beim ersten Frame mit Texten erstellt (braucht
|
|
/// die Queue, die `Renderer::new` nicht bekommt — Signatur bleibt stabil).
|
|
struct TextState {
|
|
font_system: glyphon::FontSystem,
|
|
swash_cache: glyphon::SwashCache,
|
|
viewport: glyphon::Viewport,
|
|
atlas: glyphon::TextAtlas,
|
|
renderer: glyphon::TextRenderer,
|
|
}
|
|
|
|
/// 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. 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
|
|
/// Ortho-Matrix (kein Re-Tessellieren).
|
|
pub struct Renderer {
|
|
fill_pipeline: wgpu::RenderPipeline,
|
|
line_pipeline: wgpu::RenderPipeline,
|
|
bind_group_layout: wgpu::BindGroupLayout,
|
|
/// Analytische Bogen-Pipeline (exakter Kreis-Shader, `ARC_WGSL`).
|
|
arc_pipeline: wgpu::RenderPipeline,
|
|
/// Frame-Uniform der Bogen-Pipeline (ein Block, kein dynamischer Offset).
|
|
arc_uniform: wgpu::Buffer,
|
|
arc_bind_group: wgpu::BindGroup,
|
|
/// Ausgerichtete Groesse eines Globals-Blocks im dynamischen Uniform-Puffer.
|
|
uniform_stride: u64,
|
|
/// Aktuell allozierter Uniform-Puffer + zugehoerige Bind-Group (wachsen bei Bedarf).
|
|
uniform: Option<UniformArena>,
|
|
scene: Option<SceneBuffers>,
|
|
/// Farbformat der Ziel-Surface (auch Format der MSAA-Textur).
|
|
format: wgpu::TextureFormat,
|
|
/// Multisample-Farbtextur (4x), lazily an die Ziel-Groesse gebunden.
|
|
msaa_view: Option<wgpu::TextureView>,
|
|
/// Groesse, fuer die `msaa_view` angelegt wurde.
|
|
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.
|
|
pub paper_scale_n: f32,
|
|
/// Papier-Massstab-Nenner (1:N) NUR fuer die Text-Schriftgroesse — bewusst
|
|
/// vom geometrischen `paper_scale_n` ENTKOPPELT: der SVG-Referenzpfad haelt
|
|
/// die Raumstempel-Schriftgroesse im normalen Anzeigemodus auf einer festen
|
|
/// Referenzskala 1:100 (lesbar, unabhaengig vom zufaelligen Einpass-Massstab
|
|
/// der aktuellen Ansicht) und wechselt erst im Druck-Modus auf den echten
|
|
/// gemessenen Massstab; siehe PlanView.tsx `textScaleForGl`/case "text". Ohne
|
|
/// diese Trennung wuerde Text hier mit dem live aus dem Ausschnitt
|
|
/// abgeleiteten `paper_scale_n` skalieren (z.B. 1:32 statt 1:100) und damit
|
|
/// sichtbar kleiner/groesser als im SVG-Pfad erscheinen.
|
|
pub text_scale_n: f32,
|
|
}
|
|
|
|
/// Ein Uniform-Puffer, der `capacity` Globals-Bloecke fasst, plus Bind-Group.
|
|
struct UniformArena {
|
|
buffer: wgpu::Buffer,
|
|
bind_group: wgpu::BindGroup,
|
|
capacity: u64,
|
|
}
|
|
|
|
impl Renderer {
|
|
/// Erzeugt Pipelines + Layout fuer ein gegebenes Farbformat (Surface).
|
|
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
|
let fill_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
|
label: Some("fill.wgsl"),
|
|
source: wgpu::ShaderSource::Wgsl(FILL_WGSL.into()),
|
|
});
|
|
let line_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
|
label: Some("line.wgsl"),
|
|
source: wgpu::ShaderSource::Wgsl(LINE_WGSL.into()),
|
|
});
|
|
|
|
// Ein Uniform-Block (Globals), fuer beide Pipelines geteilt, mit dynamischem
|
|
// Offset (jeder Batch bekommt seinen eigenen Block-Slice).
|
|
let bind_group_layout =
|
|
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
label: Some("globals.layout"),
|
|
entries: &[wgpu::BindGroupLayoutEntry {
|
|
binding: 0,
|
|
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
|
ty: wgpu::BindingType::Buffer {
|
|
ty: wgpu::BufferBindingType::Uniform,
|
|
has_dynamic_offset: true,
|
|
min_binding_size: wgpu::BufferSize::new(
|
|
std::mem::size_of::<Globals>() as u64
|
|
),
|
|
},
|
|
count: None,
|
|
}],
|
|
});
|
|
|
|
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
|
label: Some("2d.layout"),
|
|
bind_group_layouts: &[Some(&bind_group_layout)],
|
|
immediate_size: 0,
|
|
});
|
|
|
|
// Alpha-Blending (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) wie im WebGL-Pfad.
|
|
let blend = Some(wgpu::BlendState::ALPHA_BLENDING);
|
|
let color_target = wgpu::ColorTargetState {
|
|
format: color_format,
|
|
blend,
|
|
write_mask: wgpu::ColorWrites::ALL,
|
|
};
|
|
|
|
// Fuell-Pipeline: ein vec2-Attribut (Bildschirm-Position).
|
|
let fill_vertex_layout = wgpu::VertexBufferLayout {
|
|
array_stride: 2 * 4,
|
|
step_mode: wgpu::VertexStepMode::Vertex,
|
|
attributes: &wgpu::vertex_attr_array![0 => Float32x2],
|
|
};
|
|
let fill_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
|
label: Some("fill.pipeline"),
|
|
layout: Some(&pipeline_layout),
|
|
vertex: wgpu::VertexState {
|
|
module: &fill_module,
|
|
entry_point: Some("vs_main"),
|
|
buffers: &[fill_vertex_layout],
|
|
compilation_options: Default::default(),
|
|
},
|
|
fragment: Some(wgpu::FragmentState {
|
|
module: &fill_module,
|
|
entry_point: Some("fs_main"),
|
|
targets: &[Some(color_target.clone())],
|
|
compilation_options: Default::default(),
|
|
}),
|
|
primitive: wgpu::PrimitiveState {
|
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
|
// Ohr-Clipping liefert stets CCW; wir lassen Culling aus (robuster).
|
|
cull_mode: None,
|
|
..Default::default()
|
|
},
|
|
depth_stencil: None,
|
|
multisample: wgpu::MultisampleState {
|
|
count: SAMPLE_COUNT,
|
|
mask: !0,
|
|
alpha_to_coverage_enabled: false,
|
|
},
|
|
multiview_mask: None,
|
|
cache: None,
|
|
});
|
|
|
|
// Linien-Pipeline: [pos vec2, bisector vec2, side f32, miter f32], stride 6*4.
|
|
let line_vertex_layout = wgpu::VertexBufferLayout {
|
|
array_stride: 6 * 4,
|
|
step_mode: wgpu::VertexStepMode::Vertex,
|
|
attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32, 3 => Float32],
|
|
};
|
|
let line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
|
label: Some("line.pipeline"),
|
|
layout: Some(&pipeline_layout),
|
|
vertex: wgpu::VertexState {
|
|
module: &line_module,
|
|
entry_point: Some("vs_main"),
|
|
buffers: &[line_vertex_layout],
|
|
compilation_options: Default::default(),
|
|
},
|
|
fragment: Some(wgpu::FragmentState {
|
|
module: &line_module,
|
|
entry_point: Some("fs_main"),
|
|
targets: &[Some(color_target)],
|
|
compilation_options: Default::default(),
|
|
}),
|
|
primitive: wgpu::PrimitiveState {
|
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
|
cull_mode: None,
|
|
..Default::default()
|
|
},
|
|
depth_stencil: None,
|
|
multisample: wgpu::MultisampleState {
|
|
count: SAMPLE_COUNT,
|
|
mask: !0,
|
|
alpha_to_coverage_enabled: false,
|
|
},
|
|
multiview_mask: None,
|
|
cache: None,
|
|
});
|
|
|
|
// ── Analytische Bogen-Pipeline (exakter Kreis-Shader) ─────────────────
|
|
let arc_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
|
label: Some("arc.wgsl"),
|
|
source: wgpu::ShaderSource::Wgsl(ARC_WGSL.into()),
|
|
});
|
|
// Eigenes Layout: EIN Uniform-Block je Frame (kein dynamischer Offset).
|
|
let arc_bind_group_layout =
|
|
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
label: Some("arc.globals.layout"),
|
|
entries: &[wgpu::BindGroupLayoutEntry {
|
|
binding: 0,
|
|
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
|
ty: wgpu::BindingType::Buffer {
|
|
ty: wgpu::BufferBindingType::Uniform,
|
|
has_dynamic_offset: false,
|
|
min_binding_size: wgpu::BufferSize::new(
|
|
std::mem::size_of::<ArcGlobals>() as u64,
|
|
),
|
|
},
|
|
count: None,
|
|
}],
|
|
});
|
|
let arc_pipeline_layout =
|
|
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
|
label: Some("arc.pipeline.layout"),
|
|
bind_group_layouts: &[Some(&arc_bind_group_layout)],
|
|
immediate_size: 0,
|
|
});
|
|
// Instanz-Layout: [center vec2, geom vec4, color vec4, wdash vec4, dash vec4],
|
|
// stride 18*4, step_mode Instance. Das Quad kommt aus @builtin(vertex_index).
|
|
let arc_instance_layout = wgpu::VertexBufferLayout {
|
|
array_stride: 18 * 4,
|
|
step_mode: wgpu::VertexStepMode::Instance,
|
|
attributes: &wgpu::vertex_attr_array![
|
|
0 => Float32x2, 1 => Float32x4, 2 => Float32x4, 3 => Float32x4, 4 => Float32x4
|
|
],
|
|
};
|
|
let arc_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
|
label: Some("arc.pipeline"),
|
|
layout: Some(&arc_pipeline_layout),
|
|
vertex: wgpu::VertexState {
|
|
module: &arc_module,
|
|
entry_point: Some("vs_main"),
|
|
buffers: &[arc_instance_layout],
|
|
compilation_options: Default::default(),
|
|
},
|
|
fragment: Some(wgpu::FragmentState {
|
|
module: &arc_module,
|
|
entry_point: Some("fs_main"),
|
|
targets: &[Some(wgpu::ColorTargetState {
|
|
format: color_format,
|
|
blend,
|
|
write_mask: wgpu::ColorWrites::ALL,
|
|
})],
|
|
compilation_options: Default::default(),
|
|
}),
|
|
primitive: wgpu::PrimitiveState {
|
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
|
cull_mode: None,
|
|
..Default::default()
|
|
},
|
|
depth_stencil: None,
|
|
multisample: wgpu::MultisampleState {
|
|
count: SAMPLE_COUNT,
|
|
mask: !0,
|
|
alpha_to_coverage_enabled: false,
|
|
},
|
|
multiview_mask: None,
|
|
cache: None,
|
|
});
|
|
let arc_uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
|
label: Some("arc.globals.buffer"),
|
|
size: std::mem::size_of::<ArcGlobals>() as u64,
|
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
mapped_at_creation: false,
|
|
});
|
|
let arc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
label: Some("arc.globals.bind"),
|
|
layout: &arc_bind_group_layout,
|
|
entries: &[wgpu::BindGroupEntry {
|
|
binding: 0,
|
|
resource: arc_uniform.as_entire_binding(),
|
|
}],
|
|
});
|
|
|
|
// Block-Stride = Globals auf die Dynamic-Offset-Ausrichtung des Geraets gepolstert.
|
|
let min_align = device.limits().min_uniform_buffer_offset_alignment as u64;
|
|
let uniform_stride = align_up(std::mem::size_of::<Globals>() as u64, min_align.max(1));
|
|
|
|
Self {
|
|
fill_pipeline,
|
|
line_pipeline,
|
|
bind_group_layout,
|
|
arc_pipeline,
|
|
arc_uniform,
|
|
arc_bind_group,
|
|
uniform_stride,
|
|
uniform: None,
|
|
scene: None,
|
|
format: color_format,
|
|
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,
|
|
g: 0.941,
|
|
b: 0.941,
|
|
a: 1.0,
|
|
},
|
|
paper_scale_n: 100.0,
|
|
text_scale_n: 100.0,
|
|
}
|
|
}
|
|
|
|
/// Kompiliert eine Szene und laedt die GPU-Puffer hoch. Alles ist zoom-
|
|
/// invariant — Papier-mm-Striche wie auch die analytischen Boegen werden erst
|
|
/// im Shader auf Geraete-px abgebildet. Daher KEIN Zoom-Parameter und keine
|
|
/// Neu-Tessellierung bei Zoomaenderung mehr (der frueher noetige Bogen-Re-Tess-
|
|
/// Trigger entfaellt: der Kreis-Shader rendert bei jeder Skala exakt rund).
|
|
pub fn upload_scene(&mut self, device: &wgpu::Device, scene: &Scene) {
|
|
let geo = compile_scene(scene);
|
|
|
|
let mk_vbo = |data: &[f32], label: &str| -> Option<wgpu::Buffer> {
|
|
if data.is_empty() {
|
|
return None;
|
|
}
|
|
Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
label: Some(label),
|
|
contents: bytemuck::cast_slice(data),
|
|
usage: wgpu::BufferUsages::VERTEX,
|
|
}))
|
|
};
|
|
let mk_ibo = |data: &[u32], label: &str| -> Option<wgpu::Buffer> {
|
|
if data.is_empty() {
|
|
return None;
|
|
}
|
|
Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
label: Some(label),
|
|
contents: bytemuck::cast_slice(data),
|
|
usage: wgpu::BufferUsages::INDEX,
|
|
}))
|
|
};
|
|
|
|
// Bogen-Instanzen (je Bogen EINE) in einen Instanz-Vertexpuffer packen.
|
|
let arc_instances: Vec<ArcInstance> =
|
|
geo.arcs.iter().map(ArcInstance::from_data).collect();
|
|
let arc_count = arc_instances.len() as u32;
|
|
let arc_vbo = if arc_instances.is_empty() {
|
|
None
|
|
} else {
|
|
Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
label: Some("arc.instances"),
|
|
contents: bytemuck::cast_slice(&arc_instances),
|
|
usage: wgpu::BufferUsages::VERTEX,
|
|
}))
|
|
};
|
|
|
|
self.scene = Some(SceneBuffers {
|
|
fill_vbo: mk_vbo(&geo.fill_pos, "fill.vbo"),
|
|
fill_ibo: mk_ibo(&geo.fill_idx, "fill.ibo"),
|
|
line_vbo: mk_vbo(&geo.line_verts, "line.vbo"),
|
|
line_ibo: mk_ibo(&geo.line_idx, "line.ibo"),
|
|
arc_vbo,
|
|
arc_count,
|
|
geo,
|
|
texts: scene.texts.clone(),
|
|
});
|
|
}
|
|
|
|
/// 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),
|
|
/// sonst passt er nicht in den gemeinsamen Render-Pass.
|
|
fn ensure_text(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
|
|
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);
|
|
// ColorMode::Web statt Default (Accurate): die Surface ist non-sRGB und
|
|
// ALLE Geometrie-Passes schreiben ihre sRGB-Farbwerte unkonvertiert —
|
|
// Accurate wuerde die Textfarbe nach linear wandeln und die Glyphen
|
|
// deutlich dunkler zeichnen als der SVG-Referenzpfad (z.B. #5a7a9a ->
|
|
// (26,50,82) statt (90,122,154)).
|
|
let mut atlas = glyphon::TextAtlas::with_color_mode(
|
|
device,
|
|
queue,
|
|
&cache,
|
|
self.format,
|
|
glyphon::ColorMode::Web,
|
|
);
|
|
let renderer = glyphon::TextRenderer::new(
|
|
&mut atlas,
|
|
device,
|
|
wgpu::MultisampleState {
|
|
count: SAMPLE_COUNT,
|
|
mask: !0,
|
|
alpha_to_coverage_enabled: false,
|
|
},
|
|
None,
|
|
);
|
|
self.text = Some(TextState {
|
|
font_system,
|
|
swash_cache: glyphon::SwashCache::new(),
|
|
viewport,
|
|
atlas,
|
|
renderer,
|
|
});
|
|
}
|
|
|
|
/// Stellt sicher, dass der dynamische Uniform-Puffer mindestens `count`
|
|
/// Bloecke fasst; realloziert (Puffer + Bind-Group) bei Bedarf.
|
|
fn ensure_uniform_capacity(&mut self, device: &wgpu::Device, count: u64) {
|
|
let need = count.max(1);
|
|
if let Some(u) = &self.uniform {
|
|
if u.capacity >= need {
|
|
return;
|
|
}
|
|
}
|
|
let size = self.uniform_stride * need;
|
|
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
|
label: Some("globals.buffer"),
|
|
size,
|
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
mapped_at_creation: false,
|
|
});
|
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
label: Some("globals.bind"),
|
|
layout: &self.bind_group_layout,
|
|
entries: &[wgpu::BindGroupEntry {
|
|
binding: 0,
|
|
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
|
buffer: &buffer,
|
|
offset: 0,
|
|
size: wgpu::BufferSize::new(std::mem::size_of::<Globals>() as u64),
|
|
}),
|
|
}],
|
|
});
|
|
self.uniform = Some(UniformArena {
|
|
buffer,
|
|
bind_group,
|
|
capacity: need,
|
|
});
|
|
}
|
|
|
|
/// Stellt sicher, dass eine 4x-MSAA-Farbtextur passend zur Ziel-Groesse
|
|
/// existiert; realloziert nur bei Groessenaenderung (analog ensure_depth in 3D).
|
|
fn ensure_msaa(&mut self, device: &wgpu::Device, w: u32, h: u32) {
|
|
let (w, h) = (w.max(1), h.max(1));
|
|
if self.msaa_view.is_some() && self.msaa_size == (w, h) {
|
|
return;
|
|
}
|
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
|
label: Some("msaa.color"),
|
|
size: wgpu::Extent3d {
|
|
width: w,
|
|
height: h,
|
|
depth_or_array_layers: 1,
|
|
},
|
|
mip_level_count: 1,
|
|
sample_count: SAMPLE_COUNT,
|
|
dimension: wgpu::TextureDimension::D2,
|
|
format: self.format,
|
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
|
view_formats: &[],
|
|
});
|
|
self.msaa_view = Some(texture.create_view(&wgpu::TextureViewDescriptor::default()));
|
|
self.msaa_size = (w, h);
|
|
}
|
|
|
|
/// Zeichnet einen Frame in `view` (Surface- oder Offscreen-Textur).
|
|
/// Pan/Zoom kommt ueber `view_box`/`viewport`; nur die Ortho-Matrix aendert sich.
|
|
pub fn render(
|
|
&mut self,
|
|
device: &wgpu::Device,
|
|
queue: &wgpu::Queue,
|
|
view: &wgpu::TextureView,
|
|
view_box: ViewBox,
|
|
viewport: (u32, u32),
|
|
) {
|
|
let (vw, vh) = (viewport.0 as f32, viewport.1 as f32);
|
|
let proj: Mat4 = compute_ortho_matrix(view_box, vw, vh);
|
|
let mm_px = mm_to_device_px(view_box, vw, vh, self.paper_scale_n);
|
|
// Eigene mm->px-Umrechnung fuer Text (siehe `text_scale_n`-Doku): gleiche
|
|
// Formel, aber am Text-Referenz-Massstab statt am Geometrie-Massstab.
|
|
let text_mm_px = mm_to_device_px(view_box, vw, vh, self.text_scale_n);
|
|
// Geraete-px je Bildschirm-Einheit (== meet-Skala) — der Bogen-Shader braucht
|
|
// beides: px_per_screen fuer Radial-/Kappen-/Dash-AA, stroke_scale fuer die
|
|
// Papier-mm-Breite (identisch zu den Linien).
|
|
let px_per_screen = meet_scale(view_box, vw, vh);
|
|
|
|
// Frame-Uniform der Bogen-Pipeline schreiben (ein Block fuer ALLE Boegen).
|
|
queue.write_buffer(
|
|
&self.arc_uniform,
|
|
0,
|
|
bytemuck::bytes_of(&ArcGlobals {
|
|
view_proj: proj,
|
|
viewport_px: [vw, vh],
|
|
px_per_screen,
|
|
stroke_scale: mm_px,
|
|
}),
|
|
);
|
|
|
|
// Alle Globals-Bloecke in MALER-Reihenfolge (`draw_sequence`, interleaved
|
|
// Fuell-/Linien-Batches) sammeln, den Puffer einmal schreiben, danach nur
|
|
// noch dynamisch binden — Reihenfolge identisch zur Draw-Schleife unten.
|
|
let mut blocks: Vec<Globals> = Vec::new();
|
|
if let Some(scene) = &self.scene {
|
|
for &(kind, idx) in &scene.geo.draw_sequence {
|
|
match kind {
|
|
BatchKind::Fill => {
|
|
let b = &scene.geo.fill_batches[idx as usize];
|
|
blocks.push(Globals {
|
|
view_proj: proj,
|
|
color: b.color,
|
|
viewport_px: [vw, vh],
|
|
stroke_px: 0.0,
|
|
stroke_scale: mm_px,
|
|
});
|
|
}
|
|
BatchKind::Line => {
|
|
let b = &scene.geo.line_batches[idx as usize];
|
|
blocks.push(Globals {
|
|
view_proj: proj,
|
|
color: b.color,
|
|
viewport_px: [vw, vh],
|
|
stroke_px: b.stroke_mm,
|
|
// Schraffur-Striche (`width_screen`) sind an die
|
|
// Musterkachel gekoppelt (Bildschirm-Einheiten,
|
|
// zoom-skalierend wie der SVG-`<pattern>`-Strich) —
|
|
// Geraete-px = Breite * meet-Skala statt * mm->px.
|
|
stroke_scale: if b.width_screen { px_per_screen } else { mm_px },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.ensure_uniform_capacity(device, blocks.len() as u64);
|
|
|
|
// ── Textpass vorbereiten (echte Glyphen via Atlas) ────────────────────
|
|
// Pixelgroesse = Papier-mm * mm_px (dieselbe Formel wie die Strich-
|
|
// breiten) -> Text waechst beim Zoomen exakt wie im Browser. Die Anker
|
|
// werden ueber den aspekt-korrigierten viewBox in Fenster-Pixel
|
|
// projiziert; die Ausrichtung nutzt die gemessene Zeilenbreite.
|
|
let has_texts = self
|
|
.scene
|
|
.as_ref()
|
|
.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);
|
|
|
|
// Je sichtbarer Zeile ein geshapter Buffer + Platzierung.
|
|
struct Placed {
|
|
buffer: glyphon::Buffer,
|
|
left: f32,
|
|
top: f32,
|
|
color: glyphon::Color,
|
|
}
|
|
let mut placed: Vec<Placed> = Vec::new();
|
|
if let Some(scene) = &self.scene {
|
|
let attrs = glyphon::Attrs::new()
|
|
.family(glyphon::Family::Name(&family));
|
|
for t in &scene.texts {
|
|
let font_px = t.size_mm * text_mm_px;
|
|
// Unlesbar kleine Glyphen ueberspringen (spart Shaping/Atlas).
|
|
if !(font_px >= 1.0) || t.content.is_empty() {
|
|
continue;
|
|
}
|
|
// Anker (Modell-Meter) -> Fenster-Pixel: erst Bildschirm-Raum
|
|
// (to_screen), dann viewBox-Projektion (meet-Skala).
|
|
let s = to_screen(t.pos);
|
|
let ax = (s[0] - cvb.x) * meet;
|
|
let ay = (s[1] - cvb.y) * meet;
|
|
// Grober Sichtbarkeits-Test VOR dem Shaping (Buffer sparen):
|
|
// grosszuegige Marge, da die Zeilenbreite noch unbekannt ist.
|
|
let margin = font_px * (t.content.len() as f32 + 2.0);
|
|
if ax < -margin || ax > vw + margin || ay < -2.0 * font_px || ay > vh + 2.0 * font_px {
|
|
continue;
|
|
}
|
|
|
|
let mut buffer = glyphon::Buffer::new(
|
|
&mut ts.font_system,
|
|
glyphon::Metrics::new(font_px, font_px * 1.2),
|
|
);
|
|
// Unbegrenzt: eine Zeile, kein Umbruch (Zeilen sind vorgeflacht).
|
|
buffer.set_size(&mut ts.font_system, None, None);
|
|
// Kein Align-Override (None): die Ausrichtung rechnet der
|
|
// Platzierungscode unten selbst ueber die gemessene Zeilenbreite.
|
|
buffer.set_text(
|
|
&mut ts.font_system,
|
|
&t.content,
|
|
&attrs,
|
|
glyphon::Shaping::Advanced,
|
|
None,
|
|
);
|
|
|
|
// Gemessene Breite + Baseline fuer Ausrichtung/Anker.
|
|
let (line_w, baseline) = buffer
|
|
.layout_runs()
|
|
.next()
|
|
.map(|r| (r.line_w, r.line_y))
|
|
.unwrap_or((0.0, font_px * 0.8));
|
|
let left = match t.align {
|
|
TextAlign::Left => ax,
|
|
TextAlign::Center => ax - line_w / 2.0,
|
|
TextAlign::Right => ax - line_w,
|
|
};
|
|
let top = ay - baseline; // Anker-Y ist die BASELINE der Zeile.
|
|
// Praeziser Sichtbarkeits-Test mit gemessener Breite.
|
|
if left > vw || left + line_w < 0.0 || top > vh || top + font_px * 1.4 < 0.0 {
|
|
continue;
|
|
}
|
|
let c = t.color;
|
|
let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
|
placed.push(Placed {
|
|
buffer,
|
|
left,
|
|
top,
|
|
color: glyphon::Color::rgba(to8(c[0]), to8(c[1]), to8(c[2]), to8(c[3])),
|
|
});
|
|
}
|
|
}
|
|
|
|
ts.viewport.update(
|
|
queue,
|
|
glyphon::Resolution {
|
|
width: viewport.0,
|
|
height: viewport.1,
|
|
},
|
|
);
|
|
let bounds = glyphon::TextBounds {
|
|
left: 0,
|
|
top: 0,
|
|
right: viewport.0 as i32,
|
|
bottom: viewport.1 as i32,
|
|
};
|
|
let areas = placed.iter().map(|p| glyphon::TextArea {
|
|
buffer: &p.buffer,
|
|
left: p.left,
|
|
top: p.top,
|
|
scale: 1.0,
|
|
bounds,
|
|
default_color: p.color,
|
|
custom_glyphs: &[],
|
|
});
|
|
if let Err(e) = ts.renderer.prepare(
|
|
device,
|
|
queue,
|
|
&mut ts.font_system,
|
|
&mut ts.atlas,
|
|
&ts.viewport,
|
|
areas,
|
|
&mut ts.swash_cache,
|
|
) {
|
|
eprintln!("render2d Text-prepare fehlgeschlagen: {e:?}");
|
|
}
|
|
// Nicht mehr benoetigte Glyphen aus dem Atlas werfen (haelt ihn klein).
|
|
ts.atlas.trim();
|
|
}
|
|
|
|
// Alle Bloecke in den (auf uniform_stride gepolsterten) Puffer schreiben.
|
|
if let Some(u) = &self.uniform {
|
|
let stride = self.uniform_stride as usize;
|
|
let mut bytes = vec![0u8; stride * blocks.len().max(1)];
|
|
for (i, g) in blocks.iter().enumerate() {
|
|
let off = i * stride;
|
|
bytes[off..off + std::mem::size_of::<Globals>()]
|
|
.copy_from_slice(bytemuck::bytes_of(g));
|
|
}
|
|
if !blocks.is_empty() {
|
|
queue.write_buffer(&u.buffer, 0, &bytes);
|
|
}
|
|
}
|
|
|
|
// In die 4x-MSAA-Textur zeichnen und in die Surface aufloesen (Resolve):
|
|
// das glaettet alle Fuell-/Linienkanten, die Aufrufer bleiben unveraendert.
|
|
self.ensure_msaa(device, viewport.0, viewport.1);
|
|
let msaa_view = self.msaa_view.as_ref().unwrap();
|
|
|
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
|
label: Some("2d.encoder"),
|
|
});
|
|
{
|
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
|
label: Some("2d.pass"),
|
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
|
view: msaa_view,
|
|
// Ziel ist eine 2D-Textur (kein 3D-Volumen) -> kein Depth-Slice.
|
|
depth_slice: None,
|
|
resolve_target: Some(view),
|
|
ops: wgpu::Operations {
|
|
load: wgpu::LoadOp::Clear(self.clear_color),
|
|
store: wgpu::StoreOp::Store,
|
|
},
|
|
})],
|
|
depth_stencil_attachment: None,
|
|
timestamp_writes: None,
|
|
occlusion_query_set: None,
|
|
multiview_mask: None,
|
|
});
|
|
|
|
if let (Some(scene), Some(u)) = (&self.scene, &self.uniform) {
|
|
let stride = self.uniform_stride as u32;
|
|
|
|
// Geometrie in MALER-Reihenfolge (`draw_sequence`): Fuell- und
|
|
// Linien-Batches interleaved, Pipeline-/Puffer-Wechsel nur beim
|
|
// Wechsel der Batch-Art. Nur so deckt eine spaetere Fuellung
|
|
// einen frueheren Strich ab (SVG-Paritaet, Primitive-Reihenfolge).
|
|
let mut bound: Option<BatchKind> = None;
|
|
for (block, &(kind, idx)) in scene.geo.draw_sequence.iter().enumerate() {
|
|
match kind {
|
|
BatchKind::Fill => {
|
|
let (Some(vbo), Some(ibo)) = (&scene.fill_vbo, &scene.fill_ibo)
|
|
else {
|
|
continue;
|
|
};
|
|
if bound != Some(BatchKind::Fill) {
|
|
pass.set_pipeline(&self.fill_pipeline);
|
|
pass.set_vertex_buffer(0, vbo.slice(..));
|
|
pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32);
|
|
bound = Some(BatchKind::Fill);
|
|
}
|
|
let b = &scene.geo.fill_batches[idx as usize];
|
|
pass.set_bind_group(0, &u.bind_group, &[block as u32 * stride]);
|
|
let start = b.start_index;
|
|
pass.draw_indexed(start..start + b.index_count, 0, 0..1);
|
|
}
|
|
BatchKind::Line => {
|
|
let (Some(vbo), Some(ibo)) = (&scene.line_vbo, &scene.line_ibo)
|
|
else {
|
|
continue;
|
|
};
|
|
if bound != Some(BatchKind::Line) {
|
|
pass.set_pipeline(&self.line_pipeline);
|
|
pass.set_vertex_buffer(0, vbo.slice(..));
|
|
pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32);
|
|
bound = Some(BatchKind::Line);
|
|
}
|
|
let b = &scene.geo.line_batches[idx as usize];
|
|
pass.set_bind_group(0, &u.bind_group, &[block as u32 * stride]);
|
|
let start = b.start_index;
|
|
pass.draw_indexed(start..start + b.index_count, 0, 0..1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) Analytische Boegen: eigene Pipeline, EIN Frame-Uniform, je Bogen
|
|
// EINE Instanz (6 Vertices, Quad aus vertex_index). Mathematisch
|
|
// exakt rund per SDF — nach den Linien, damit der Schwenkbogen ueber
|
|
// dem Tuerblatt liegt.
|
|
if let Some(scene) = &self.scene {
|
|
if let Some(arc_vbo) = &scene.arc_vbo {
|
|
if scene.arc_count > 0 {
|
|
pass.set_pipeline(&self.arc_pipeline);
|
|
pass.set_bind_group(0, &self.arc_bind_group, &[]);
|
|
pass.set_vertex_buffer(0, arc_vbo.slice(..));
|
|
pass.draw(0..6, 0..scene.arc_count);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4) Text ZUOBERST im selben MSAA-Pass (der TextRenderer wurde mit
|
|
// identischem MultisampleState erstellt, siehe ensure_text).
|
|
if let Some(ts) = &self.text {
|
|
if let Err(e) = ts.renderer.render(&ts.atlas, &ts.viewport, &mut pass) {
|
|
eprintln!("render2d Text-render fehlgeschlagen: {e:?}");
|
|
}
|
|
}
|
|
}
|
|
queue.submit(std::iter::once(encoder.finish()));
|
|
}
|
|
}
|