fix: Glatte Strichkanten (Issue #7)

Die Zeichenfläche wird mit 4× Multisampling gezeichnet und in die
Fensterfläche aufgelöst; Strichkanten, Formen und Bildränder sind damit
kantengeglättet (ohne Unterstützung des Formats wie bisher). Runde
Strichenden und Punkte sind feiner unterteilt (10 statt 4 Schritte je
Viertelkreis, 20 statt 8 für Punkte) und wirken auch stark vergrößert rund.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 11:54:12 +02:00
co-authored by Claude Opus 5.5
parent 27f84ff1b1
commit 47300f2ca2
3 changed files with 72 additions and 8 deletions
+64 -5
View File
@@ -160,6 +160,9 @@ pub struct CanvasState {
pub surface: wgpu::Surface<'static>, pub surface: wgpu::Surface<'static>,
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung) /// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
adapter: wgpu::Adapter, adapter: wgpu::Adapter,
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
msaa_samples: u32,
msaa_view: Option<wgpu::TextureView>,
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben /// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
presentation_warned: bool, presentation_warned: bool,
pub device: wgpu::Device, pub device: wgpu::Device,
@@ -326,6 +329,18 @@ impl CanvasState {
}; };
surface.configure(&device, &config); surface.configure(&device, &config);
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
let msaa_samples = if adapter
.get_texture_format_features(config.format)
.flags
.sample_count_supported(MSAA_SAMPLES)
{
MSAA_SAMPLES
} else {
1
};
let msaa_view = create_msaa_view(&device, &config, msaa_samples);
// 5. Shader & Bind Group Layout // 5. Shader & Bind Group Layout
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Ink WGSL Shader"), label: Some("Ink WGSL Shader"),
@@ -406,7 +421,10 @@ impl CanvasState {
conservative: false, conservative: false,
}, },
depth_stencil: None, depth_stencil: None,
multisample: wgpu::MultisampleState::default(), multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
},
multiview: None, multiview: None,
}); });
@@ -438,7 +456,10 @@ impl CanvasState {
conservative: false, conservative: false,
}, },
depth_stencil: None, depth_stencil: None,
multisample: wgpu::MultisampleState::default(), multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
},
multiview: None, multiview: None,
}); });
@@ -487,6 +508,7 @@ impl CanvasState {
&shader, &shader,
&bind_group_layout, &bind_group_layout,
config.format, config.format,
msaa_samples,
); );
// 8. egui Renderer für das schwebende UI initialisieren // 8. egui Renderer für das schwebende UI initialisieren
@@ -495,6 +517,8 @@ impl CanvasState {
Ok(Self { Ok(Self {
surface, surface,
adapter, adapter,
msaa_samples,
msaa_view,
presentation_warned: false, presentation_warned: false,
device, device,
queue, queue,
@@ -643,6 +667,7 @@ impl CanvasState {
} }
} }
self.surface.configure(&self.device, &self.config); self.surface.configure(&self.device, &self.config);
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
self.recalculate_viewport(); self.recalculate_viewport();
} }
} }
@@ -1264,12 +1289,17 @@ impl CanvasState {
{ {
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Ink Canvas Render Pass"), label: Some("Ink Canvas Render Pass"),
// Mit Multisampling ins Zwischenbild zeichnen und in die Fensterfläche auflösen
color_attachments: &[Some(wgpu::RenderPassColorAttachment { color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view, view: self.msaa_view.as_ref().unwrap_or(&view),
resolve_target: None, resolve_target: self.msaa_view.as_ref().map(|_| &view),
ops: wgpu::Operations { ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
store: wgpu::StoreOp::Store, store: if self.msaa_view.is_some() {
wgpu::StoreOp::Discard
} else {
wgpu::StoreOp::Store
},
}, },
})], })],
depth_stencil_attachment: None, depth_stencil_attachment: None,
@@ -1376,6 +1406,35 @@ impl CanvasState {
} }
} }
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
const MSAA_SAMPLES: u32 = 4;
/// Zwischenbild für das Multisampling in Fenstergröße; `None` ohne Multisampling.
fn create_msaa_view(
device: &wgpu::Device,
config: &wgpu::SurfaceConfiguration,
samples: u32,
) -> Option<wgpu::TextureView> {
(samples > 1).then(|| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("MSAA Color Target"),
size: wgpu::Extent3d {
width: config.width.max(1),
height: config.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: samples,
dimension: wgpu::TextureDimension::D2,
format: config.format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&wgpu::TextureViewDescriptor::default())
})
}
/// Zu rasterndes Textfeld. /// Zu rasterndes Textfeld.
struct TextSpec { struct TextSpec {
text: crate::text::TextBox, text: crate::text::TextBox,
+5 -1
View File
@@ -102,6 +102,7 @@ impl ImageLayer {
shader: &wgpu::ShaderModule, shader: &wgpu::ShaderModule,
camera_layout: &wgpu::BindGroupLayout, camera_layout: &wgpu::BindGroupLayout,
format: wgpu::TextureFormat, format: wgpu::TextureFormat,
sample_count: u32,
) -> Self { ) -> Self {
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Image Texture Bind Group Layout"), label: Some("Image Texture Bind Group Layout"),
@@ -151,7 +152,10 @@ impl ImageLayer {
..Default::default() ..Default::default()
}, },
depth_stencil: None, depth_stencil: None,
multisample: wgpu::MultisampleState::default(), multisample: wgpu::MultisampleState {
count: sample_count,
..Default::default()
},
multiview: None, multiview: None,
}); });
let sampler = device.create_sampler(&wgpu::SamplerDescriptor { let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+3 -2
View File
@@ -274,7 +274,7 @@ pub fn generate_triangle_strip(
// Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad // Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad
let pt = control_points[0]; let pt = control_points[0];
let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius); let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius);
let segments = 8; let segments = 20;
let mut vertices = Vec::with_capacity((segments + 1) * 2); let mut vertices = Vec::with_capacity((segments + 1) * 2);
for i in 0..=segments { for i in 0..=segments {
@@ -599,7 +599,8 @@ pub fn generate_finalized_triangle_strip(
return Vec::new(); return Vec::new();
} }
const CAP_STEPS: usize = 4; // Runde Kappen fein genug unterteilt, dass sie auch stark vergrößert rund wirken
const CAP_STEPS: usize = 10;
let mut vertices = Vec::new(); let mut vertices = Vec::new();
// 3. Runde Start-Kappe vor dem ersten Run // 3. Runde Start-Kappe vor dem ersten Run