Never crash: gpu and merge free of panicking calls

gpu (its render already runs under catch_unwind and falls back to the CPU):
- a failed readback (device lost, out of memory) logs, marks the GPU
  broken and returns zeros, so crate::render discards the result and
  renders on the CPU, instead of panicking;
- an unknown kernel name does the same instead of panic!;
- finish-parameter fields are looked up as Option (debug_assert on an
  unknown name) instead of panic!;
- the warp sampler matches (mode, warp) like the CPU pipeline;
- Buf::raw keeps one documented expect (Some from construction to Drop).

merge:
- pyramid building (push_pull, Blender::finish), camera graph growth,
  bundle adjustment, focal median and alignment use let-else / filter_map
  instead of expect;
- synth::bracket_dngs / pano_views return Result (a missing demo scene
  or a DNG write error is an error); lightcraft-cli synth-merge and the
  engine merge tests handle it.

lightcraft-gpu, lightcraft-merge and lightcraft-cli now #![deny]
unwrap/expect/panic/unimplemented/todo/unreachable; clippy.toml as in the
steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-04 12:34:53 -04:00
co-authored by Claude Opus 5.5
parent fb8e1e93aa
commit bd2e756b24
14 changed files with 96 additions and 54 deletions
+21 -3
View File
@@ -86,6 +86,9 @@ pub struct Buf {
impl Buf {
pub fn raw(&self) -> &wgpu::Buffer {
// `buf` is `Some` from construction (`Gpu::buffer`/`upload`) until `Drop` takes it back
// for the pool, so a live `Buf` always has it.
#[allow(clippy::expect_used)]
&self.buf.as_ref().expect("live buffer").0
}
}
@@ -442,7 +445,12 @@ impl Gpu {
/// Record kernel `name` with parameters `p` over buffers `bufs` (None = unused binding).
pub fn run(&self, enc: &mut wgpu::CommandEncoder, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) {
let k = self.kernels.get(name).unwrap_or_else(|| panic!("unknown kernel {name}"));
let Some(k) = self.kernels.get(name) else {
// a kernel name typo: fail this render over to the CPU instead of crashing
log::error!("gpu: unknown kernel {name}");
crate::mark_broken();
return;
};
assert_eq!(bufs.len(), k.nbuf, "{name}: binding count");
let params = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
@@ -479,11 +487,21 @@ impl Gpu {
let _ = tx.send(r);
});
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
// A failed readback (device lost, out of memory) marks the GPU broken: the caller
// (`crate::render`) then discards this render's zeroed result and renders on the CPU.
let failed = |why: String| {
log::error!("gpu readback failed: {why}");
crate::mark_broken();
vec![<T as bytemuck::Zeroable>::zeroed(); len]
};
match rx.recv() {
Ok(Ok(())) => {}
other => panic!("gpu readback failed: {other:?}"),
other => return failed(format!("{other:?}")),
}
let data = slice.get_mapped_range().expect("mapped");
let data = match slice.get_mapped_range() {
Ok(d) => d,
Err(e) => return failed(format!("{e:?}")),
};
let out: Vec<T> = bytemuck::cast_slice(&data).to_vec();
drop(data);
staging.unmap();
+1
View File
@@ -11,6 +11,7 @@
//! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here
//! compiles to the CPU fallback on wasm32.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
+12 -10
View File
@@ -86,15 +86,17 @@ pub fn finish_fields() -> Vec<(&'static str, usize)> {
.collect()
}
fn index(name: &str) -> usize {
/// Offset of field `name`; `None` (and a debug assertion) for a name not in [`FIELDS`].
fn index(name: &str) -> Option<usize> {
let mut i = 0;
for (n, len) in FIELDS {
if *n == name {
return i;
return Some(i);
}
i += len;
}
panic!("unknown finish field {name}")
debug_assert!(false, "unknown finish field {name}");
None
}
/// A parameter block being filled by name.
@@ -105,20 +107,20 @@ impl Block {
Block(vec![0; FIELDS.iter().map(|f| f.1).sum()])
}
fn f(&mut self, name: &str, v: f32) {
let i = index(name);
self.0[i] = v.to_bits();
self.u(name, v.to_bits());
}
fn u(&mut self, name: &str, v: u32) {
let i = index(name);
self.0[i] = v;
if let Some(slot) = index(name).and_then(|i| self.0.get_mut(i)) {
*slot = v;
}
}
fn b(&mut self, name: &str, v: bool) {
self.u(name, v as u32);
}
fn fs(&mut self, name: &str, v: &[f32]) {
let i = index(name);
for (k, x) in v.iter().enumerate() {
self.0[i + k] = x.to_bits();
let Some(i) = index(name) else { return };
for (slot, x) in self.0.iter_mut().skip(i).zip(v) {
*slot = x.to_bits();
}
}
}
+13 -11
View File
@@ -402,17 +402,19 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>
Some(d) => (Arc::new(resize(cx, &oriented, (sp.ow, sp.oh), d, 3, Filter::Mitchell)), d),
None => (oriented, (sp.ow, sp.oh)),
};
match &sp.mode {
SampleMode::Copy => base,
SampleMode::Affine(xf) => {
let out = cx.gpu.buffer(w * h * 3);
let mut p = vec![bw as u32, bh as u32, w as u32, h as u32];
p.extend(affine_bits(xf));
cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
Arc::new(out)
}
SampleMode::Warp(o2t) => {
let wp = fr.warp.as_ref().expect("warp mode has a warp");
let mut affine = |xf: &lightcraft_geom::Affine| {
let out = cx.gpu.buffer(w * h * 3);
let mut p = vec![bw as u32, bh as u32, w as u32, h as u32];
p.extend(affine_bits(xf));
cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
Arc::new(out)
};
match (&sp.mode, fr.warp.as_ref()) {
(SampleMode::Copy, _) => base,
(SampleMode::Affine(xf), _) => affine(xf),
// `sample_plan` plans a warp only when there is one; without it the same mapping is affine
(SampleMode::Warp(o2t), None) => affine(&(lightcraft_geom::Affine::scale(sp.sx, sp.sy) * *o2t)),
(SampleMode::Warp(o2t), Some(wp)) => {
let out = cx.gpu.buffer(w * h * 3);
let p = warp_params(wp, (bw, bh), (w, h), o2t, sp.sx, sp.sy);
cx.run("sample_warp", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));