//! 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> = 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 { 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 { 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 { 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(step_x), i32(step_y)); var sum = vec3(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() } }