From df3fe660e582bb07ecc877ca1c028cd9176704d1 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:14:53 +0200 Subject: [PATCH] Finish the render when a kernel is too small to draw An active detail operation may emit no pass at a given render scale - the honest answer for a sensor-sized radius on a heavy proxy. The fused composer cannot see that, having no resolution to consult, so it had already stopped short of the output transform and the frame died on a storage-format mismatch. Compose a bodyless resolve pass in that case so the output transform still happens exactly once. --- core/dr-pipeline/src/detail.rs | 93 ++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/core/dr-pipeline/src/detail.rs b/core/dr-pipeline/src/detail.rs index cccc909..6226a8d 100644 --- a/core/dr-pipeline/src/detail.rs +++ b/core/dr-pipeline/src/detail.rs @@ -449,6 +449,48 @@ pub fn compose_detail( } } + // An active detail operation that emitted nothing at this scale. + // + // Legal, and the honest answer for an acutance operation on a heavy proxy + // — a one-source-pixel radius is a third of a render pixel there and no + // kernel represents a third of a pixel (see [`RenderScale`]). But it opens + // a hole between the two halves of the composition: [`compose_full`] + // decides to hand on linear working values from the *operations*, which it + // must, having no scale to consult, so the fused pass has already stopped + // short of the output transform. Returning an empty chain here would leave + // that transform undone and bind an `rgba16float` shader to an + // `rgba8unorm` target, which surfaces as a wgpu validation failure a long + // way from the cause. + // + // So the chain is never empty when the fused pass is expecting one: a + // single pass with no body, which reads the intermediate and performs the + // output transform the fused pass skipped. One dispatch, in the uncommon + // case where a photographer has a kernel switched on at a scale that + // cannot draw it — against the alternative of the preview failing outright + // or `compose_full` growing a resolution argument it has no other use for. + if planned.is_empty() + && ops + .iter() + .any(|o| o.is_active() && o.detail().is_some()) + { + return ComposedDetail { + passes: vec![compose_one( + RESOLVE_ID, + &[], + &DetailPass { + label: "resolve", + radius: 0, + wgsl: String::new(), + uniforms: Vec::new(), + }, + 0, + scale, + output, + true, + )], + }; + } + let last = planned.len().saturating_sub(1); let passes = planned .into_iter() @@ -461,6 +503,14 @@ pub fn compose_detail( ComposedDetail { passes } } +/// The operation id the resolve pass is labelled with. +/// +/// Not an operation: no `ops/*.yaml` declares it and nothing in the chain +/// answers to it. It exists so the generated label reads `detail/resolve` +/// rather than borrowing the id of whichever operation happened to fall +/// through, which would send a reader looking for a bug in that operation. +const RESOLVE_ID: &str = "detail"; + #[allow(clippy::too_many_arguments)] fn compose_one( id: &str, @@ -880,6 +930,49 @@ mod tests { } } + #[test] + fn an_active_operation_that_draws_nothing_still_finishes_the_render() { + // The seam between the two composers, and the one case where they + // cannot see each other. `compose_full` decides to hand on linear + // working values from the *operations* — it has no resolution to + // consult — while this composer converts a radius and can legitimately + // decide there is nothing to draw at this size. An empty chain would + // then leave the output transform undone: the fused pass writes + // `rgba16float` and the frontend binds an `rgba8unorm` target to it. + // + // A photographer meets this by turning on capture sharpening or + // luminance noise reduction while the develop view is fitted to a + // large file, which is the normal way to work, so it is not an edge + // case that can be left to fail. + let ops = with_blur(0.001); + let scale = RenderScale::full((400, 400)); + assert!(ops.last().expect("the blur").is_active()); + assert_eq!( + BoxBlur::with_radius(0.001).passes(scale).len(), + 0, + "the premise: a radius too small to draw emits no pass" + ); + + let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb); + assert_eq!(composed.len(), 1, "the chain must not be empty here"); + assert_eq!(composed.radius(), 0, "it reads only the pixel it writes"); + + let resolve = &composed.passes[0]; + assert_eq!(resolve.label, "detail/resolve"); + assert!(resolve.writes_output); + assert!(resolve.source.contains("texture_storage_2d