diff --git a/core/dr-gpu/src/shaders/merge.wgsl b/core/dr-gpu/src/shaders/merge.wgsl index 57381b9..07fc4df 100644 --- a/core/dr-gpu/src/shaders/merge.wgsl +++ b/core/dr-gpu/src/shaders/merge.wgsl @@ -68,8 +68,8 @@ fn to_direction(u: f32, v: f32) -> vec3 { return vec3(sin(theta) * cos(phi), sin(phi), cos(theta) * cos(phi)); } -fn load(x: i32, y: i32) -> vec3 { - return textureLoad(tile, vec2(x, y), 0).rgb; +fn load(x: i32, y: i32) -> vec4 { + return textureLoad(tile, vec2(x, y), 0); } @compute @workgroup_size(8, 8, 1) @@ -110,11 +110,25 @@ fn warp(@builtin(global_invocation_id) gid: vec3) { let y1 = min(y0 + 1, i32(p.tile_size.y) - 1); let fx = tx - f32(x0); let fy = ty - f32(y0); - let top = mix(load(x0, y0), load(x1, y0), fx); - let bot = mix(load(x0, y1), load(x1, y1), fx); - let rgb = mix(top, bot, fy) * p.gain; + // The four texels, with their alpha: the tap writes alpha 0 where the + // lens correction found no source pixel, and a sample that touches one + // of those is a partial pixel — down-weighted by exactly how much of + // it is missing, and dropped when all of it is. + let s00 = load(x0, y0); + let s10 = load(x1, y0); + let s01 = load(x0, y1); + let s11 = load(x1, y1); + let top = mix(s00, s10, fx); + let bot = mix(s01, s11, fx); + let s = mix(top, bot, fy); + if (s.a <= 0.001) { + return; + } + // Colour is the alpha-weighted mean of the texels that exist. + let rgb = s.rgb / s.a * p.gain; + let wa = w * s.a; let i = gid.y * p.chunk_size.x + gid.x; - acc[i] = acc[i] + vec4(rgb * w, w); + acc[i] = acc[i] + vec4(rgb * wa, wa); } // Resolve: the accumulated chunk to sixteen-bit samples. diff --git a/core/dr-pipeline/src/operation.rs b/core/dr-pipeline/src/operation.rs index 8f30d1b..66e835a 100644 --- a/core/dr-pipeline/src/operation.rs +++ b/core/dr-pipeline/src/operation.rs @@ -857,12 +857,22 @@ fn compose_inner( "" }; + // TRACES: FR-MRG-2 + // A pixel whose source coordinate leaves the frame — the corners a lens + // correction pulls in — is stored black. For the display that is the + // right picture; for a merge it is a pixel that does not exist and must + // not be averaged in as if it did, so the camera-space tap marks it + // with alpha 0 and the warp reads the alpha as validity. + let void_alpha = match output_mode { + OutputMode::CameraLinear => "0.0", + _ => "1.0", + }; let prologue = format!( "{}{}{}\n{}", framing.wgsl_prologue(), channel_positions, warp.body, - sample_source(interpolate, warp.splits_channels) + sample_source(interpolate, warp.splits_channels).replace("VOID_ALPHA", void_alpha) ); let sampler_helper = if interpolate { BILINEAR_HELPER } else { "" }; @@ -1237,7 +1247,7 @@ pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'stati // remove. `sample_bilinear` clamps its own texel indices, so red and blue // land on the edge pixel rather than out of bounds. if (any(uv_src < vec2(0.0)) || any(uv_src >= vec2(1.0))) { - textureStore(output, vec2(gid.xy), vec4(0.0, 0.0, 0.0, 1.0)); + textureStore(output, vec2(gid.xy), vec4(0.0, 0.0, 0.0, VOID_ALPHA)); return; } @@ -1265,7 +1275,7 @@ pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'stati // corners; render them black rather than clamping, which would smear an // edge pixel across them. if (any(uv_src < vec2(0.0)) || any(uv_src >= vec2(1.0))) { - textureStore(output, vec2(gid.xy), vec4(0.0, 0.0, 0.0, 1.0)); + textureStore(output, vec2(gid.xy), vec4(0.0, 0.0, 0.0, VOID_ALPHA)); return; } @@ -1308,7 +1318,7 @@ pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'stati // transformed at all. Render it black rather than clamping, which would // smear an edge pixel across the gap. if (any(uv_src < vec2(0.0)) || any(uv_src >= vec2(1.0))) { - textureStore(output, vec2(gid.xy), vec4(0.0, 0.0, 0.0, 1.0)); + textureStore(output, vec2(gid.xy), vec4(0.0, 0.0, 0.0, VOID_ALPHA)); return; } @@ -2109,6 +2119,35 @@ mod tests { assert!(convert < clip, "the clip must come after the conversion"); } + #[test] + fn the_camera_space_tap_marks_a_pixel_off_the_sensor_with_alpha_zero() { + // TRACES: FR-MRG-2 + // A lens correction pulls the corners in, and the pixels it leaves + // behind have no source. The display stores them black and opaque; + // the tap stores them black and *transparent*, so a merge can tell + // "nothing here" from "black here" and never averages the fringe in. + let tap = compose_camera_linear( + &[], + dr_types::Orientation::default(), + crate::framing::CropRect::default(), + ) + .source; + assert!( + tap.contains("vec4(0.0, 0.0, 0.0, 0.0)"), + "the tap must store alpha 0 off the sensor:\n{tap}" + ); + assert!( + !tap.contains("VOID_ALPHA"), + "the placeholder must be substituted:\n{tap}" + ); + let display = compose(&[]).source; + assert!( + !display.contains("vec4(0.0, 0.0, 0.0, 0.0)"), + "the display keeps its opaque black:\n{display}" + ); + assert!(!display.contains("VOID_ALPHA")); + } + #[test] fn the_generated_matrix_is_the_one_the_profile_writer_will_use() { // The shader encodes the pixels and `dr-export` describes them, from