Every neighbourhood pass so far has been a convolution, whose whole description fits in the uniform block because its structure fixes how many numbers it needs. Spot removal is not that shape: sixty-four repairs and one repair are the same shader with a different buffer behind it. So a pass may declare `storage`, which arrives at binding 3 as `array<vec4<f32>>` with `arrayLength` in scope. The alternative — packing the list into uniforms — needs a fixed maximum paid for on every frame, a composer that can emit vec4 fields because a uniform array's stride is 16 whatever it holds, and it gives the next operation that wants a table nothing to build on. The property worth having is what stays out of the generated source: the count is in the buffer, so placing the tenth spot uploads 512 bytes and reuses the compiled pipeline, exactly as moving a slider does for the fused pass. `changing_the_list_does_not_recompile` is that, asserted. One bind group entry rather than two more layouts, and one placeholder buffer allocated in `new` rather than sixteen bytes per pass per frame — a zero-length storage buffer cannot be bound, and per-frame allocation is what this module's documentation exists to refuse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
178 lines
6.3 KiB
Rust
178 lines
6.3 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 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: OpDescriptor = OpDescriptor {
|
|
id: OpId("detail_probe"),
|
|
label: LocalizedKey("op.detail_probe"),
|
|
params: &[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: &[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) -> &'static OpDescriptor {
|
|
&DESCRIPTOR
|
|
}
|
|
|
|
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 {
|
|
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()
|
|
}
|
|
}
|