Let the fused shader sample a window of a larger source

A photograph larger than one texture has to be developed from a part of
it or from a reduced copy of it. The generated prologue measured the frame
by the bound texture, so either would have moved every crop, warp and
grain seed. Two uniform vec4s now say which part of the frame the texture
holds and how large the frame is; the sampler maps into the texture
through to_window, and a texture holding the whole frame samples exactly
as before ((uv - 0) / 1 is uv to the bit).
This commit is contained in:
2026-09-27 17:34:00 -04:00
parent 72aa7e98bf
commit cfc1fea25a
4 changed files with 113 additions and 32 deletions
+89 -11
View File
@@ -583,9 +583,9 @@ pub struct ComposedShader {
/// are needed by every generated shader in any case: the matrix, the as-shot
/// balance and the sample cache's flags. Framing's block follows.
///
/// Twelve fewer than before D19, which retired the base curve that sat at the
/// end of this block.
const BASE_UNIFORM_FIELDS: usize = 16 + SAMPLE_CACHE_UNIFORM_FIELDS;
/// Eight of them are the source window ([`SOURCE_WINDOW_UNIFORM_FIELDS`]),
/// which took the end of the block when D19 retired the base curve there.
const BASE_UNIFORM_FIELDS: usize = 16 + SAMPLE_CACHE_UNIFORM_FIELDS + SOURCE_WINDOW_UNIFORM_FIELDS;
/// Slots the sample cache's two flags occupy: read, write, and two spare to
/// keep the block a whole `vec4`. See [`ComposedShader::sample_key`].
@@ -599,6 +599,35 @@ const SAMPLE_CACHE_UNIFORM_FIELDS: usize = 4;
/// caller that never heard of the cache gets the direct read it always had.
pub const SAMPLE_CACHE_UNIFORM_OFFSET: usize = 16;
/// TRACES: FR-DSP-2 | NFR-RES-2
/// Slots the source window occupies: which part of the photograph the bound
/// texture holds, and how large the whole photograph is.
///
/// Two `vec4`s. The first is the window as a rectangle in normalised source
/// coordinates (origin, extent), `(0, 0, 1, 1)` for a texture that holds the
/// whole frame. The second carries the whole frame's size in pixels in
/// `.xy`, zero when the texture *is* the whole frame at full resolution, in
/// which case the shader measures the texture as it always did.
///
/// A photograph larger than one texture is developed from windows cut out of
/// it, or from a reduced copy of all of it. Either way everything above the
/// sampler — framing, the lens warps, grain seeded from `source_px` — has to
/// go on seeing the frame it was authored against, not the texture, or a
/// crop drawn on the reduced copy would land somewhere else in the export.
pub const SOURCE_WINDOW_UNIFORM_FIELDS: usize = 8;
/// Where the source window's slots begin in the generated uniform block.
///
/// Exported so `dr-gpu` writes them by index, as it does the sample cache.
pub const SOURCE_WINDOW_UNIFORM_OFFSET: usize =
SAMPLE_CACHE_UNIFORM_OFFSET + SAMPLE_CACHE_UNIFORM_FIELDS;
/// The source window a texture holding the whole frame at full resolution
/// has: every block the composer hands out starts with it, so a caller that
/// never heard of windows samples exactly as it always did.
pub const WHOLE_SOURCE_WINDOW: [f32; SOURCE_WINDOW_UNIFORM_FIELDS] =
[0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0];
/// Where the highlight desaturation begins: the fraction of the white level
/// above which a photosite is treated as clipped.
///
@@ -873,9 +902,17 @@ fn compose_inner(
\x20 // The sample cache (see `ComposedShader::sample_key`): `.x` reads\n\
\x20 // the source colour from `sampled`, `.y` writes it to\n\
\x20 // `sample_out`. Zero for both is the direct read.\n\
\x20 sample_cache: vec4<f32>,\n",
\x20 sample_cache: vec4<f32>,\n\
\x20 // The source window (see `SOURCE_WINDOW_UNIFORM_FIELDS`): the\n\
\x20 // part of the frame the texture holds, as origin and extent in\n\
\x20 // normalised source coordinates, then the whole frame's size in\n\
\x20 // pixels, zero when the texture is the whole frame.\n\
\x20 source_window: vec4<f32>,\n\
\x20 source_full: vec4<f32>,\n",
);
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
uniform_values[SOURCE_WINDOW_UNIFORM_OFFSET..BASE_UNIFORM_FIELDS]
.copy_from_slice(&WHOLE_SOURCE_WINDOW);
// TRACES: FR-DEV-3j
// Whether the composer emits a view transform — which is every render but
@@ -1297,7 +1334,7 @@ struct Params {{
@group(0) @binding(6) var sampled: texture_2d<f32>;
@group(0) @binding(7) var sample_out: texture_storage_2d<rgba16float, write>;
{sampler_helper}{helper_src}{encode_output}
{WINDOW_HELPER}{sampler_helper}{helper_src}{encode_output}
// Display-encoded sRGB back to linear, for sources that arrive that way.
//
// A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded
@@ -1622,9 +1659,9 @@ pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'stati
// discarded, which is why `Warp::splits_channels` exists: with no CA in
// the chain the single-sample path below is emitted instead.
var c = vec3<f32>(
sample_bilinear(uv_r, src_dims).r,
sample_bilinear(uv_src, src_dims).g,
sample_bilinear(uv_b, src_dims).b,
sample_bilinear(to_window(uv_r), tex_dims).r,
sample_bilinear(to_window(uv_src), tex_dims).g,
sample_bilinear(to_window(uv_b), tex_dims).b,
);
";
}
@@ -1669,7 +1706,7 @@ pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'stati
// amount that changes with the aspect ratio. It reads as a correction that
// is simply too weak, which is indistinguishable from a bad profile.
let radius = length(p) / (0.5 * length(aspect));
var c = sample_bilinear(uv_src, src_dims);
var c = sample_bilinear(to_window(uv_src), tex_dims);
"
} else {
" // Back to texture coordinates.
@@ -1684,8 +1721,14 @@ pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'stati
}
// Every output pixel lands on a source pixel, so load it directly: exact,
// and with no interpolation to soften detail.
let coord = min(vec2<i32>(uv_src * vec2<f32>(src_dims)), vec2<i32>(src_dims) - vec2<i32>(1));
// and with no interpolation to soften detail. The texel is the texture's,
// which is the frame's own only when the texture holds all of it at full
// resolution (see `to_window`).
let coord = clamp(
vec2<i32>(to_window(uv_src) * vec2<f32>(tex_dims)),
vec2<i32>(0),
vec2<i32>(tex_dims) - vec2<i32>(1),
);
// TRACES: FR-DEV-3f
// Where this pixel sits on the *source*, in source pixels. Published for
// fragments that need a position and not only a colour.
@@ -1730,6 +1773,21 @@ pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'stati
}
}
/// TRACES: FR-DSP-2 | NFR-RES-2
/// Normalised source coordinates to the bound texture's own.
///
/// The identity for a texture that holds the whole frame, which is every
/// photograph that fits in one: `(uv - 0) / 1` is `uv` to the bit, so those
/// render exactly as they did before windows existed. For a window cut from a
/// larger frame, or a reduced copy of all of it, this is the only place that
/// knows the texture is not the frame. Emitted on every path, since both the
/// exact load and the filtered sample go through it.
const WINDOW_HELPER: &str = "fn to_window(uv: vec2<f32>) -> vec2<f32> {
return (uv - u.source_window.xy) / u.source_window.zw;
}
";
/// Bilinear sampling against an unfiltered `texture_2d`.
///
/// Hand-rolled rather than done with a sampler: the source is bound as a plain
@@ -2441,6 +2499,26 @@ mod tests {
assert!(source.contains("if (!non_linear)"));
}
#[test]
fn the_source_window_owns_the_slots_dr_gpu_writes() {
// TRACES: FR-DSP-2
// `dr-gpu` fills these by index. The offset is exported rather than
// recomputed there, and this asserts the exported number still points
// at the block the shader declares — the failure otherwise is a
// window read out of a crop rectangle, which renders as nonsense
// rather than as an error.
assert_eq!(
SOURCE_WINDOW_UNIFORM_OFFSET + SOURCE_WINDOW_UNIFORM_FIELDS,
BASE_UNIFORM_FIELDS,
"the source window must be the last thing in the base block"
);
assert_eq!(
compose(&[]).uniforms[SOURCE_WINDOW_UNIFORM_OFFSET..BASE_UNIFORM_FIELDS],
WHOLE_SOURCE_WINDOW,
"a block the composer hands out must sample the whole source"
);
}
/// Compose with neutral framing into a chosen output space.
fn compose_to(ops: &[Box<dyn Operation>], output: ColourSpace) -> ComposedShader {
compose_with_framing(ops, &Framing::new(), output)