Files
DarkRoom/core/dr-pipeline/src/detail/probe.rs
T
dtourolleandClaude Opus 5 6997c0f7ac Let a detail pass carry a list, not only a kernel
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>
2026-08-26 20:00:24 +02:00

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()
}
}