Read the fit view's source gather once per framing, not once per frame

At fit, every output pixel of the fused pass loads one texel from a source
three or four times its width, on a stride. The memory system fetches the
texels it skips along with the one it wanted, so on a 60 MP rgba16float
source that gather was most of what the fused pass cost: 10.6 ms of a
2560x1600 frame against 3.8 ms for the same shader reading a contiguous
window (the 1:1 view). At 3840x2160 it was 21.1 ms. Those are the laptop
RTX 3050 with its clocks held at 420/810 MHz by the power cap; unthrottled
the same frames were about 2.0 and 3.2 ms, and the gather is the same
share of them.

Which texel an output pixel reads depends only on the framing prologue,
the framing and warp uniforms, the source and the render size. None of
those move during a slider drag, so the gather is the same work every
frame. The fused shader now takes a render-sized rgba16float cache of it
(bindings 6 and 7, declared in every generated shader like the masks) and
a pair of uniform flags: write what was gathered, or read it back at the
pixel's own coordinate. AdjustPass keeps the cache and decides per
dispatch. The composer supplies `ComposedShader::sample_key`, a hash of
the prologue and those uniforms, and AdjustPass adds the image and the
size; an image gets a process-unique id for this rather than being held
alive by the key.

The picture is bit-for-bit the same. The source is rgba16float and so is
the cache, so the stored texel is the texel, and only the path that reads
a texel whole takes part: an interpolated sample (straightening, lens
warps, CA) is a blend that f16 could not hold exactly, so the composer
gives it no key and it reads directly as before.

The cache is written on the second frame with a given key, not the first:
a crop or zoom drag changes the key every frame, and writing then would
add a render-sized write to exactly the gestures that can afford it least.
It is kept only up to 3840x2400, so an export never parks a full-frame
copy on the device, and `release_caches` drops it.

Measured with a scratch probe rendering the synthetic 60 MP frame from
examples/frame_budget.rs, forty frames per run after six warm-up, five
runs of each binary alternated, median of the per-run p50 (GPU idle apart
from the power cap):

  scene                    before     after
  neutral   2560x1600 fit  10.62 ms   3.88 ms
  exposure  2560x1600 fit  10.83 ms   3.87 ms
  nr chroma 2560x1600 fit  19.84 ms  12.69 ms
  neutral   3840x2160 fit  21.05 ms   7.11 ms
  exposure  3840x2160 fit  21.08 ms   6.94 ms
  clarity   3840x2160 fit  42.20 ms  27.88 ms
  neutral   2560x1600 1:1   3.83 ms   3.84 ms  (control: nothing to gain)

The rgba8 output of every scene hashed identically before and after, in
isolated runs and across all 38 scene/size/view combinations of the
probe. New tests walk a pass through direct, write and read frames, a
slider move, a neighbourhood operation and a framing change, and compare
every frame with a fresh pass that can only have read directly.
This commit is contained in:
2026-09-26 07:10:35 -04:00
parent 284fc4a456
commit 1dc7b45cfe
5 changed files with 521 additions and 28 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
RESERVED_UNIFORM_FIELDS,
RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
};
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
pub use sidecar::{Sidecar, Version};
+71 -3
View File
@@ -456,6 +456,25 @@ pub struct ComposedShader {
pub structure_hash: u64,
/// What this shader writes. See [`OutputMode`].
pub output_mode: OutputMode,
/// What decides which source texel each output pixel reads, when that
/// texel is read whole — `None` when it is interpolated.
///
/// A fit view reads one texel in every three or four of a 60 MP source,
/// on a stride, and that gather is most of what the fused pass costs
/// there: the texel it wants shares a cache line with neighbours nobody
/// reads. But the gather depends on the framing and nothing else, so it
/// is the same on every frame of a slider drag. The shader can therefore
/// write what it gathered to a viewport-sized texture once and read it
/// back contiguously thereafter; the flags in the uniform block at
/// [`SAMPLE_CACHE_UNIFORM_OFFSET`] say which, and `dr-gpu` decides.
///
/// This key is the half of that decision only the composer can make: a
/// hash of the generated prologue and the framing and warp uniforms, which
/// together are everything that maps an output pixel to a source texel.
/// The caller mixes in the source image and the render size. `None` for
/// the interpolating paths, whose sample is a blend of four texels and
/// not representable exactly in the source's own format.
pub sample_key: Option<u64>,
}
/// Fields the generated uniform struct always carries, before op uniforms.
@@ -466,7 +485,19 @@ pub struct ComposedShader {
/// Twelve of the twenty-eight are the camera profile's base curve
/// ([`BASE_CURVE_UNIFORM_FIELDS`]); the rest are the matrix, the as-shot
/// balance and framing's own block.
const BASE_UNIFORM_FIELDS: usize = 16 + BASE_CURVE_UNIFORM_FIELDS;
const BASE_UNIFORM_FIELDS: usize = 16 + SAMPLE_CACHE_UNIFORM_FIELDS + BASE_CURVE_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`].
const SAMPLE_CACHE_UNIFORM_FIELDS: usize = 4;
/// Where the sample cache's flags sit in the generated uniform block: `x` says
/// read the source colour from the cache, `y` says write it there.
///
/// Exported for the reason [`BASE_CURVE_UNIFORM_OFFSET`] is — `dr-gpu` writes
/// these by index — and zero in every block the composer hands out, so a
/// caller that never heard of the cache gets the direct read it always had.
pub const SAMPLE_CACHE_UNIFORM_OFFSET: usize = 16;
/// TRACES: FR-DEV-3e
/// Slots the base curve occupies: five `(x, y)` points and an active flag.
@@ -484,7 +515,8 @@ const BASE_CURVE_UNIFORM_FIELDS: usize = 12;
/// Exported for the same reason [`RESERVED_UNIFORM_FIELDS`] is: `dr-gpu`
/// writes these by index, and an offset computed independently at both ends is
/// an offset that will eventually disagree with itself.
pub const BASE_CURVE_UNIFORM_OFFSET: usize = 16;
pub const BASE_CURVE_UNIFORM_OFFSET: usize =
SAMPLE_CACHE_UNIFORM_OFFSET + SAMPLE_CACHE_UNIFORM_FIELDS;
/// How many control points a base curve carries.
///
@@ -756,6 +788,10 @@ fn compose_inner(
\x20 // gamma-encoded JPEG, 0.0 for demosaiced sensor data), which the\n\
\x20 // prologue reads to decide whether to linearise.\n\
\x20 as_shot_wb: vec4<f32>,\n\
\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 // The camera profile's base curve (FR-DEV-3e): five points on a\n\
\x20 // monotone spline, packed as x0..x3, y0..y3, then (x4, y4, on).\n\
\x20 // `.z` of the last is the flag, not padding — it is 0 for a\n\
@@ -936,6 +972,19 @@ fn compose_inner(
);
let sampler_helper = if interpolate { BILINEAR_HELPER } else { "" };
// Everything that decides which texel an output pixel reads: the code that
// computes `coord`, and the uniforms that code reads. Only on the path that
// reads a texel whole — see `ComposedShader::sample_key`.
let sample_key = (!interpolate && !warp.splits_channels).then(|| {
framing
.uniforms()
.iter()
.chain(&warp.uniforms)
.fold(hash_source(&prologue), |h, v| {
mix(h, u64::from(v.to_bits()))
})
});
// The tail, and it is the whole of the difference between the two output
// modes. Everything above — the prologue, the fragments, the mask layers,
// the camera matrix — is emitted identically either way, so an operation
@@ -1087,6 +1136,12 @@ struct Params {{
// stock is loaded, which costs eight bytes and no branch.
@group(0) @binding(4) var film_curves: texture_2d<f32>;
@group(0) @binding(5) var film_lut_texture: texture_3d<f32>;
// The sample cache: the source texel each output pixel read on an earlier
// frame with this framing, and where this frame writes it when asked. See
// `ComposedShader::sample_key`. Declared unconditionally, like the masks, and
// bound to 1x1 placeholders whenever the flags say not to touch them.
@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}
// Display-encoded sRGB back to linear, for sources that arrive that way.
@@ -1196,6 +1251,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
uniforms: uniform_values,
structure_hash,
output_mode,
sample_key,
}
}
@@ -1415,7 +1471,19 @@ 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 = textureLoad(source, coord, 0).rgb;
// The texel itself, from the source or from the cache of it an earlier
// frame wrote (see `ComposedShader::sample_key`). Both branches yield the
// same bits: the source is `rgba16float` and so is the cache. The flags
// are uniforms, so the whole dispatch takes one branch.
var c: vec3<f32>;
if (u.sample_cache.x > 0.5) {
c = textureLoad(sampled, vec2<i32>(gid.xy), 0).rgb;
} else {
c = textureLoad(source, coord, 0).rgb;
if (u.sample_cache.y > 0.5) {
textureStore(sample_out, vec2<i32>(gid.xy), vec4<f32>(c, 1.0));
}
}
"
}
}