Compare commits

..
2 Commits
Author SHA1 Message Date
haraldandClaude Fable 5.2 07f28df03f chore(release): bump version to 0.45.5
Build & Publish Release / Android APK (push) Successful in 7m2s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 13m40s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 18:39:09 +01:00
haraldandClaude Fable 5.2 cde1fc8e91 fix(canvas): draw very full pages from several vertex buffers
The finished strokes of a page went into one vertex buffer. Beyond the
buffer size limit of the device (256 MiB, about 11 million vertices,
reachable on an endless page) creating it is a validation error that ends
the app. The strokes are now split into as many buffers as needed, without
cutting a stroke; the neighbor pages of the continuous view too.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 18:31:05 +01:00
6 changed files with 343 additions and 79 deletions

No files matched your search

Generated
+1 -1
View File
@@ -1540,7 +1540,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.45.4"
version = "0.45.5"
dependencies = [
"ab_glyph",
"android_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.45.4"
version = "0.45.5"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -84,6 +84,11 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.45.5" date="2026-11-14">
<description>
<p>Extremely full pages, for example on an endless notebook, no longer end the app when they are opened.</p>
</description>
</release>
<release version="0.45.4" date="2026-11-14">
<description>
<p>A page added on another device is no longer dropped from the notebook when a change is saved here at the same moment.</p>
+5
View File
@@ -84,6 +84,11 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.45.5" date="2026-11-14">
<description>
<p>Extremely full pages, for example on an endless notebook, no longer end the app when they are opened.</p>
</description>
</release>
<release version="0.45.4" date="2026-11-14">
<description>
<p>A page added on another device is no longer dropped from the notebook when a change is saved here at the same moment.</p>
+1
View File
@@ -3,6 +3,7 @@
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Extremely full pages**: A page with so many strokes that they no longer fit into one graphics buffer (possible on an endless notebook) ended the app when it was opened. Such pages are now drawn from several buffers
- **Sync while writing, page list**: If another device added a page just while you were writing, saving here could drop that page from the notebook's page list. The two page lists are now merged: pages added or removed on either device, titles and counters are combined
- **Undo after "make space"**: Making space moves the content of the following pages, sometimes onto the next page. Undoing and redoing an older step on such a page could then add a stroke a second time. The older steps of the following pages are now set aside while the space is there and come back when making space is undone
- **Sync while writing**: If a change from another device reached a page just while you were writing on it, saving your stroke overwrote that change. Now your version is saved and theirs is kept as a page of its own ("Conflict copy: …"), as for other sync conflicts; if you changed nothing on the page, their version is simply taken over
+330 -77
View File
@@ -160,8 +160,120 @@ struct NeighborPage {
/// Papier (Dreiecke) und Muster (Rechteck für `fs_pattern`)
paper_buffer: wgpu::Buffer,
pattern_buffer: Option<wgpu::Buffer>,
vertex_buffer: Option<wgpu::Buffer>,
stroke_ranges: Vec<Range<u32>>,
strokes: StrokeBuffers,
}
/// Part of a page's stroke vertices that goes into one GPU buffer.
#[derive(Debug, Clone, PartialEq)]
struct ChunkPlan {
/// Position in the page's vertex cache
vertices: Range<usize>,
/// The strokes in it, relative to the start of the chunk
strokes: Vec<Range<u32>>,
}
/// Splits the strokes of a page (ranges in its vertex cache) into runs of at most
/// `max_vertices`, without cutting a stroke. Nearly every page is a single run; only a huge
/// one, in practice an endless page, needs more than one buffer. A single stroke larger than
/// a buffer cannot be drawn and is left out.
fn plan_stroke_chunks(stroke_ranges: &[Range<u32>], max_vertices: usize) -> Vec<ChunkPlan> {
let mut chunks: Vec<ChunkPlan> = Vec::new();
for range in stroke_ranges {
let (start, end) = (range.start as usize, range.end as usize);
if end <= start || end - start > max_vertices {
continue;
}
let fits = chunks
.last()
.is_some_and(|c| c.vertices.end == start && end - c.vertices.start <= max_vertices);
if !fits {
chunks.push(ChunkPlan {
vertices: start..start,
strokes: Vec::new(),
});
}
if let Some(chunk) = chunks.last_mut() {
let offset = (start - chunk.vertices.start) as u32;
chunk.strokes.push(offset..offset + (end - start) as u32);
chunk.vertices.end = end;
}
}
chunks
}
struct StrokeChunk {
buffer: wgpu::Buffer,
/// In vertices
capacity: usize,
strokes: Vec<Range<u32>>,
}
/// The finished strokes of a page on the GPU, in as many vertex buffers as the size limit
/// of the device requires (one buffer beyond the limit is a validation error that ends the
/// app).
#[derive(Default)]
struct StrokeBuffers {
chunks: Vec<StrokeChunk>,
/// Chunks holding the current content; further ones are kept for their capacity
used: usize,
}
impl StrokeBuffers {
/// Uploads the vertex cache of a page. `spare` leaves room for more strokes (at least
/// that many vertices and twice the need), so drawing does not reallocate every time.
fn upload(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
vertices: &[InkVertex],
stroke_ranges: &[Range<u32>],
spare: Option<usize>,
) {
let vertex_size = std::mem::size_of::<InkVertex>();
let max_vertices = (device.limits().max_buffer_size / vertex_size as u64) as usize;
let mut used = 0;
for part in plan_stroke_chunks(stroke_ranges, max_vertices) {
let Some(data) = vertices.get(part.vertices.clone()) else {
continue;
};
if self
.chunks
.get(used)
.is_none_or(|c| c.capacity < data.len())
{
let capacity = match spare {
Some(initial) => grown_vertex_capacity(data.len(), initial, max_vertices),
None => data.len(),
};
let chunk = StrokeChunk {
buffer: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Strokes Vertex Buffer"),
size: (capacity * vertex_size) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
capacity,
strokes: Vec::new(),
};
if used < self.chunks.len() {
self.chunks[used] = chunk;
} else {
self.chunks.push(chunk);
}
}
let chunk = &mut self.chunks[used];
queue.write_buffer(&chunk.buffer, 0, bytemuck::cast_slice(data));
chunk.strokes = part.strokes;
used += 1;
}
self.used = used;
// Keep the first buffer for the next page, give the memory of the others back
self.chunks.truncate(used.max(1));
}
fn in_use(&self) -> &[StrokeChunk] {
&self.chunks[..self.used.min(self.chunks.len())]
}
}
pub struct CanvasState {
@@ -238,10 +350,7 @@ pub struct CanvasState {
pub spline_config: SplineConfig,
// Pipeline-Trennung: Abgeschlossene Striche der aktuellen Seite
pub committed_vertex_buffer: wgpu::Buffer,
pub committed_vertex_capacity: usize,
pub committed_vertex_count: u32,
pub committed_strokes: Vec<Range<u32>>,
committed: StrokeBuffers,
// Eingefügte Bilder der aktuellen Seite
image_layer: crate::image_layer::ImageLayer,
@@ -575,13 +684,6 @@ impl CanvasState {
mapped_at_creation: false,
});
let committed_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Committed Strokes Vertex Buffer"),
size: (Self::INITIAL_COMMITTED_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let image_layer = crate::image_layer::ImageLayer::new(
&device,
&shader,
@@ -641,10 +743,7 @@ impl CanvasState {
max_radius: 3.75, // 0.375 mm bei vollem Druck
samples_per_segment: 16,
},
committed_vertex_buffer,
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
committed_vertex_count: 0,
committed_strokes: Vec::with_capacity(128),
committed: StrokeBuffers::default(),
image_layer,
neighbors: Vec::new(),
camera_layout: bind_group_layout,
@@ -1013,33 +1112,12 @@ impl CanvasState {
let quads = self.page_image_quads(page, self.hidden_text);
self.image_layer.set_quads(quads);
self.committed_vertex_count = page.vertex_cache.len() as u32;
self.committed_strokes = page.stroke_ranges.clone();
if page.vertex_cache.is_empty() {
return;
}
if page.vertex_cache.len() > self.committed_vertex_capacity {
let vertex_size = std::mem::size_of::<InkVertex>() as u64;
let max_vertices = (self.device.limits().max_buffer_size / vertex_size) as usize;
self.committed_vertex_capacity = grown_vertex_capacity(
page.vertex_cache.len(),
Self::INITIAL_COMMITTED_CAPACITY,
max_vertices,
);
self.committed_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Committed Strokes Vertex Buffer (Reallocated)"),
size: (self.committed_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
}
self.queue.write_buffer(
&self.committed_vertex_buffer,
0,
bytemuck::cast_slice(&page.vertex_cache),
self.committed.upload(
&self.device,
&self.queue,
&page.vertex_cache,
&page.stroke_ranges,
Some(Self::INITIAL_COMMITTED_CAPACITY),
);
}
@@ -1084,19 +1162,14 @@ impl CanvasState {
resource: camera_buffer.as_entire_binding(),
}],
});
let vertex_buffer_with = |label: &str, vertices: &[InkVertex]| {
(!vertices.is_empty()).then(|| {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: std::mem::size_of_val(vertices) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.queue
.write_buffer(&buffer, 0, bytemuck::cast_slice(vertices));
buffer
})
};
let mut strokes = StrokeBuffers::default();
strokes.upload(
&self.device,
&self.queue,
&page.vertex_cache,
&page.stroke_ranges,
None,
);
// Eigenes Papier und Muster (Nachbarseiten können anders groß sein)
let paper = generate_paper_quad(self.paper_theme.paper_color(), page.size);
let pattern = generate_pattern_quad(
@@ -1130,8 +1203,7 @@ impl CanvasState {
camera_bind_group,
paper_buffer,
pattern_buffer,
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
stroke_ranges: page.stroke_ranges.clone(),
strokes,
}
}
@@ -1492,14 +1564,12 @@ impl CanvasState {
render_pass.draw(0..6, 0..1);
}
self.image_layer.draw(&mut render_pass, i + 1);
if let Some(buffer) = &neighbor.vertex_buffer {
render_pass.set_pipeline(&self.strip_pipeline);
render_pass.set_vertex_buffer(0, buffer.slice(..));
for range in &neighbor.stroke_ranges {
if range.end > range.start {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
render_pass.set_pipeline(&self.strip_pipeline);
for chunk in neighbor.strokes.in_use() {
render_pass.set_vertex_buffer(0, chunk.buffer.slice(..));
for range in &chunk.strokes {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
}
}
@@ -1525,13 +1595,11 @@ impl CanvasState {
// 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
render_pass.set_pipeline(&self.strip_pipeline);
if self.committed_vertex_count > 0 {
render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..));
for range in &self.committed_strokes {
if range.end > range.start {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
for chunk in self.committed.in_use() {
render_pass.set_vertex_buffer(0, chunk.buffer.slice(..));
for range in &chunk.strokes {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
}
@@ -1741,9 +1809,7 @@ fn text_resolution(screen_px_per_unit: f32) -> f32 {
}
/// Capacity for a vertex buffer that has to hold `needed` vertices: twice as much, so that
/// it is not reallocated with every stroke, but no more than the device allows. Doubling
/// alone asked for a buffer beyond the limit as soon as a very full page used half of it,
/// which ends in a validation panic.
/// it is not reallocated with every stroke, but no more than the device allows.
fn grown_vertex_capacity(needed: usize, initial: usize, max_vertices: usize) -> usize {
needed
.saturating_mul(2)
@@ -1756,6 +1822,193 @@ fn grown_vertex_capacity(needed: usize, initial: usize, max_vertices: usize) ->
mod tests {
use super::*;
#[test]
#[allow(clippy::single_range_in_vec_init)]
fn test_strokes_are_split_into_buffers_within_the_limit() {
// The usual case: one buffer, ranges as in the vertex cache
let ranges = vec![0..10, 10..10, 10..25, 25..40];
let plan = plan_stroke_chunks(&ranges, 1000);
assert_eq!(
plan,
vec![ChunkPlan {
vertices: 0..40,
strokes: vec![0..10, 10..25, 25..40],
}]
);
// More than fits: several buffers, no stroke cut, ranges relative to their buffer
let plan = plan_stroke_chunks(&ranges, 30);
assert_eq!(
plan,
vec![
ChunkPlan {
vertices: 0..25,
strokes: vec![0..10, 10..25],
},
ChunkPlan {
vertices: 25..40,
strokes: vec![0..15],
},
]
);
assert!(plan.iter().all(|c| c.vertices.len() <= 30));
// A stroke larger than a buffer is left out, the others are still drawn
let plan = plan_stroke_chunks(&[0..10, 10..60, 60..70], 30);
assert_eq!(
plan,
vec![
ChunkPlan {
vertices: 0..10,
strokes: vec![0..10],
},
ChunkPlan {
vertices: 60..70,
strokes: vec![0..10],
},
]
);
assert!(plan_stroke_chunks(&[], 30).is_empty());
}
/// Uploads and draws a page whose strokes exceed the buffer limit of a real device (the
/// limit is set very low for the test). Skipped where no graphics adapter is available.
#[test]
fn test_page_beyond_the_buffer_limit_is_drawn_from_several_buffers() {
let vertex_size = std::mem::size_of::<InkVertex>() as u64;
// 40 vertices per buffer
let limits = wgpu::Limits {
max_buffer_size: 40 * vertex_size,
..wgpu::Limits::default()
};
let instance = wgpu::Instance::default();
let mut found = None;
for force_fallback_adapter in [false, true] {
let options = wgpu::RequestAdapterOptions {
force_fallback_adapter,
..Default::default()
};
if let Some(adapter) = pollster::block_on(instance.request_adapter(&options)) {
let descriptor = wgpu::DeviceDescriptor {
label: None,
required_features: wgpu::Features::empty(),
required_limits: limits.clone(),
};
if let Ok(pair) = pollster::block_on(adapter.request_device(&descriptor, None)) {
found = Some(pair);
break;
}
}
}
let Some((device, queue)) = found else {
eprintln!("no graphics adapter, test skipped");
return;
};
// 10 strokes of 12 vertices: 120 vertices, three times the limit
let vertices = vec![InkVertex::new(0.0, 0.0, [0.0, 0.0, 0.0, 1.0]); 120];
let ranges: Vec<Range<u32>> = (0..10).map(|i| i * 12..(i + 1) * 12).collect();
// The limit is real: one buffer for everything is refused
device.push_error_scope(wgpu::ErrorFilter::Validation);
let _ = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: vertices.len() as u64 * vertex_size,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
assert!(pollster::block_on(device.pop_error_scope()).is_some());
device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut strokes = StrokeBuffers::default();
strokes.upload(&device, &queue, &vertices, &ranges, Some(16));
assert_eq!(strokes.in_use().len(), 4);
let drawn: usize = strokes.in_use().iter().map(|c| c.strokes.len()).sum();
assert_eq!(drawn, 10);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs(@location(0) p: vec2<f32>, @location(1) c: vec4<f32>)
-> @builtin(position) vec4<f32> { return vec4<f32>(p, 0.0, 1.0) + c * 0.0; }
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }"
.into(),
),
});
let format = wgpu::TextureFormat::Rgba8Unorm;
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs",
buffers: &[InkVertex::desc()],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs",
targets: &[Some(format.into())],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
});
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&pipeline);
for chunk in strokes.in_use() {
pass.set_vertex_buffer(0, chunk.buffer.slice(..));
for range in &chunk.strokes {
pass.draw(range.clone(), 0..1);
}
}
}
queue.submit(std::iter::once(encoder.finish()));
device.poll(wgpu::Maintain::Wait);
let error = pollster::block_on(device.pop_error_scope());
assert!(error.is_none(), "{error:?}");
// A smaller page afterwards uses one buffer again and frees the others
strokes.upload(&device, &queue, &vertices[..24], &ranges[..2], Some(16));
assert_eq!(strokes.in_use().len(), 1);
assert_eq!(strokes.chunks.len(), 1);
strokes.upload(&device, &queue, &[], &[], Some(16));
assert!(strokes.in_use().is_empty());
}
#[test]
fn test_vertex_capacity_doubles_up_to_the_device_limit() {
assert_eq!(grown_vertex_capacity(10, 1000, 1_000_000), 1000);