2D-Bögen analytisch: exakter Kreis-Shader statt Segment-Tessellierung

Bögen im nativen 2D-wgpu-Renderer werden nicht mehr zoomabhängig in Segmente
zerlegt, sondern per SDF-Fragment-Shader (ARC_WGSL) mathematisch exakt rund
gerendert — bei jeder Zoomstufe ein echter Kreis, kein Vieleck, ohne
Neu-Tessellierung.

- compile_scene sammelt je Bogen EINE analytische Instanz (ArcInstanceData,
  Bildschirm-Raum-Parameter + Dash in Modell-Metern), zoom-invariant.
- Eigene Arc-Pipeline (ein Frame-Uniform, Quad je Instanz aus vertex_index):
  radiale Kante, Butt-Cap-Winkelclamp (beide Sweep-Vorzeichen) und Dash
  (Bogenlänge modulo Muster) analytisch antialiased; Strichbreite mit derselben
  mm->px-Formel wie die Linien.
- tessellate_arc + Zoom-Retessellierungs-Cache (last_scene/arc_px_per_m/
  maybe_retessellate) entfernt; upload_scene ohne px_per_m.
- Tests auf die neue Semantik umgeschrieben (Winkel-Parität, Bounding-Box,
  Dash-Mapping), ARC_WGSL per naga validiert.
This commit is contained in:
2026-07-02 22:07:18 +02:00
parent 926dedca40
commit 2a2b3b294c
8 changed files with 588 additions and 172 deletions
+197 -41
View File
@@ -18,8 +18,8 @@ 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::{FILL_WGSL, LINE_WGSL};
use crate::tessellate::{compile_scene_scaled, to_screen, GpuGeometry, PX_PER_M};
use crate::shaders::{ARC_WGSL, FILL_WGSL, LINE_WGSL};
use crate::tessellate::{compile_scene, to_screen, ArcInstanceData, GpuGeometry, MAX_ARC_DASH};
use crate::types::{Scene, Text, TextAlign, ViewBox};
/// MSAA-Faktor: 4x Multisampling fuer glatte Linien-/Fuellkanten (wie im Browser).
@@ -49,6 +49,49 @@ impl Default for Globals {
}
}
/// 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 {
@@ -63,6 +106,10 @@ struct SceneBuffers {
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
@@ -93,16 +140,16 @@ 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>,
/// Die zuletzt hochgeladene Szene (fuer Re-Tessellierung bei Zoomaenderung,
/// siehe `maybe_retessellate` — nur relevant, wenn die Szene Boegen enthaelt).
last_scene: Option<Scene>,
/// Geraete-px je Modell-Meter, mit dem `last_scene` zuletzt tessellliert wurde.
arc_px_per_m: f32,
/// Farbformat der Ziel-Surface (auch Format der MSAA-Textur).
format: wgpu::TextureFormat,
/// Multisample-Farbtextur (4x), lazily an die Ziel-Groesse gebunden.
@@ -242,6 +289,91 @@ impl Renderer {
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: &[&arc_bind_group_layout],
push_constant_ranges: &[],
});
// 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: "vs_main",
buffers: &[arc_instance_layout],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &arc_module,
entry_point: "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: 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));
@@ -250,11 +382,12 @@ impl Renderer {
fill_pipeline,
line_pipeline,
bind_group_layout,
arc_pipeline,
arc_uniform,
arc_bind_group,
uniform_stride,
uniform: None,
scene: None,
last_scene: None,
arc_px_per_m: PX_PER_M,
format: color_format,
msaa_view: None,
msaa_size: (0, 0),
@@ -270,12 +403,13 @@ impl Renderer {
}
}
/// Tessellliert eine Szene und laedt die Puffer hoch. `px_per_m` sind die
/// aktuellen Geraete-px je Modell-Meter (treibt nur die Bogen-Adaptivitaet,
/// siehe `tessellate::tessellate_arc`); wird gemerkt, damit `render` bei
/// Zoomaenderung automatisch neu tessellliert (`maybe_retessellate`).
pub fn upload_scene(&mut self, device: &wgpu::Device, scene: &Scene, px_per_m: f32) {
let geo = compile_scene_scaled(scene, px_per_m);
/// 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() {
@@ -298,37 +432,30 @@ impl Renderer {
}))
};
// 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(),
});
self.last_scene = Some(scene.clone());
self.arc_px_per_m = px_per_m;
}
/// Tessellliert die zuletzt hochgeladene Szene neu, wenn sich der Zoom seit
/// dem letzten Upload um mehr als den Faktor 1.3 veraendert hat UND die
/// Szene ueberhaupt Boegen enthaelt (bei anderen Primitiven ist Zoom-
/// invariant, kein Re-Tessellieren noetig). Haelt Boegen bei jeder
/// Zoomstufe glatt, ohne jeden Frame neu zu tessellieren (Szenen sind
/// klein, das Re-Tessellieren selbst ist billig — nur nicht JEDEN Frame).
fn maybe_retessellate(&mut self, device: &wgpu::Device, view_box: ViewBox, viewport: (u32, u32)) {
let Some(scene) = &self.last_scene else {
return;
};
if scene.arcs.is_empty() {
return;
}
let px_per_m = PX_PER_M * meet_scale(view_box, viewport.0 as f32, viewport.1 as f32);
let ratio = px_per_m / self.arc_px_per_m.max(1e-6);
if ratio > 1.3 || ratio < 1.0 / 1.3 {
let scene = scene.clone();
self.upload_scene(device, &scene, px_per_m);
}
}
/// Erstellt den Glyphen-Textpass beim ersten Bedarf (FontSystem laedt die
@@ -431,11 +558,25 @@ impl Renderer {
view_box: ViewBox,
viewport: (u32, u32),
) {
self.maybe_retessellate(device, view_box, viewport);
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);
// 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 der Reihenfolge nach (erst Fuell-, dann Linien-Batches)
// sammeln, den Puffer einmal schreiben, danach nur noch dynamisch binden.
@@ -650,7 +791,22 @@ impl Renderer {
}
}
// 3) Text ZUOBERST im selben MSAA-Pass (der TextRenderer wurde mit
// 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) {