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>
This commit is contained in:
haraldandClaude Fable 5.2 committed 2026-11-14 18:31:05 +01:00
1 parent b1e725ed52
commit cde1fc8e91
1 file changed
+330 -77
+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);