Clarity's Gaussian sigma is 1.2% of the frame's shorter edge, so its radius is a property of the viewport: 52 render pixels at 4K, two separable passes of 105 taps each over 8.3 M pixels. That measured 33.9 ms — seven times the entire fused point chain, for one slider — and is docs/technical-debt.md TD-4. A detail pass may now declare `output_scale`, and clarity's base is computed on a grid a quarter the size on each axis. The pass that combines needs the blur *and* the full-resolution colour, and a colour that has been through a quarter-scale target is no longer full resolution. So a scaled pass cannot simply join the ping-pong: there are two chains now. The full-resolution one carries the colour and no scaled pass touches it; the reduced one carries the base and reaches the combining pass through a second binding as `reduced_at()`. The reduce is a dispatch of its own rather than something the first blur half does on the way past, and that is the whole difference between this and the strided kernel the module documentation rules out. A stride samples an image that is not band-limited and aliases high-frequency content down into the base, which is then subtracted, and arrives in the output as mottling across smooth gradients. This band-limits first and samples after. What is discarded is content the base could not represent at any resolution, because a Gaussian at sigma = 26 px holds nothing above one cycle per 26 px and the quarter-scale grid carries one per 8 — so the reduced base is not an approximation of the full-resolution one, it is the same function sampled where it is still determined. Which is also why the scale belongs to the band rather than to the stage. Texture's sigma is a decade finer, so the reduce pass's own box would be wider than the Gaussian it was prefiltering; texture never reduces. And clarity steps 4 -> 2 -> 1 as sigma falls, because a quarter of a small sigma is not a Gaussian either — the case that gives up is the one that was already cheap. `radius` stays in each pass's own pixels and `ComposedDetail::radius` multiplies it back up, so 13 reduced pixels at scale 4 still report the 52 render pixels a tile would have to be grown by. The halo a scheduler sees does not move. The halo tests pass unchanged, which was TD-4's stated bar; they render at 1024 px and so exercise the reduced path rather than stepping around it. Added `crossing_the_reduction_threshold_does_not_change_the_picture`, because nothing yet compared the reduced form against a *less* reduced one — every other test measures one form against itself. It renders the same edit either side of the 4 -> 2 step-down and holds the peak excursion to 0.03 stops and the reach to 2% of the frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
6.5 KiB
Rust
182 lines
6.5 KiB
Rust
//! A separable box blur, for testing the detail stage. **Not a develop
|
|
//! operation.**
|
|
//!
|
|
//! # Why an abstraction gets a fake consumer
|
|
//!
|
|
//! The detail stage was written before any of the operations it exists for —
|
|
//! sharpening, noise reduction, clarity, spot removal are each their own piece
|
|
//! of work — and an abstraction with no consumer is a guess. Nothing would
|
|
//! have proved that the WGSL it generates compiles, that the ping-pong hands
|
|
//! pass two what pass one wrote, that the last pass really does encode, or
|
|
//! that a radius stated in one unit survives the trip from a proxy to an
|
|
//! export.
|
|
//!
|
|
//! So the stage has exactly one consumer, and it lives here, behind the
|
|
//! `detail-probe` feature. It is deliberately *not* declared in `ops/`: it has
|
|
//! no `order:`, it is not in [`crate::ops::chain`], it never reaches
|
|
//! [`crate::EditGraph::capabilities`], and so it cannot appear in the develop
|
|
//! panel or in a sidecar. A shipping build does not contain it.
|
|
//!
|
|
//! # Why a box blur specifically
|
|
//!
|
|
//! Because its answer is known in closed form. A box blur of radius *r* over a
|
|
//! step edge produces a ramp exactly `2r + 1` pixels wide with a known value
|
|
//! at every step, so a test can assert *pixels*, not "something changed". A
|
|
//! Gaussian would need a tolerance chosen to hide whatever the implementation
|
|
//! actually did.
|
|
//!
|
|
//! And because it is **separable**, which is the property the two-pass case
|
|
//! was built for: a horizontal pass then a vertical one is mathematically a 2D
|
|
//! box average, so if the ping-pong is wired backwards or a pass reads its own
|
|
//! output the result is visibly not a box blur rather than subtly wrong.
|
|
use std::sync::{Arc, LazyLock};
|
|
|
|
use crate::descriptor::{
|
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit,
|
|
};
|
|
use crate::detail::{DetailPass, DetailStage, RenderScale};
|
|
use crate::operation::{Affects, Operation, Uniform};
|
|
|
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
|
Arc::new(OpDescriptor {
|
|
id: OpId("detail_probe"),
|
|
label: LocalizedKey("op.detail_probe"),
|
|
params: vec![ParamDescriptor {
|
|
id: ParamId("radius"),
|
|
label: LocalizedKey("param.detail_probe.radius"),
|
|
// A fraction of the frame's shorter edge, which is the unit
|
|
// `RenderScale::frame_fraction` converts and the unit a mask feather
|
|
// is already stored in. Stating it in pixels is the mistake this
|
|
// whole stage is arranged to make impossible.
|
|
kind: ParamKind::Scalar {
|
|
min: 0.0,
|
|
max: 0.25,
|
|
scale: Scale::Linear,
|
|
unit: Unit::None,
|
|
precision: 4,
|
|
},
|
|
default: 0.0,
|
|
facet: None,
|
|
}],
|
|
attributes: vec![Attribute::Detail],
|
|
})
|
|
});
|
|
|
|
/// A separable box blur whose radius is a fraction of the frame's shorter edge.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct BoxBlur {
|
|
radius: f32,
|
|
}
|
|
|
|
impl BoxBlur {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Set the radius directly, in fractions of the shorter edge.
|
|
pub fn with_radius(radius: f32) -> Self {
|
|
Self { radius }
|
|
}
|
|
|
|
/// The kernel radius this blur would use at `scale`, in render pixels.
|
|
///
|
|
/// Exposed so a test can state the expected ramp width without repeating
|
|
/// the rounding rule — a test that recomputed it would agree with a bug.
|
|
pub fn kernel(&self, scale: RenderScale) -> u32 {
|
|
scale.frame_fraction(self.radius).round().max(0.0) as u32
|
|
}
|
|
}
|
|
|
|
impl Operation for BoxBlur {
|
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
|
DESCRIPTOR.clone()
|
|
}
|
|
|
|
fn set_param(&mut self, _id: ParamId, value: f32) {
|
|
self.radius = value;
|
|
}
|
|
|
|
fn param(&self, _id: ParamId) -> f32 {
|
|
self.radius
|
|
}
|
|
|
|
fn is_active(&self) -> bool {
|
|
self.radius > 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)
|
|
}
|
|
}
|
|
|
|
impl DetailStage for BoxBlur {
|
|
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
|
|
let r = self.kernel(scale);
|
|
// A radius that rounded to nothing is not "blur by zero" — it is an
|
|
// effect this render is too small to show. Emitting a pass that
|
|
// averages one pixel would burn a dispatch to copy the image.
|
|
if r == 0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
// Two passes, one per axis. The horizontal one reads the fused pass's
|
|
// output and the vertical one reads the horizontal one's, which is the
|
|
// whole point: if the ping-pong were wired to hand the second pass the
|
|
// original again, the result would be a horizontal smear rather than a
|
|
// box, and the test asserting a symmetric ramp would say so.
|
|
["x", "y"]
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(axis, _)| DetailPass {
|
|
output_scale: 1,
|
|
label: if axis == 0 { "horizontal" } else { "vertical" },
|
|
radius: r,
|
|
// A convolution, not a list: nothing to bind at binding 3.
|
|
storage: Vec::new(),
|
|
uniforms: vec![
|
|
Uniform {
|
|
name: "radius",
|
|
value: r as f32,
|
|
},
|
|
Uniform {
|
|
name: "step_x",
|
|
value: if axis == 0 { 1.0 } else { 0.0 },
|
|
},
|
|
Uniform {
|
|
name: "step_y",
|
|
value: if axis == 0 { 0.0 } else { 1.0 },
|
|
},
|
|
],
|
|
wgsl: "// One axis of a separable box average.
|
|
//
|
|
// `tap` clamps at the border, so a kernel hanging off the edge averages the
|
|
// edge pixel repeatedly rather than averaging in black — which keeps a
|
|
// constant image constant, the cheapest property to check and the first one
|
|
// a broken border rule breaks.
|
|
let r = i32(radius);
|
|
let step = vec2<i32>(i32(step_x), i32(step_y));
|
|
var sum = vec3<f32>(0.0);
|
|
for (var i = -r; i <= r; i = i + 1) {
|
|
sum = sum + tap(coord, step * i);
|
|
}
|
|
c = sum / f32(2 * r + 1);"
|
|
.to_string(),
|
|
})
|
|
.collect()
|
|
}
|
|
}
|