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 7b9f72e79f
commit 6eead6d493
8 changed files with 588 additions and 172 deletions
+161
View File
@@ -101,3 +101,164 @@ fn fs_main() -> @location(0) vec4<f32> {
return globals.color;
}
"#;
/// Analytische BOGEN-Pipeline: rendert einen Kreisbogen mathematisch exakt (SDF im
/// Fragment-Shader) statt als Segment-Kette — bei jedem Zoom ein "richtiger" Kreis,
/// nie ein Vieleck.
///
/// Ein Bogen = EINE Instanz + EIN Quad (6 Vertices, aus `vertex_index` erzeugt).
/// Das Quad ist die Bounding-Box des Bogens im BILDSCHIRM-Raum, im Vertex-Shader
/// aus der aktuellen px-Skala aufgespannt (Radius + halbe Strichbreite + AA-Rand),
/// sodass es bei jedem Zoom passt — OHNE Neu-Tessellierung.
///
/// Instanz-Layout (alles zoom-invariant, in Bildschirm-Raum bzw. Modell-Metern):
/// @location(0) center : vec2 Mittelpunkt (Bildschirm-Raum)
/// @location(1) geom : vec4 (r_screen, a0, sweep, r_model)
/// @location(2) color : vec4 RGBA
/// @location(3) wdash : vec4 (width_mm, dash_total[m], dash_count, _pad)
/// @location(4) dash : vec4 bis zu MAX_ARC_DASH(=4) An/Aus-Laengen (Modell-m)
///
/// Frame-Uniform (`ArcGlobals`): view_proj, viewport_px, px_per_screen (== meet-
/// Skala, Geraete-px je Bildschirm-Einheit) und stroke_scale (mm -> Geraete-px,
/// dieselbe Formel wie die Linien, `ortho::mm_to_device_px`).
///
/// Fragment: `d = abs(length(p-center) - r)` gibt den Ring-Abstand; die Kante wird
/// analytisch per `smoothstep` (~device-px) geglaettet (MSAA glaettet zusaetzlich).
/// Der Winkel wird gegen [0, sweep] geklemmt (beide Sweep-Vorzeichen, sauberer
/// Wrap) mit Butt-Cap an den Enden. Dash: Bogenlaenge s = theta_rel * r_model
/// (Modell-Meter!) modulo Muster, weicher An/Aus-Uebergang.
pub const ARC_WGSL: &str = r#"
struct ArcGlobals {
view_proj : mat4x4<f32>,
viewport_px : vec2<f32>,
px_per_screen : f32,
stroke_scale : f32,
};
@group(0) @binding(0) var<uniform> g : ArcGlobals;
const PI : f32 = 3.14159265358979;
// Bildschirm-Einheiten je Modell-Meter (== tessellate::PX_PER_M). Fest, weil die
// Instanz-Geometrie bereits in Bildschirm-Raum vorliegt (to_screen skaliert *90).
const PX_PER_M_2D : f32 = 90.0;
struct VsOut {
@builtin(position) pos : vec4<f32>,
@location(0) frag : vec2<f32>,
@location(1) @interpolate(flat) center : vec2<f32>,
@location(2) @interpolate(flat) geom : vec4<f32>,
@location(3) @interpolate(flat) color : vec4<f32>,
@location(4) @interpolate(flat) wdash : vec4<f32>,
@location(5) @interpolate(flat) dash : vec4<f32>,
};
@vertex
fn vs_main(
@builtin(vertex_index) vidx : u32,
@location(0) center : vec2<f32>,
@location(1) geom : vec4<f32>,
@location(2) color : vec4<f32>,
@location(3) wdash : vec4<f32>,
@location(4) dash : vec4<f32>,
) -> VsOut {
// Zwei Dreiecke, Ecken in {-1,+1}^2.
var corners = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0), vec2<f32>( 1.0, -1.0), vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, -1.0), vec2<f32>( 1.0, 1.0), vec2<f32>(-1.0, 1.0),
);
let corner = corners[vidx];
let r_s = geom.x;
// Echte Papierbreite (Geraete-px) -> zurueck in Bildschirm-Einheiten fuer die
// Quad-Groesse; plus AA-Rand (~2 px). px_per_screen gegen 0 sichern.
let pps = max(g.px_per_screen, 1e-6);
let width_px = max(0.6, wdash.x * g.stroke_scale);
let half_w_screen = 0.5 * width_px / pps;
let aa_screen = 2.0 / pps;
let ext = r_s + half_w_screen + aa_screen;
let p_screen = center + corner * ext;
var out : VsOut;
out.pos = g.view_proj * vec4<f32>(p_screen, 0.0, 1.0);
out.frag = p_screen;
out.center = center;
out.geom = geom;
out.color = color;
out.wdash = wdash;
out.dash = dash;
return out;
}
@fragment
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
let center = in.center;
let r_s = in.geom.x;
let a0 = in.geom.y;
let sweep = in.geom.z;
let r_m = in.geom.w;
let width_mm = in.wdash.x;
let dash_total = in.wdash.y;
let dash_count = i32(in.wdash.z + 0.5);
let pps = max(g.px_per_screen, 1e-6);
let rel = in.frag - center;
let dist = length(rel);
// 1) Radiale Kante (Strichbreite quer zum Bogen), analytisch antialiased.
let d_ring_px = abs(dist - r_s) * pps;
let half_w_px = 0.5 * max(0.6, width_mm * g.stroke_scale);
let cov_radial = 1.0 - smoothstep(half_w_px - 0.6, half_w_px + 0.6, d_ring_px);
// 2) Winkel-Clamp auf [min(0,sweep), max(0,sweep)] mit Butt-Cap an den Enden.
let theta = atan2(rel.y, rel.x);
var da = theta - a0;
da = da - 2.0 * PI * round(da / (2.0 * PI)); // Wrap nach (-pi, pi]
let lo = min(0.0, sweep);
let hi = max(0.0, sweep);
var sd : f32;
if (da < lo) {
sd = lo - da;
} else if (da > hi) {
sd = da - hi;
} else {
sd = -min(da - lo, hi - da);
}
let cap_px = sd * r_s * pps; // signierter Abstand zur Kappe (Geraete-px)
let cov_cap = 1.0 - smoothstep(-0.5, 0.5, cap_px);
// 3) Dash: Bogenlaenge ab a0 (Modell-Meter) modulo Muster, weicher Uebergang.
var cov_dash = 1.0;
if (dash_count > 0 && dash_total > 1e-9) {
var progress = da;
if (sweep < 0.0) { progress = -da; }
progress = max(progress, 0.0);
let s_model = progress * r_m;
let m = s_model - dash_total * floor(s_model / dash_total);
var cyc = array<f32, 4>(in.dash.x, in.dash.y, in.dash.z, in.dash.w);
var acc = 0.0;
var cur_on = true;
var edge = dash_total;
for (var i = 0; i < 4; i = i + 1) {
if (i >= dash_count) { break; }
let seg = cyc[i];
if (m >= acc && m < acc + seg) {
cur_on = (i % 2) == 0; // gerade Segmente = "an"
edge = min(m - acc, acc + seg - m); // Abstand zur naechsten Grenze
}
acc = acc + seg;
}
let px_per_m = PX_PER_M_2D * pps;
let edge_px = edge * px_per_m;
// Signierter Abstand: innen "an" positiv, innen "aus" negativ.
let sdist = select(-edge_px, edge_px, cur_on);
cov_dash = smoothstep(-0.5, 0.5, sdist);
}
let a = in.color.a * cov_radial * cov_cap * cov_dash;
if (a <= 0.002) {
discard;
}
return vec4<f32>(in.color.rgb, a);
}
"#;