`Operation::descriptor()` returned `&'static OpDescriptor`, and that lifetime
is the whole reason a build-time node is free and a run-time node is
impossible: only a compile-time literal can satisfy it, so no amount of
reading `ops/*.yaml` at startup could ever produce a descriptor the rest of
the application would accept. FR-PLG-2 says a bundled operation and a
third-party plugin are the same kind of thing, differing only in where the
file was found — and a lifetime outsiders cannot meet is exactly the second,
weaker format that requirement forbids.
So a descriptor is now owned and handed out as `Arc<OpDescriptor>`, with `Vec`
where it held `&'static` slices. `Arc` rather than a `&self`-borrowed
reference because the callers want to *keep* it: the develop panel collects
descriptors and then mutates the graph, and a borrow would tie the
descriptor's lifetime to a borrow of the operation it came from, which is the
one thing `&'static` was doing right.
The identifier newtypes deliberately did not follow. `ParamId` is `Copy`, is
compared in `match` arms against generated constants, is a map key in the
sidecar and history, and reaches Slint model rows; an `Arc<str>` there would
cost a refcount on every one of those and would take `match id { EXPOSURE =>
.. }` away from the generated code. They gain an interner instead, which is
honest about its lifetime rather than pretending to one — the set of ids is
bounded by deduplication and is process-lifetime by construction, because the
sidecar on disk names its parameters and an id has to stay resolvable for as
long as any edit naming it can be opened.
No behaviour changes. Every descriptor that was a `static` is a `LazyLock`
initialiser now, `Operation::helpers` borrows from `self` instead of being
`'static` so a future run-time node can own its list, and `Warp` and `Framing`
follow `Operation` so there is one shape rather than two.
The one place a descriptor is read per frame is `compose_full`, which takes
`descriptor().id` to prefix each active operation's uniforms, and `dr-ui`
composes on every frame it draws. That is a dozen atomic increments beside a
composition that is already building several kilobytes of WGSL on the same
call; it is noted at the trait method rather than left for a profiler to find.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
6.5 KiB
Rust
181 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 {
|
|
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()
|
|
}
|
|
}
|