Files
DarkRoom/core/dr-pipeline/src/ops/capture_sharpen.rs
T
dtourolle c07f81edcb Run the view transform after the detail stage, in a pass of its own
The fused pass stops at "linear working values" when a sharpener, a
blur or a repair follows, and the detail passes convolve what it hands
on. Until now it handed on the rendering: the base curve, and since the
last commit the view transform, ran before the store. So every kernel
worked on display-referred values while its comments promised the
opposite — D19's second finding.

A fused pass composed for a detail stage now stops before the view
transform, and carries a second shader, `ComposedShader::view`, composed
from the same inputs. It runs the same prologue, for the positions a
fragment reads (a film's grain seeds from `source_px`) and the corners
it blacks out, takes its colour from the detail stage's result bound
where the sample cache would be, and runs the view transform, the
output transform and the mask reveal. `render_detailed` dispatches it
after the last detail pass, in the same encoder.

So no detail pass encodes any more. Every pass writes an intermediate,
the last one included, which retires three things that existed only to
make the last pass encode: `writes_output` and the runner's second
layout, the body-less resolve pass for an active kernel with nothing to
draw at this scale, and capture sharpening's pass-through, which now
emits no pass at all. An empty chain is a whole render: the view pass
reads the fused result directly. The detail stage no longer takes an
output space either, so `compose_detail_for` folds into
`compose_detail` and the space is named once, on the fused half.

The cost is one full-render read and write per frame when a detail
stage exists, and a third intermediate for a one-pass chain.
2026-09-27 16:52:54 -04:00

871 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TRACES: FR-DEV-3 | FR-DSP-1
//! Capture sharpening — an unsharp mask against the sensor.
//!
//! Every raw file arrives softer than the scene was. The anti-aliasing filter
//! spreads a point over more than one photosite on purpose, the lens's circle
//! of confusion spreads it further, and the demosaic interpolates two of every
//! three colour samples at each site from its neighbours. None of that is a
//! mistake to be corrected in the developed *picture*; it is a property of how
//! the frame was recorded, and capture sharpening is the step that undoes as
//! much of it as the data supports before anything else is built on top.
//!
//! That distinction is the whole reason this operation exists separately from
//! the output sharpening in `dr-export` (FR-EXP-4). Output sharpening is aimed
//! at a size and a medium — a 900-pixel web image and a matte A2 print want
//! different treatment of the same edit. Capture sharpening is aimed at the
//! sensor, and its answer does not change because the file is going somewhere
//! else.
//!
//! # Unsharp mask, and why the plainest one
//!
//! Blur a copy, subtract it from the original, and add back some multiple of
//! the difference. The difference is everything the blur threw away — the
//! high-frequency content — so adding it back steepens exactly the transitions
//! that the capture chain flattened, and leaves flat areas alone because the
//! blur of a flat area is the area itself.
//!
//! Deconvolution would in principle do better, since the thing being undone
//! really is a convolution with a roughly known kernel. It is also iterative,
//! needs a per-body point-spread estimate this codebase does not have, and
//! amplifies noise in a way that needs its own regularisation. An unsharp mask
//! is the baseline every editor ships and the one a photographer's hands
//! already know; a deconvolution mode can be added later behind the same three
//! parameters without changing what those parameters mean.
//!
//! # Why two passes and not one kernel
//!
//! A Gaussian is separable: blurring along x and then along y gives exactly
//! the same result as a single two-dimensional kernel, at 2(2R+1) taps per
//! pixel instead of (2R+1)². At the radii this operation reaches when zoomed
//! in — a 3-source-pixel radius at 400% is a kernel extent of 36 render pixels
//! — that is 146 taps against 5 329, and it is the difference between a
//! sharpening slider that tracks the mouse and one that does not.
//!
//! [`crate::detail::DetailStage::passes`] returns a *list* precisely so this
//! is expressible: the stage ping-pongs between intermediates, so the second
//! pass is handed what the first one wrote with no plumbing here.
//!
//! ## What the chain can and cannot do, exactly
//!
//! A detail pass reads exactly one texture — whatever ran before it. So the
//! textbook arrangement, "blur in two passes and then subtract the result from
//! the original", is not available: by the time the blur is finished the
//! original is two dispatches behind and nothing is holding it. That is not a
//! gap to be worked around with a third pass either, and it is worth writing
//! down why, because it looks like it should be.
//!
//! Write `Gx`, `Gy` for the two one-dimensional blurs and `Hx = I - Gx`,
//! `Hy = I - Gy` for the high-passes they define. A second pass can form only
//! `α·t + β·Gy(t)` from what the first pass left it in `t`. The result wanted
//! is `(1+a)c - a·GyGx(c)`, whose only occurrence of `c` is under two blurs;
//! matching the `GyGx` term needs `β ≠ 0`, and then the stray `Gy(c)` term
//! that comes with it can only be cancelled by `α·t` if `t` contains `c`
//! unblurred, which the `Gx` in the same expression rules out. No number of
//! extra passes changes this: each one only adds another blur in front.
//!
//! So the two passes each apply a *one-dimensional* unsharp mask, and the
//! composite is the product of the two one-dimensional kernels:
//!
//! ```text
//! (I + a·Hy)(I + a·Hx) = I + a·(Hx + Hy) + a²·HxHy
//! true unsharp = I + a·(Hx + Hy) - a ·HxHy
//! ```
//!
//! They differ in one term, and that term is worth understanding rather than
//! apologising for. `HxHy` responds only to structure that curves in both
//! directions at once: on any locally one-dimensional feature — which is what
//! an edge is — one of the two factors is zero and **the two agree exactly**.
//! Run this on a vertical edge and it produces the textbook unsharp mask to
//! the last bit. They part company only at corners and at fine two-dimensional
//! texture, where this arrangement sharpens slightly harder, by `a(1+a)` times
//! a quantity that is itself second-order small.
//!
//! Both preserve a flat field exactly: each one-dimensional kernel sums to
//! `(1+a) - a = 1`, so their product does too, and no amount of sharpening
//! shifts the brightness of a sky.
//!
//! # The radius is in source pixels, and that is the decision to check
//!
//! [`crate::detail::RenderScale`] offers two units and the choice between them
//! is the one thing about a neighbourhood operation that is easy to get wrong
//! and invisible when it is. `frame_fraction` is for lengths that are a
//! property of the *composition* — clarity, texture, dehaze, a mask feather —
//! where "one percent of the frame" is what the photographer meant. This
//! radius is not one of those. It stands for the spread of a point across
//! *photosites*, and a body with a stronger anti-aliasing filter needs a
//! larger one at the same framing, so it is stated in source pixels and
//! converted with [`RenderScale::source_pixels`] once per render.
//!
//! Read as render pixels instead, the slider would mean a different photograph
//! at every size: the develop view renders at whatever the viewport needs
//! (FR-DSP-1), so a 60 MP frame in a 2 000 px panel would be sharpened with a
//! kernel nine times too wide relative to the picture, and the export — the
//! only render that is ever kept — would be the one that looked nothing like
//! what was tuned. `the_radius_is_a_sensor_length_not_a_viewport_one` below
//! and `a_proxy_and_an_export_sharpen_the_same_photograph` in `dr-gpu` are the
//! two halves of the proof that it does not.
//!
//! # Where the honest answer is "not at this size"
//!
//! Converting into render pixels does not conjure detail back. On a proxy at
//! one-third scale a one-source-pixel radius is a third of a render pixel, and
//! the frequencies it would act on were destroyed by the downscale before this
//! stage ran. [`RenderScale::resolves`] is the predicate for that condition and
//! this operation obeys it: below one render pixel it stops, rather than
//! drawing a plausible-looking sharpening that the exported file will not
//! contain. That is why every editor tells the photographer to judge
//! sharpening at 1:1 — and zooming to 1:1 is enough, because the framing's
//! view rect shrinks while the render target keeps its size and the ratio
//! climbs back to one.
//!
//! A softer roll-off, fading the amount out as the kernel approaches a pixel
//! rather than stopping at it, would look better while zooming. It is not done
//! because it needs a second threshold that no requirement supplies and that
//! would be a guess dressed as a number; `resolves` is the line the stage
//! already draws, and drawing it in two places differently is worse than a
//! visible step.
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::detail::{DetailPass, DetailStage, RenderScale};
use crate::operation::{Affects, Helper, Operation, Uniform};
use crate::ops::helpers;
pub const ID: OpId = OpId("capture_sharpen");
pub const AMOUNT: ParamId = ParamId("amount");
pub const RADIUS: ParamId = ParamId("radius");
pub const THRESHOLD: ParamId = ParamId("threshold");
/// How far out the Gaussian is walked, in standard deviations.
///
/// Three: beyond that a Gaussian carries under 1.2% of its weight, and the
/// taps cost more than they change. The kernel is normalised by the weights
/// actually summed rather than by an analytic integral, so truncating here
/// costs a slightly narrower effective blur and *not* a brightness shift.
const KERNEL_SIGMAS: f32 = 3.0;
/// The largest kernel extent, in render pixels, that will be dispatched.
///
/// Only reachable by zooming past about 16:1, where the ratio climbs above one
/// and a source-pixel radius becomes many render pixels. The cap exists so
/// that a magnification nobody judges sharpening at cannot quietly turn a
/// slider drag into a 200-tap convolution per pass; the price is a Gaussian
/// truncated inside three sigma at those magnifications, which is a slightly
/// tighter blur and nothing else.
const MAX_KERNEL: f32 = 48.0;
/// The default radius, in source pixels.
///
/// One photosite. It is what an anti-aliasing filter and a demosaic between
/// them spread a point over on a conventional Bayer sensor, and it is where
/// every editor's capture sharpening starts.
const DEFAULT_RADIUS: f32 = 1.0;
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: ID,
label: LocalizedKey("op.capture_sharpen"),
params: vec![
// Amount carries the neutral, which is why it is first: the operation
// is off when this is zero regardless of the other two, so a reset is
// one control and the panel's ordering matches the way it is used.
ParamDescriptor::amount("amount", "param.amount"),
// In **source pixels** — see the module documentation. Half a photosite
// is the smallest radius that means anything on a Bayer sensor, and
// three is already past the point where an unsharp mask is sharpening
// rather than adding local contrast; a photographer wanting the latter
// wants clarity, which is a different operation with a different unit.
ParamDescriptor::scalar(
"radius",
"param.radius",
0.5,
3.0,
DEFAULT_RADIUS,
Unit::None,
Scale::Linear,
2,
),
// A fraction, but declared as a scalar rather than through
// `ParamDescriptor::fraction` for its precision alone: four decimal
// places on a control whose whole useful travel is a dozen steps
// reads as noise, and invites fiddling with digits that do nothing.
ParamDescriptor::scalar(
"threshold",
"param.threshold",
0.0,
1.0,
0.0,
Unit::None,
Scale::Linear,
2,
),
],
attributes: vec![Attribute::Detail],
})
});
/// TRACES: FR-DEV-3
/// Capture sharpening: a separable unsharp mask with a contrast threshold.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CaptureSharpen {
/// −100…100. Negative softens, which is a real request: a lens that
/// out-resolves the sensor, or a frame with moiré, is better served by
/// backing off the capture chain's acutance than by sharpening it.
amount: f32,
/// The Gaussian's standard deviation, **in source pixels**.
radius: f32,
/// Local contrast below which detail is left alone, 0…1.
threshold: f32,
}
impl Default for CaptureSharpen {
/// Neutral, and a radius already set to something usable.
///
/// The radius does not start at its minimum, and this is not the usual
/// "neutral means every parameter at zero" rule being broken: neutrality
/// here is `amount == 0`, and a radius has no neutral value at all — a
/// blur of zero width is not the identity, it is a kernel that does not
/// exist. Starting it at one photosite means dragging the amount up gives
/// a sensible result immediately rather than whatever the low end of the
/// slider happens to be.
fn default() -> Self {
Self {
amount: 0.0,
radius: DEFAULT_RADIUS,
threshold: 0.0,
}
}
}
impl CaptureSharpen {
pub fn new() -> Self {
Self::default()
}
/// The Gaussian's standard deviation at `scale`, in **render** pixels.
///
/// The single place the unit conversion happens, and the reason it is a
/// method rather than a line inside [`Self::passes`]: the tests assert
/// against it, and a test that recomputed the conversion would agree with
/// a bug in it.
pub fn sigma(&self, scale: RenderScale) -> f32 {
scale.source_pixels(self.radius)
}
/// The kernel extent at `scale`, in render pixels — the halo each pass
/// reads, and what [`DetailPass::radius`] has to state.
///
/// At least one whenever the pass runs at all: a kernel of extent zero
/// reads one tap, its "blur" is the pixel itself, and the high-pass it
/// produces is identically zero. That would be a dispatch that copies the
/// image, which is not what "sharpen a little" should mean.
pub fn kernel(&self, scale: RenderScale) -> u32 {
let extent = (self.sigma(scale) * KERNEL_SIGMAS).ceil();
extent.clamp(1.0, MAX_KERNEL) as u32
}
/// Whether this render is fine enough to show the radius that was chosen.
///
/// Delegates to [`RenderScale::resolves`] rather than restating the
/// comparison, so that the line between "sharpened" and "not at this size"
/// is drawn in exactly one place in the codebase.
pub fn resolves(&self, scale: RenderScale) -> bool {
scale.resolves(self.radius)
}
}
impl Operation for CaptureSharpen {
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
if id == AMOUNT {
self.amount = value;
} else if id == RADIUS {
self.radius = value;
} else if id == THRESHOLD {
self.threshold = value;
} else {
log::warn!("capture_sharpen: unknown parameter {id}");
}
}
fn param(&self, id: ParamId) -> f32 {
if id == AMOUNT {
self.amount
} else if id == RADIUS {
self.radius
} else if id == THRESHOLD {
self.threshold
} else {
0.0
}
}
/// Neutral is `amount == 0`, not "every parameter at its default".
///
/// A radius and a threshold describe *how* to sharpen and say nothing
/// about whether to; moving either one with the amount at zero must leave
/// the photograph untouched and must not make the edit non-neutral, or an
/// unedited file would open reporting itself modified as soon as anyone
/// brushed the radius slider.
fn is_active(&self) -> bool {
self.amount != 0.0
}
/// Never called: a detail operation contributes no fused fragment, and
/// [`crate::operation::compose_full`] filters it out before asking.
fn wgsl_body(&self) -> String {
String::new()
}
fn uniforms(&self) -> Vec<Uniform> {
Vec::new()
}
fn affects(&self) -> Affects {
Affects::Detail
}
fn detail(&self) -> Option<&dyn DetailStage> {
Some(self)
}
/// The shared Rec. 709 luminance, which in this stage is not the
/// approximation its own documentation warns about: `_helpers.yaml` notes
/// that the weights are only approximate on camera-space values, and the
/// detail stage runs *after* the camera matrix, in linear sRGB, where they
/// are the definition.
fn helpers(&self) -> &'static [Helper] {
&[helpers::LUMINANCE]
}
}
impl DetailStage for CaptureSharpen {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
// A radius finer than one pixel of this render: the detail it would
// act on is not in this texture — it was lost to the downscale before
// this stage ran (FR-DSP-1). Guessing at it would put sharpening on
// screen that the exported file will not contain, so there is no
// pass, and the interface is free to say `zoom to 1:1`. An empty
// chain is a whole render since D19: the fused pass's view pass
// performs the output transform whatever the chain holds.
if !self.resolves(scale) {
return Vec::new();
}
let extent = self.kernel(scale);
// Both halves are the same body and the same uniforms, differing only
// in the axis they walk — and the axis is read from the pass index the
// composer already writes into the base uniform block, so there is one
// kernel here rather than two that can drift apart.
["horizontal", "vertical"]
.into_iter()
.map(|label| DetailPass {
output_scale: 1,
label,
radius: extent,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![
Uniform {
// −100…100 as a gain around zero. A hundred percent is
// a strong capture sharpen and not the ceiling of what
// is useful, which is why the control is the familiar
// photographic amount rather than a 0…1 fraction.
name: "amount",
value: self.amount / 100.0,
},
Uniform {
name: "sigma",
value: self.sigma(scale),
},
Uniform {
name: "taps",
value: extent as f32,
},
Uniform {
// The threshold as a local-contrast fraction. A quarter
// at the top of the slider: past about 25% modulation
// the gate has stopped rejecting noise and started
// rejecting the edges the operation exists to sharpen.
name: "gate",
value: self.threshold * 0.25,
},
],
wgsl: BODY.to_string(),
})
.collect()
}
}
/// One axis of the separable unsharp mask.
///
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
/// axis is read from a uniform the composer already writes rather than from a
/// second copy of this kernel.
const BODY: &str = r#"// One axis of a separable unsharp mask, applied to luminance.
//
// The composer writes this pass's index into the base uniform block's fourth
// lane precisely so that a two-pass operation need not carry a uniform of its
// own to say which half it is in. Pass 0 walks x, pass 1 walks y.
let axis = select(vec2<i32>(0, 1), vec2<i32>(1, 0), u.detail_base.w < 0.5);
// The Gaussian is evaluated here rather than uploaded as a weight table. The
// kernel changes size with the zoom — the radius is in source pixels and the
// ratio is not fixed — so a table would have to be a fixed-length array padded
// to the widest kernel the slider can reach, uploaded per frame, to save an
// `exp` that the hardware does in one instruction.
let extent = i32(taps);
let falloff = 1.0 / (2.0 * sigma * sigma);
// Sharpening acts on luminance alone. Adding the high-pass to the three
// channels independently sharpens chroma noise into coloured speckle at every
// edge, which is the classic way an unsharp mask ruins a high-ISO frame; and
// the demosaic's interpolation error — the thing being corrected — is a
// luminance error, because that is the channel the CFA samples most densely.
let centre = luminance(c);
var weighted = 0.0;
var total = 0.0;
for (var i = -extent; i <= extent; i = i + 1) {
let d = f32(i);
let w = exp(-d * d * falloff);
weighted = weighted + w * luminance(tap(coord, axis * i));
total = total + w;
}
// Normalised by the weights actually summed, never by an analytic integral.
// The kernel is truncated at three sigma and `tap` clamps at the border, so
// the two disagree — by a fraction of a percent in the middle of the frame and
// by far more along its edge. Dividing by the wrong one would put a bright or
// dark band around the whole photograph, which is invisible on a test pattern
// and perfectly visible on a sky.
let blurred = weighted / total;
// Everything this axis's blur threw away. Zero on a flat field, so a sky comes
// through untouched at any amount, and the kernel as a whole still sums to one.
let high = centre - blurred;
// The threshold, as *local contrast* rather than as an absolute difference.
//
// A photographer setting this is saying "modulation this shallow is noise, not
// detail", and that judgement is about the ratio between the detail and the
// tone it sits on: the same sensor noise is a hundred times smaller in linear
// units in a shadow than in a highlight, so an absolute gate calibrated on a
// midtone would leave shadow noise fully sharpened and flatten highlight
// texture. A ratio also makes the control survive the exposure slider, which
// an absolute one would not.
//
// The floor keeps the ratio finite as the local level approaches black. Below
// roughly nine stops down there is nothing but read noise anyway, and without
// it a noise-sized difference divided by a noise-sized level would read as a
// hard edge and be sharpened hardest exactly where it is least wanted.
let level = max(blurred, 0.005);
let contrast = abs(high) / level;
// A soft knee rather than a step: gating on a comparison would sharpen one
// pixel fully and its neighbour not at all, and the boundary between them is
// itself an edge — visible as a crawling outline around every gently graded
// region. Full suppression below half the gate, full effect above it.
let knee = max(gate, 1e-5);
let keep = select(1.0, smoothstep(knee * 0.5, knee, contrast), gate > 0.0);
// `sharpened` rather than the obvious `target`: `target` is a WGSL reserved
// keyword, and a fragment that declares one fails to compile against
// *generated* source, so the error names a file nobody wrote. The pipeline
// has been bitten by exactly this once already — see
// `no_fragment_declares_a_wgsl_reserved_keyword` in `lib.rs`, which was added
// the day `let target` broke the contrast fragment.
let sharpened = centre + amount * high * keep;
// Applied as a gain on all three channels rather than as an offset, so that
// steepening an edge does not drag its colour towards grey: the channel ratios
// are the hue and the saturation, and multiplying leaves them exactly where
// they were. Negative luminance is not a colour, so undershoot stops at black
// — the only clamp in this stage, and it is on the scalar, not on the channels,
// which stay unclipped above one for the output transform to deal with.
//
// Below a nearly-black luminance the ratio stops carrying information — the
// three channels are all noise there and the divisor is meaningless — so the
// pixel is handed on untouched rather than multiplied by whatever fell out.
let scaled = c * (max(sharpened, 0.0) / max(centre, 1e-5));
c = select(c, scaled, centre > 1e-5);"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::EditGraph;
/// The develop chain with the sharpener turned up.
///
/// Built through [`EditGraph`] rather than by reaching into `ops::chain()`
/// directly, so that every value these tests use passes the same clamp a
/// slider's would. A test asserting an exact kernel for a radius the graph
/// would have clipped on the way in is a test of a configuration the
/// photographer cannot reach — the mistake `build.rs` refuses outright for
/// declared nodes, and which nothing catches for a hand-written one.
fn sharpening(amount: f32, radius: f32) -> EditGraph {
let mut graph = EditGraph::default_chain();
graph.set_param(ID, AMOUNT, amount);
graph.set_param(ID, RADIUS, radius);
graph
}
fn chain_at(graph: &EditGraph, scale: RenderScale) -> crate::detail::ComposedDetail {
// The scale is what these tests vary, so it is rebuilt into the two
// sizes it stands for rather than handed over: a render of the full
// frame at `render_size`, from a source of `full_size`.
graph.compose_detail(scale.full_size(), scale.render_size())
}
#[test]
fn a_radius_alone_is_not_an_edit() {
// The neutral rule this operation states differently from most: two of
// its three parameters describe *how* to sharpen, and moving them with
// the amount at zero must leave the file unmodified. Otherwise opening
// an image and brushing the radius slider would mark it edited and
// write a sidecar for a photograph nobody changed.
let mut op = CaptureSharpen::new();
assert!(!op.is_active());
op.set_param(RADIUS, 3.0);
op.set_param(THRESHOLD, 1.0);
assert!(!op.is_active(), "a radius is not a decision to sharpen");
op.set_param(AMOUNT, 25.0);
assert!(op.is_active());
}
#[test]
fn a_neutral_sharpener_composes_no_passes_at_all() {
// The rule the whole pipeline rests on: an operation at its defaults
// costs nothing. Almost every photograph in a library is unsharpened,
// and none of them should pay a dispatch for it.
let composed = chain_at(&EditGraph::default_chain(), RenderScale::full((512, 512)));
assert!(composed.is_empty());
assert_eq!(composed.radius(), 0);
}
#[test]
fn sharpening_is_two_passes_and_only_the_last_one_encodes() {
// Separability, as it reaches the GPU. The first pass writes a linear
// intermediate and the second writes the display texture, so the
// output transform happens exactly once (FR-DEV-2) at the end of the
// chain rather than in the middle of a convolution.
let composed = chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512)));
assert_eq!(composed.len(), 2);
let (first, last) = (&composed.passes[0], &composed.passes[1]);
assert_eq!(first.label, "capture_sharpen/horizontal");
assert_eq!(last.label, "capture_sharpen/vertical");
// Neither encodes: the view pass after the detail stage does (D19).
for pass in [first, last] {
assert!(pass.source.contains("texture_storage_2d<rgba16float"));
assert!(!pass.source.contains("fn encode_output"));
}
// Two shaders, so two pipeline-cache entries. Sharing one would run
// the horizontal pass's uniforms through the vertical pass's slots.
assert_ne!(first.structure_hash, last.structure_hash);
}
#[test]
fn both_passes_are_one_kernel_told_apart_by_the_index_the_composer_writes() {
// The reason there is a single `BODY` constant. If the axis came from
// a uniform of this operation's own, there would be two bodies to keep
// in step; instead each pass reads the index the composer already puts
// in the base block, and the only difference between the two generated
// shaders is the uniform prefix.
let composed = chain_at(&sharpening(50.0, 1.0), RenderScale::full((256, 256)));
for pass in &composed.passes {
assert!(pass.source.contains("u.detail_base.w < 0.5"));
}
assert!(composed.passes[0]
.source
.contains("capture_sharpen_0_amount: f32,"));
assert!(composed.passes[1]
.source
.contains("capture_sharpen_1_amount: f32,"));
// And the body addresses them by the bare names it declared, with the
// composer doing the prefixing — so two detail operations may both
// call a uniform `amount` and neither has to know.
assert!(composed.passes[0]
.source
.contains("let falloff = 1.0 / (2.0 * u.capture_sharpen_0_sigma"));
}
#[test]
fn the_declared_halo_is_the_kernel_the_body_actually_walks() {
// ARCH §5.3 grows a tile by the declared radius before computing it.
// Understate it and every tile boundary shows a seam that looks like a
// driver bug; nothing can infer it from the WGSL, because the loop
// bound is a uniform.
let scale = RenderScale::full((1000, 1000));
let op = {
let mut op = CaptureSharpen::new();
op.set_param(AMOUNT, 60.0);
op.set_param(RADIUS, 2.0);
op
};
let expected = op.kernel(scale);
assert_eq!(expected, 6, "three sigma of a two-pixel radius at 1:1");
let composed = chain_at(&sharpening(60.0, 2.0), scale);
assert_eq!(composed.radius(), expected);
assert!(composed.passes.iter().all(|p| p.radius == expected));
// And the loop really is walked over that many taps each way.
for pass in &composed.passes {
assert!(pass.uniforms.contains(&(expected as f32)));
}
}
#[test]
fn the_radius_is_a_sensor_length_not_a_viewport_one() {
// TRACES: FR-DSP-1 — the decision this operation is most likely to get
// wrong, asserted directly. It is the test the brief asked for: the
// same edit at more than one render resolution has to sharpen the same
// photograph.
//
// Three renders of one 4000-pixel frame: an export, a half-size proxy,
// and a 40% fit view. A radius in source pixels must come out as the
// same number of *source* pixels every time, which means a different
// number of render pixels every time. Read the stored radius as render
// pixels instead and the last three assertions are the ones that fail:
// the kernel would be three sigma wide at every size, so the proxy on
// screen would be sharpened two and a half times as hard, relative to
// the picture, as the file that gets exported.
//
// The radius is the slider's maximum rather than a round number past
// it, because `EditGraph::set_param` clamps and a test asserting on a
// radius the graph would have clipped would be asserting about a
// photograph nobody can produce.
let graph = sharpening(80.0, 3.0);
let full = (4000u32, 4000u32);
let in_source_pixels = |render: u32| -> f32 {
let scale = RenderScale::new((render, render), full);
chain_at(&graph, scale).radius() as f32 / scale.ratio()
};
let export = in_source_pixels(4000);
let half = in_source_pixels(2000);
let fit = in_source_pixels(1600);
assert!((export - 9.0).abs() < 0.01, "three sigma of three pixels");
// Within the rounding of one render pixel back through the ratio,
// which is the whole of the permitted error: the kernel is an integer
// count of render pixels, and one of those is two source pixels on the
// half proxy and two and a half on the fit view.
assert!(
(half - export).abs() <= 2.0,
"the same edit covers {half} source pixels on a half proxy and \
{export} at export"
);
assert!(
(fit - export).abs() <= 2.5,
"the same edit covers {fit} source pixels on a 40% view and \
{export} at export"
);
// The other half of the statement, and the one that fails if the unit
// is misread: the kernel in *render* pixels must shrink with the
// render, because that is what keeps it the same size on the picture.
let render_pixels =
|render: u32| chain_at(&graph, RenderScale::new((render, render), full)).radius();
assert_eq!(render_pixels(4000), 9);
assert_eq!(render_pixels(2000), 5, "ceil(3 sigma of 1.5)");
assert_eq!(render_pixels(1600), 4, "ceil(3 sigma of 1.2)");
}
#[test]
fn a_render_too_coarse_for_the_radius_stops_rather_than_guesses() {
// A one-pixel radius on a quarter-scale proxy is a quarter of a render
// pixel, and no kernel represents that — the frequencies it would act
// on went out with the downscale. `RenderScale::resolves` reports the
// condition and this obeys it, because a preview that shows sharpening
// the exported file will not contain is worse than one that shows none.
let graph = sharpening(100.0, 1.0);
let proxy = RenderScale::new((1000, 1000), (4000, 4000));
assert!(!proxy.resolves(1.0));
// Empty: since D19 nothing in the detail stage encodes, so a chain
// with nothing to draw is a whole render — the fused pass's view pass
// finishes it.
let composed = chain_at(&graph, proxy);
assert!(composed.is_empty());
// Zooming to 1:1 is what brings it back — the view rect shrinks while
// the render target keeps its size — so there is no separate
// full-resolution preview path for a photographer to wait on.
let one_to_one = RenderScale::new((1000, 1000), (1000, 1000));
assert_eq!(chain_at(&graph, one_to_one).len(), 2);
}
#[test]
fn the_amount_reaches_the_shader_as_a_gain_and_keeps_its_sign() {
// Negative is not a mistake to be clamped away: a lens that
// out-resolves the sensor, or a frame with moiré, wants the capture
// chain's acutance backed off rather than lifted, and the same kernel
// with a negative gain is exactly that.
let amount_of = |value: f32| -> f32 {
let composed = chain_at(&sharpening(value, 1.0), RenderScale::full((512, 512)));
// Base block first and fixed, then this pass's own, in the order
// `passes` declared them.
composed.passes[0].uniforms[crate::detail::DETAIL_BASE_UNIFORM_FIELDS]
};
assert!((amount_of(100.0) - 1.0).abs() < 1e-6);
assert!((amount_of(50.0) - 0.5).abs() < 1e-6);
assert!((amount_of(-40.0) + 0.4).abs() < 1e-6);
}
#[test]
fn the_threshold_is_off_when_it_is_at_zero() {
// The gate is a `smoothstep`, and a `smoothstep` whose two edges meet
// is undefined. The body guards it with a `select` on this uniform
// being positive, so a photographer who never touches the threshold
// gets the plain unsharp mask and not a NaN.
let gate_of = |threshold: f32| -> f32 {
let mut graph = sharpening(50.0, 1.0);
graph.set_param(ID, THRESHOLD, threshold);
let composed = chain_at(&graph, RenderScale::full((512, 512)));
*composed.passes[0].uniforms.last().expect("a gate")
};
assert_eq!(gate_of(0.0), 0.0);
assert!((gate_of(1.0) - 0.25).abs() < 1e-6);
assert!(
chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))).passes[0]
.source
.contains("gate > 0.0")
);
}
#[test]
fn every_pass_declares_a_uniform_block_the_gpu_will_accept() {
// A uniform struct whose size is not a multiple of sixteen is rejected
// outright by the WGSL uniform address space rules, and the failure
// arrives as a compilation error against source nobody wrote. Both
// shapes this operation emits have to satisfy it — the sharpening pair
// and the pass-through, which carries no uniforms of its own at all.
let scales = [
RenderScale::full((512, 512)),
RenderScale::new((256, 256), (4096, 4096)),
];
for scale in scales {
for pass in chain_at(&sharpening(75.0, 1.0), scale).passes {
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
assert!(
pass.uniforms.iter().all(|v| v.is_finite()),
"{}",
pass.label
);
}
}
}
#[test]
fn the_kernel_declares_no_wgsl_reserved_keyword() {
// `lib.rs` runs this check over the fused fragments and cannot reach
// here: a detail pass is a separate shader, composed at a resolution
// that `compose()` never sees. It is worth repeating rather than
// skipping, because the failure it catches is the least legible one in
// this crate — `let target = ...` in the contrast fragment once failed
// with "name `target` is a reserved keyword", pointing at generated
// source rather than at the operation that wrote it, and this body
// reaches for exactly that word.
//
// Not the full reserved list; the words a kernel would plausibly pick
// for a local. The same list `no_fragment_declares_a_wgsl_reserved_keyword` uses,
// kept identical on purpose: two lists that drift apart would let a
// word be safe in one stage and not in the other, which is the sort of
// difference nobody discovers until a shader fails to compile.
const RESERVED: &[&str] = &[
"target",
"sample",
"filter",
"texture",
"buffer",
"binding",
"const",
"enum",
"mat",
"vec",
"ptr",
"ref",
"shared",
"static",
"typedef",
"union",
"unless",
"handle",
"layout",
"packed",
"premerge",
"regardless",
"active",
"do",
"input",
"output",
"private",
"resource",
"restrict",
"self",
"std",
"where",
];
for pass in chain_at(&sharpening(100.0, 2.0), RenderScale::full((512, 512))).passes {
// Only what this operation wrote — the block the composer wraps
// the body in. Its preamble declares `var output` and its tail
// writes through it, and neither is this test's business; scanning
// the whole file would fail on generated code nobody here can fix.
let block = pass
.source
.rsplit_once(" {\n")
.expect("the operation's block opens")
.1;
let body = block
.split_once("\n }\n")
.map_or(block, |(inside, _)| inside);
// Comments are not declarations, and the kernel deliberately names
// `target` in one to say why it does not use it. Stripping them
// keeps this on the code, so that editing the prose can never fail
// a test about what compiles.
let code: Vec<&str> = body
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect();
let code = code.join("\n");
for keyword in RESERVED {
for form in [format!("let {keyword} "), format!("var {keyword} ")] {
assert!(
!code.contains(&form),
"{} declares `{keyword}`, which is a WGSL reserved keyword",
pass.label
);
}
}
}
}
#[test]
fn a_deep_zoom_cannot_turn_a_slider_into_an_unbounded_convolution() {
// Zooming past about 16:1 pushes the ratio above one, and a radius in
// source pixels becomes many render pixels. The cap keeps the cost of
// a magnification nobody judges sharpening at from growing without
// limit; what it costs is a Gaussian truncated inside three sigma,
// which is a slightly tighter blur and no other artefact.
let mut op = CaptureSharpen::new();
op.set_param(AMOUNT, 100.0);
op.set_param(RADIUS, 3.0);
let deep = RenderScale::new((2000, 2000), (25, 25));
assert!(deep.ratio() > 16.0);
assert_eq!(op.kernel(deep), MAX_KERNEL as u32);
}
}