fix: an out-of-memory GPU no longer aborts the process

The crash in #358 was not the out-of-memory itself. wgpu reports OOM as
recoverable and the app already installs a handler that logs and keeps
the session alive. What killed the process was what happened next:

  create_buffer_init maps the new buffer unconditionally, and mapping the
  invalid buffer an OOM device returns panics. That panic unwinds through
  iced's main-thread redraw, trips the wgpu-hal swapchain assertion in a
  destructor, and becomes a non-unwinding abort -- no chance to autosave.

Every allocation whose size scales with the drawing now goes through
src/scene/pipeline/gpu_upload.rs, which creates the buffer unmapped and
fills it with Queue::write_buffer. A failed allocation then surfaces as a
validation error on the write, which reaches install_gpu_error_handler
and degrades the frame -- exactly what that handler's comment promised:
"A bad frame must degrade, not end the session."

Converted: block-wire vertices and instances, the wire const storage
buffer, all three wire-arena allocators, face3d vertices and instances,
block-text instances and text vertices, both hatch backends, and the
silhouette buffers. mesh_gpu already allocated this way, which is why the
mesh path never produced this panic; the rest now match it.

No mapped_at_creation:true remains in the renderer. The create_buffer_init
calls left are fixed-size or bounded-small -- uniforms, the viewcube's
static geometry, per-image and per-wipeout quads -- where an allocation
failure is not a realistic outcome.

Two trade-offs worth stating:

  * write_buffer stages through a copy, which mapping at creation avoided.
    Chunking bounds that transient cost, and the measured block-wire
    upload got faster with chunking, not slower.
  * `queue` had to be threaded through the wire, face3d, text and hatch
    builders and their callers. Mechanical, but it widens the diff.

This touches every upload path, so it needs a visual check against a real
drawing before it goes upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-04 16:21:08 +03:00
1 parent 771a5b2c5c
commit d0ef752532
10 files changed
+362 -208

No files matched your search

+2 -2
View File
@@ -167,7 +167,7 @@ impl HatchGpu {
.filter(|hatch| hatch.boundary.len() >= 3)
.cloned()
.collect();
*resident = StorageHatchBatch::build(device, &self.bind_group_layout, &renderable);
*resident = StorageHatchBatch::build(device, queue, &self.bind_group_layout, &renderable);
}
HatchBackend::Texture { resident, .. } => {
let renderable: Vec<HatchModel> = hatches
@@ -198,7 +198,7 @@ impl HatchGpu {
.filter(|hatch| hatch.boundary.len() >= 3)
.cloned()
.collect();
*preview = StorageHatchBatch::build(device, &self.bind_group_layout, &renderable);
*preview = StorageHatchBatch::build(device, queue, &self.bind_group_layout, &renderable);
}
HatchBackend::Texture { preview, .. } => {
let renderable: Vec<HatchModel> = hatches
+52 -37
View File
@@ -3,7 +3,6 @@
use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily};
use iced::wgpu;
use iced::wgpu::util::DeviceExt;
// ── GPU structs ────────────────────────────────────────────────────────────
//
@@ -188,6 +187,7 @@ impl StorageHatchBatch {
/// Builds whole-hatch chunks within the device's buffer limits.
pub(super) fn build(
device: &wgpu::Device,
queue: &wgpu::Queue,
bgl: &wgpu::BindGroupLayout,
hatches: &[HatchModel],
) -> Vec<Self> {
@@ -224,12 +224,13 @@ impl StorageHatchBatch {
ranges.push(start..hatches.len());
ranges
.into_iter()
.filter_map(|range| Self::build_chunk(device, bgl, &hatches[range]))
.filter_map(|range| Self::build_chunk(device, queue, bgl, &hatches[range]))
.collect()
}
fn build_chunk(
device: &wgpu::Device,
queue: &wgpu::Queue,
bgl: &wgpu::BindGroupLayout,
hatches: &[HatchModel],
) -> Option<Self> {
@@ -500,42 +501,56 @@ impl StorageHatchBatch {
return None;
}
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.vertex"),
contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.index"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
let placement_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.placements"),
contents: bytemuck::cast_slice(&placements),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
});
let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.instances"),
contents: bytemuck::cast_slice(&instances),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
});
let family_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.families"),
contents: bytemuck::cast_slice(&families),
usage: wgpu::BufferUsages::STORAGE,
});
let dash_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.dashes"),
contents: bytemuck::cast_slice(&dashes),
usage: wgpu::BufferUsages::STORAGE,
});
let vertex_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
device,
queue,
"hatch.vertex",
&verts,
wgpu::BufferUsages::VERTEX,
);
let index_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
device,
queue,
"hatch.index",
&indices,
wgpu::BufferUsages::INDEX,
);
let placement_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
device,
queue,
"hatch.placements",
&placements,
wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
);
let instance_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
device,
queue,
"hatch.instances",
&instances,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
);
let family_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
device,
queue,
"hatch.families",
&families,
wgpu::BufferUsages::STORAGE,
);
let dash_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
device,
queue,
"hatch.dashes",
&dashes,
wgpu::BufferUsages::STORAGE,
);
let visibility_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.visibility"),
contents: bytemuck::cast_slice(&visibility),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
});
let visibility_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
device,
queue,
"hatch.visibility",
&visibility,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
);
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("hatch.bg"),
+21 -15
View File
@@ -199,16 +199,20 @@ impl TextureHatch {
.into_iter()
.map(|[x, y]| HatchVertex { pos: [x, y, 0.0], _pad: 0.0 })
.collect();
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.texture.vbuf"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.texture.ibuf"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
let vertex_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
device,
queue,
"hatch.texture.vbuf",
&vertices,
wgpu::BufferUsages::VERTEX,
);
let index_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
device,
queue,
"hatch.texture.ibuf",
&indices,
wgpu::BufferUsages::INDEX,
);
let base = model
.render_instance
.map_or([0.0; 3], |instance| instance.translation);
@@ -231,11 +235,13 @@ impl TextureHatch {
}
})
.collect();
let placement_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.texture.placements"),
contents: bytemuck::cast_slice(&placements),
usage: wgpu::BufferUsages::VERTEX,
});
let placement_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
device,
queue,
"hatch.texture.placements",
&placements,
wgpu::BufferUsages::VERTEX,
);
// ── Gradient projection range (snapped-local space) ───────────────
let base_mode = mode & 0xFF;