Mark a pixel the lens correction pushed off the sensor with alpha 0 in the camera-space tap

The fused shader stored black with alpha 1 for a pixel whose source
coordinate left the frame, and the merge's warp averaged it in like any
other: a dark, badly interpolated fringe along every frame's edge, visible
as a seam wherever a frame ended and, later, as the edge the border fill
continued. The display keeps its opaque black; CameraLinear stores alpha 0
and the warp weights each sample by the alpha it interpolated, dropping a
sample that has none.
This commit is contained in:
2026-09-19 20:41:20 +02:00
parent c5f07f9ced
commit 2fd7690b6f
2 changed files with 63 additions and 10 deletions
+43 -4
View File
@@ -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<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(0.0, 0.0, 0.0, 1.0));
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(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<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(0.0, 0.0, 0.0, 1.0));
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(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<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(0.0, 0.0, 0.0, 1.0));
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(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<f32>(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<f32>(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