fix: Karos und Linien in gleichmäßigen Abständen (Issue #10)

Karo, Linien und Punkte waren Dreiecke mit 0,15 mm breiten Linien –
schmaler als ein Bildschirmpixel. Je nach Lage auf dem Pixelraster
erschienen sie stärker, schwächer oder versetzt, die Abstände wirkten
unregelmäßig. Jetzt berechnet ein Fragment-Shader das Muster für jedes
Pixel aus der Seitenkoordinate: Linien und Punkte sind mindestens ein
Pixel breit, kantengeglättet und gleichmäßig; bei sehr kleinen Abständen
(weit herausgezoomt) blendet das Muster aus. Gemessen: Karo-Abstand
13,5 ± 0,04 Pixel bei gleicher Linienstärke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 12:21:16 +02:00
co-authored by Claude Opus 5.5
parent ee5257d744
commit 80d319099f
3 changed files with 226 additions and 155 deletions
+73 -125
View File
@@ -165,115 +165,70 @@ pub fn generate_paper_quad(color: [f32; 4], size: glam::Vec2) -> Vec<InkVertex>
] ]
} }
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu. /// Oberer und unterer Rand ohne Linien bei liniertem Papier (15 mm).
#[inline] pub const LINED_MARGIN: f32 = 150.0;
fn push_quad(
vertices: &mut Vec<InkVertex>, /// Ecke des Musterrechtecks: Das Muster selbst berechnet der Fragment-Shader (`fs_pattern`)
color: [f32; 4], /// für jedes Bildschirmpixel. So liegen Linien und Punkte in jedem Zoom exakt gleichmäßig,
x_min: f32, /// sind mindestens ein Pixel breit und kantengeglättet – vorher gerasterte Haarlinien
y_min: f32, /// fielen je nach Lage auf das Pixelraster und wirkten unregelmäßig.
x_max: f32, #[repr(C)]
y_max: f32, #[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
) { pub struct PatternVertex {
vertices.push(InkVertex::new(x_min, y_min, color)); /// Seitenkoordinate
vertices.push(InkVertex::new(x_max, y_min, color)); pub position: [f32; 2],
vertices.push(InkVertex::new(x_min, y_max, color)); pub color: [f32; 4],
vertices.push(InkVertex::new(x_min, y_max, color)); /// Art (1 = Karo, 2 = Linien, 3 = Punkte), Abstand, Linienstärke, Punktgröße
vertices.push(InkVertex::new(x_max, y_min, color)); pub params: [f32; 4],
vertices.push(InkVertex::new(x_max, y_max, color)); /// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien, ungenutzt
pub bounds: [f32; 4],
} }
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color` auf impl PatternVertex {
/// einem Blatt der Größe `size`. const ATTRIBUTES: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
pub fn generate_background_mesh( 0 => Float32x2,
1 => Float32x4,
2 => Float32x4,
3 => Float32x4
];
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<PatternVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBUTES,
}
}
}
/// Rechteck über das ganze Blatt der Größe `size`, auf dem der Shader das Muster
/// `background` in `color` zeichnet; leer bei blankem Papier.
pub fn generate_pattern_quad(
background: PageBackground, background: PageBackground,
color: [f32; 4], color: [f32; 4],
size: glam::Vec2, size: glam::Vec2,
) -> Vec<InkVertex> { ) -> Vec<PatternVertex> {
let (page_width, page_height) = (size.x, size.y); let params = match background {
match background { PageBackground::Blank => return Vec::new(),
PageBackground::Blank => Vec::new(), PageBackground::Grid5mm => [1.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
PageBackground::Grid5mm => { PageBackground::Lined => [2.0, LINED_SPACING, LINE_THICKNESS, DOT_SIZE],
let mut vertices = Vec::with_capacity(1200); PageBackground::DotGrid => [3.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
let half_t = LINE_THICKNESS / 2.0; };
let bounds = [size.x, size.y, LINED_MARGIN, 0.0];
// Vertikale Linien alle 5 mm let corner = |x: f32, y: f32| PatternVertex {
let mut x = GRID_5MM_SPACING; position: [x, y],
while x < page_width {
push_quad(
&mut vertices,
color, color,
x - half_t, params,
0.0, bounds,
x + half_t, };
page_height, vec![
); corner(0.0, 0.0),
x += GRID_5MM_SPACING; corner(size.x, 0.0),
} corner(0.0, size.y),
corner(0.0, size.y),
// Horizontale Linien alle 5 mm corner(size.x, 0.0),
let mut y = GRID_5MM_SPACING; corner(size.x, size.y),
while y < page_height { ]
push_quad(
&mut vertices,
color,
0.0,
y - half_t,
page_width,
y + half_t,
);
y += GRID_5MM_SPACING;
}
vertices
}
PageBackground::Lined => {
let mut vertices = Vec::with_capacity(600);
let half_t = LINE_THICKNESS / 2.0;
// Oberer und unterer Randabstand (z. B. 15 mm)
let margin_top = 150.0;
let margin_bottom = 150.0;
let mut y = margin_top;
while y <= page_height - margin_bottom {
push_quad(
&mut vertices,
color,
0.0,
y - half_t,
page_width,
y + half_t,
);
y += LINED_SPACING;
}
vertices
}
PageBackground::DotGrid => {
let mut vertices = Vec::with_capacity(15000);
let half_d = DOT_SIZE / 2.0;
let mut y = GRID_5MM_SPACING;
while y < page_height {
let mut x = GRID_5MM_SPACING;
while x < page_width {
push_quad(
&mut vertices,
color,
x - half_d,
y - half_d,
x + half_d,
y + half_d,
);
x += GRID_5MM_SPACING;
}
y += GRID_5MM_SPACING;
}
vertices
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -323,32 +278,25 @@ mod tests {
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0]; const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
#[test] #[test]
fn test_background_meshes() { fn test_pattern_quads() {
let blank = generate_background_mesh(PageBackground::Blank, PATTERN, A4_SIZE); assert!(generate_pattern_quad(PageBackground::Blank, PATTERN, A4_SIZE).is_empty());
assert!(blank.is_empty()); for (background, kind) in [
(PageBackground::Grid5mm, 1.0),
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN, A4_SIZE); (PageBackground::Lined, 2.0),
assert!(!grid.is_empty()); (PageBackground::DotGrid, 3.0),
assert_eq!( ] {
grid.len() % 6, let quad = generate_pattern_quad(background, PATTERN, A4_SIZE);
0, assert_eq!(quad.len(), 6);
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)" assert!(quad
); .iter()
.all(|v| v.params[0] == kind && v.color == PATTERN));
let lined = generate_background_mesh(PageBackground::Lined, PATTERN, A4_SIZE); assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
assert!(!lined.is_empty()); assert_eq!(quad[0].bounds[..2], [PAGE_WIDTH, PAGE_HEIGHT]);
assert_eq!(lined.len() % 6, 0); }
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN, A4_SIZE);
assert!(!dots.is_empty());
assert_eq!(dots.len() % 6, 0);
} }
#[test] #[test]
fn test_background_mesh_uses_theme_color() { fn test_background_mesh_uses_theme_color() {
let dark = PaperTheme::Black.pattern_color();
let mesh = generate_background_mesh(PageBackground::Lined, dark, A4_SIZE);
assert!(mesh.iter().all(|v| v.color == dark));
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE); let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
assert!(quad assert!(quad
.iter() .iter()
+71 -29
View File
@@ -12,7 +12,9 @@ use std::sync::Arc;
use winit::dpi::PhysicalSize; use winit::dpi::PhysicalSize;
use winit::window::Window; use winit::window::Window;
use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme, PatternColor}; use crate::background::{
generate_paper_quad, generate_pattern_quad, PaperTheme, PatternColor, PatternVertex,
};
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample}; use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
use crate::filter::OneEuroFilter; use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig}; use crate::spline::{self, ControlPoint, SplineConfig};
@@ -150,8 +152,9 @@ struct NeighborPage {
offset: glam::Vec2, offset: glam::Vec2,
camera_buffer: wgpu::Buffer, camera_buffer: wgpu::Buffer,
camera_bind_group: wgpu::BindGroup, camera_bind_group: wgpu::BindGroup,
background_buffer: Option<wgpu::Buffer>, /// Papier (Dreiecke) und Muster (Rechteck für `fs_pattern`)
background_vertex_count: u32, paper_buffer: wgpu::Buffer,
pattern_buffer: Option<wgpu::Buffer>,
vertex_buffer: Option<wgpu::Buffer>, vertex_buffer: Option<wgpu::Buffer>,
stroke_ranges: Vec<Range<u32>>, stroke_ranges: Vec<Range<u32>>,
} }
@@ -187,9 +190,10 @@ pub struct CanvasState {
// Statische Hintergrund-Geometrie // Statische Hintergrund-Geometrie
pub paper_buffer: wgpu::Buffer, pub paper_buffer: wgpu::Buffer,
/// Musterrechteck der aktuellen Seite (6 Ecken, leer bei blankem Papier)
pub background_buffer: wgpu::Buffer, pub background_buffer: wgpu::Buffer,
pub background_vertex_capacity: usize,
pub background_vertex_count: u32, pub background_vertex_count: u32,
pattern_pipeline: wgpu::RenderPipeline,
pub current_background: PageBackground, pub current_background: PageBackground,
/// Papierfarbe der Anzeige /// Papierfarbe der Anzeige
pub paper_theme: PaperTheme, pub paper_theme: PaperTheme,
@@ -246,7 +250,6 @@ pub struct CanvasState {
impl CanvasState { impl CanvasState {
const INITIAL_LIVE_CAPACITY: usize = 4096; const INITIAL_LIVE_CAPACITY: usize = 4096;
const INITIAL_COMMITTED_CAPACITY: usize = 65536; const INITIAL_COMMITTED_CAPACITY: usize = 65536;
const INITIAL_BACKGROUND_CAPACITY: usize = 16384;
pub const VIEWPORT_MARGIN: f32 = 24.0; pub const VIEWPORT_MARGIN: f32 = 24.0;
/// Initialisiert den CanvasState für das übergebene Winit-Fenster. /// Initialisiert den CanvasState für das übergebene Winit-Fenster.
@@ -475,19 +478,46 @@ impl CanvasState {
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad)); queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
let current_background = PageBackground::Grid5mm; let current_background = PageBackground::Grid5mm;
let bg_mesh = generate_background_mesh( let bg_mesh = generate_pattern_quad(
current_background, current_background,
paper_theme.pattern_color(), paper_theme.pattern_color(),
crate::document::A4_SIZE, crate::document::A4_SIZE,
); );
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor { let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Background Mesh Buffer"), label: Some("Pattern Quad Buffer"),
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64, size: (6 * std::mem::size_of::<PatternVertex>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false, mapped_at_creation: false,
}); });
queue.write_buffer(&background_buffer, 0, bytemuck::cast_slice(&bg_mesh)); queue.write_buffer(&background_buffer, 0, bytemuck::cast_slice(&bg_mesh));
// Karo, Linien und Punkte: pro Pixel im Shader berechnet
let pattern_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Pattern Pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_pattern",
buffers: &[PatternVertex::desc()],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_pattern",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
},
multiview: None,
});
// 7. GPU-Puffer für Live- und Committed-Geometrie // 7. GPU-Puffer für Live- und Committed-Geometrie
let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Live Stroke Vertex Buffer"), label: Some("Live Stroke Vertex Buffer"),
@@ -535,8 +565,8 @@ impl CanvasState {
current_stroke_width_mm: 0.5, current_stroke_width_mm: 0.5,
paper_buffer, paper_buffer,
background_buffer, background_buffer,
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
background_vertex_count: bg_mesh.len() as u32, background_vertex_count: bg_mesh.len() as u32,
pattern_pipeline,
current_background, current_background,
paper_theme, paper_theme,
adapt_ink: false, adapt_ink: false,
@@ -838,23 +868,12 @@ impl CanvasState {
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU. /// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
fn upload_background_mesh(&mut self) { fn upload_background_mesh(&mut self) {
let mesh = generate_background_mesh( let mesh = generate_pattern_quad(
self.current_background, self.current_background,
self.pattern.color(self.paper_theme), self.pattern.color(self.paper_theme),
self.page_size, self.page_size,
); );
self.background_vertex_count = mesh.len() as u32; self.background_vertex_count = mesh.len() as u32;
if mesh.len() > self.background_vertex_capacity {
self.background_vertex_capacity = mesh.len() * 2;
self.background_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Background Mesh Buffer (Reallocated)"),
size: (self.background_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
}
if !mesh.is_empty() { if !mesh.is_empty() {
self.queue self.queue
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh)); .write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
@@ -962,20 +981,39 @@ impl CanvasState {
buffer buffer
}) })
}; };
// Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein) // Eigenes Papier und Muster (Nachbarseiten können anders groß sein)
let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size); let paper = generate_paper_quad(self.paper_theme.paper_color(), page.size);
background.extend(generate_background_mesh( let pattern = generate_pattern_quad(
page.background, page.background,
self.pattern.color(self.paper_theme), self.pattern.color(self.paper_theme),
page.size, page.size,
)); );
let paper_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Neighbor Paper Buffer"),
size: std::mem::size_of_val(paper.as_slice()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.queue
.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper));
let pattern_buffer = (!pattern.is_empty()).then(|| {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Neighbor Pattern Buffer"),
size: std::mem::size_of_val(pattern.as_slice()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.queue
.write_buffer(&buffer, 0, bytemuck::cast_slice(&pattern));
buffer
});
NeighborPage { NeighborPage {
fingerprint, fingerprint,
offset: glam::Vec2::ZERO, offset: glam::Vec2::ZERO,
camera_buffer, camera_buffer,
camera_bind_group, camera_bind_group,
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background), paper_buffer,
background_vertex_count: background.len() as u32, pattern_buffer,
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache), vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
stroke_ranges: page.stroke_ranges.clone(), stroke_ranges: page.stroke_ranges.clone(),
} }
@@ -1311,9 +1349,12 @@ impl CanvasState {
for (i, neighbor) in self.neighbors.iter().enumerate() { for (i, neighbor) in self.neighbors.iter().enumerate() {
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]); render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
render_pass.set_pipeline(&self.triangle_pipeline); render_pass.set_pipeline(&self.triangle_pipeline);
if let Some(buffer) = &neighbor.background_buffer { render_pass.set_vertex_buffer(0, neighbor.paper_buffer.slice(..));
render_pass.draw(0..6, 0..1);
if let Some(buffer) = &neighbor.pattern_buffer {
render_pass.set_pipeline(&self.pattern_pipeline);
render_pass.set_vertex_buffer(0, buffer.slice(..)); render_pass.set_vertex_buffer(0, buffer.slice(..));
render_pass.draw(0..neighbor.background_vertex_count, 0..1); render_pass.draw(0..6, 0..1);
} }
self.image_layer.draw(&mut render_pass, i + 1); self.image_layer.draw(&mut render_pass, i + 1);
if let Some(buffer) = &neighbor.vertex_buffer { if let Some(buffer) = &neighbor.vertex_buffer {
@@ -1337,6 +1378,7 @@ impl CanvasState {
// 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe) // 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe)
if self.background_vertex_count > 0 { if self.background_vertex_count > 0 {
render_pass.set_pipeline(&self.pattern_pipeline);
render_pass.set_vertex_buffer(0, self.background_buffer.slice(..)); render_pass.set_vertex_buffer(0, self.background_buffer.slice(..));
render_pass.draw(0..self.background_vertex_count, 0..1); render_pass.draw(0..self.background_vertex_count, 0..1);
} }
+81
View File
@@ -77,3 +77,84 @@ fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> { fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
return textureSample(image_texture, image_sampler, in.uv); return textureSample(image_texture, image_sampler, in.uv);
} }
// Karo, Linien und Punkte: pro Pixel aus der Seitenkoordinate berechnet, damit die Abstände
// in jedem Zoom gleichmäßig bleiben. Linien und Punkte sind mindestens ein Pixel breit und
// kantengeglättet; werden die Abstände sehr klein, blendet das Muster aus statt zu flimmern.
struct PatternInput {
@location(0) position: vec2<f32>,
@location(1) color: vec4<f32>,
// Art (1 Karo, 2 Linien, 3 Punkte), Abstand, Linienstärke, Punktgröße
@location(2) params: vec4<f32>,
// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien
@location(3) bounds: vec4<f32>,
};
struct PatternOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) page: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) params: vec4<f32>,
@location(3) bounds: vec4<f32>,
};
@vertex
fn vs_pattern(in: PatternInput) -> PatternOutput {
var out: PatternOutput;
let ndc = in.position * camera.transform_scale + camera.transform_offset;
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
out.page = in.position;
out.color = in.color;
out.params = in.params;
out.bounds = in.bounds;
return out;
}
// Deckung einer Linie bei `line` für die Koordinate `c` (`upp`: Seiteneinheiten je Pixel).
fn line_coverage(c: f32, line: f32, half_width: f32, upp: f32) -> f32 {
let half_px = max(half_width / upp, 0.5);
let d = abs(c - line) / upp;
return clamp(half_px + 0.5 - d, 0.0, 1.0);
}
@fragment
fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
let p = in.page;
let kind = in.params.x;
let spacing = in.params.y;
let upp = max(max(fwidth(p.x), fwidth(p.y)), 1e-4);
let size = in.bounds.xy;
// Unter 3 Pixel Abstand ausblenden (ganz herausgezoomt)
let fade = clamp((spacing / upp - 3.0) / 3.0, 0.0, 1.0);
var coverage = 0.0;
if (kind < 1.5) {
// Karo: senkrechte und waagerechte Linien bei k · Abstand, nicht am Blattrand
let k = round(p / spacing);
let line = k * spacing;
if (k.x >= 1.0 && line.x < size.x - 0.01) {
coverage = max(coverage, line_coverage(p.x, line.x, in.params.z * 0.5, upp));
}
if (k.y >= 1.0 && line.y < size.y - 0.01) {
coverage = max(coverage, line_coverage(p.y, line.y, in.params.z * 0.5, upp));
}
} else if (kind < 2.5) {
// Linien: waagerecht ab dem oberen Rand bis zum unteren Rand
let margin = in.bounds.z;
let k = round((p.y - margin) / spacing);
let line = margin + k * spacing;
if (k >= 0.0 && line <= size.y - margin + 0.01) {
coverage = line_coverage(p.y, line, in.params.z * 0.5, upp);
}
} else {
// Punkte: an den Kreuzungen des Karos
let k = round(p / spacing);
let point = k * spacing;
if (all(k >= vec2<f32>(1.0)) && point.x < size.x - 0.01 && point.y < size.y - 0.01) {
let radius_px = max(in.params.w * 0.5 / upp, 0.6);
let d = length(p - point) / upp;
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
}
}
return vec4<f32>(in.color.rgb, in.color.a * coverage * fade);
}