Files
DarkRoom/core/dr-pipeline/src/ops/local_contrast.rs
T
dtourolle 6b1aac477d Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.

Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
2026-09-20 16:20:15 +02:00

1264 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TRACES: FR-DEV-3 | FR-DSP-1
//! Clarity and texture — local contrast at two scales.
//!
//! Both are unsharp masks. Both build a blurred *base*, subtract it from the
//! pixel to get a local contrast signal, and add a multiple of that signal
//! back. The only thing that separates them is the width of the blur, and
//! that single difference is the whole of what a photographer means by the two
//! words:
//!
//! - **Clarity** works at roughly a hundredth of the frame. At that scale the
//! base is a picture of where the *subject* is, so the difference is the
//! subject's modelling — the sense of a face standing away from its
//! background, of cloud having volume. It is the "punch" control, and it is
//! also the one that produces visible halos when it is got wrong, because a
//! forty-pixel overshoot along a skyline is not a subtlety.
//!
//! - **Texture** works a decade finer, at a few pixels. At that scale the base
//! is a picture of the *surface*, so the difference is skin, fabric, bark,
//! foliage. Its overshoot is a band two or three pixels wide, which the eye
//! reads as acutance rather than as a halo — which is exactly why it can be
//! pushed much harder than clarity without looking artificial.
//!
//! # Why two nodes and not one node with two parameters
//!
//! The tempting shape is a single `local_contrast` node with a `clarity` and a
//! `texture` slider, since they share every line of machinery. It is the wrong
//! one, for four reasons that all point the same way.
//!
//! **The scale is not a parameter, it is the definition.** Neither control
//! exposes a radius, and neither should: a texture slider with a large radius
//! *is* clarity, and offering the photographer that knob would ask them to
//! re-derive the distinction the two names already make. So the radius is a
//! constant of the node — and a node whose defining constant differs is a
//! different node, not a different setting.
//!
//! **Neutrality would have to be re-implemented by hand.** The rule the whole
//! pipeline rests on is that an operation at its defaults contributes nothing:
//! no code, no uniform, no dispatch. `is_active()` gives each of these that for
//! free. Merged, the node would be active whenever *either* slider had moved,
//! and would then need an internal guard per half to avoid dispatching a
//! forty-pixel blur for a control sitting at zero — hand-writing, in one
//! place, the thing the pipeline already does everywhere.
//!
//! **There is no dispatch to save.** The usual reason to merge two operations
//! is to fuse their work. Here there is nothing to fuse: the two blurs are
//! different blurs, by definition, so a merged node costs the same four passes
//! that two nodes cost, and costs them in the same order.
//!
//! **The sidecar, the history and the reset all read better.** `clarity.amount`
//! and `texture.amount` say what they are; `local_contrast.clarity` names a
//! concept no photographer asked for in order to reach one that they did.
//! Undo says "clarity", and double-tapping clarity to reset it leaves texture
//! alone — which is what a photographer who has just tuned texture expects.
//!
//! Against all that, the cost of two nodes is one shared implementation
//! parameterised by a [`Band`], below. The `attributes:` grouping is
//! `[detail]` either way, so it offers no argument in either direction.
//!
//! # Halos, and what is done about them
//!
//! A naive unsharp mask — `c + amount * (c - blur(c))` in linear light — is
//! the single most common way this feature is got wrong, and it fails in four
//! separate ways at once. Each is addressed by a specific decision here.
//!
//! **1. Work in stops, not in levels.** The base is a Gaussian mean of *log*
//! luminance, so the detail signal is a ratio: "this pixel is 0.4 stops
//! brighter than its surroundings". In linear light the same edge produces an
//! overshoot proportional to absolute brightness, so an edge against a bright
//! sky blows out while the identical edge in shadow does nothing — and the
//! amount that looked right stops looking right the moment exposure moves.
//! Stops also make the negative direction symmetric: −50 removes exactly the
//! proportion of local contrast that +50 adds.
//!
//! **2. Soft-limit the detail signal — this is the main halo control.** The
//! signal is passed through `t * tanh(d / t)` before it is used. Below the
//! threshold the function is the identity to within a percent, so structure
//! and surface detail pass through at full strength; far above it the output
//! saturates at `t` whatever the input, so a four-stop skyline transition
//! contributes no more overshoot than a one-stop one. That is the distinction
//! between *structure* and an *edge*, drawn on amplitude rather than by an
//! edge detector — a guided or bilateral base would draw it more precisely and
//! would cost several more full-frame passes to do it. `tanh` costs one
//! instruction and has no threshold artefact, because it is smooth everywhere;
//! a hard clamp would put a visible contour along the locus where the detail
//! signal crosses `t`.
//!
//! It is also the right thing on the negative side. At amount −100 an
//! unlimited unsharp mask subtracts the whole detail signal and dissolves
//! edges into mud; limited, it removes at most `t` stops, so negative clarity
//! softens surface and modelling while leaving real edges standing.
//!
//! **3. Move luminance only, and scale the triple.** The gain is applied as
//! `c * 2^stops`, which leaves chromaticity exactly where it was. Boosting the
//! three channels independently shifts hue and saturation wherever the detail
//! signal is large — that is a *coloured* fringe along every edge, arriving
//! from a control the photographer thinks of as contrast, and it is the hardest
//! kind of halo to attribute to its cause. `apply_tone_gain` already takes this
//! position for the tonal controls, for the same reason.
//!
//! **4. Taper clarity to nothing at both ends of the range.** Clarity is
//! midtone structure by definition, and its two worst halos are at the
//! extremes: a bright sky beside a dark subject blooms, and deep shadow goes
//! to mud. A weight of `1 - (2p - 1)^2` over the perceptual tone position
//! removes exactly those, and stops a *contrast* slider from creating a blown
//! highlight by pushing a recovered value back over one.
//!
//! Texture deliberately does **not** get this taper. Skin in a highlight and
//! fabric in a shadow are precisely what the control is for, and a fine-scale
//! overshoot at either end is a two-pixel band, not a bloom.
//!
//! # Why the radius is a fraction of the frame
//!
//! [`RenderScale`] names two units, and picking the wrong one produces an
//! effect that is a different photograph on screen and in the file. These two
//! controls take [`RenderScale::frame_fraction`] — the unit a mask feather is
//! already stored in — and not [`RenderScale::source_pixels`].
//!
//! The test is whose property the length is. Capture sharpening's radius
//! belongs to the *sensor*: it is about the lens's circle of confusion and the
//! demosaic's interpolation, both of which are facts about the file and
//! neither of which changes if the photograph is cropped. Clarity's radius
//! belongs to the *composition*: "separate the subject from its background" is
//! a statement about how much of the frame the subject occupies, and it stays
//! true when the same frame is printed large or viewed small. Crop into a
//! quarter of the frame and the subject now fills it, so the scale that models
//! it really has grown — which `frame_fraction` gives, because
//! [`crate::EditGraph::render_scale`] folds the crop in before this code runs.
//!
//! The practical consequence is that these two controls preview honestly at
//! every zoom level, which the acutance family cannot. There is no
//! [`RenderScale::resolves`] check here and no reason for one: at a small
//! render the kernel shrinks with the frame and keeps its proportions, and the
//! effect is the effect.
//!
//! Texture does eventually round to a zero-pixel kernel on a thumbnail, and
//! then contributes no pass at all. That is not the acutance family's problem
//! restated — it is the honest answer. A two-pixel surface structure is not
//! present in a 300-pixel rendering of the frame in the first place, and it
//! reappears, exactly, as soon as the view is zoomed.
//!
//! # What this costs
//!
//! Clarity's kernel is large — of the order of a hundred taps per pass at
//! preview resolution — and each half is the honest, exact separable Gaussian
//! rather than a sparse approximation of one. A strided kernel would be
//! several times cheaper and is deliberately not taken: undersampling an image
//! that is not band-limited aliases high-frequency content down into the base,
//! the base is then subtracted, and the aliasing arrives in the output as
//! low-frequency mottling across smooth gradients. Mottled skies are precisely
//! the artefact this control must not have.
//!
//! Run at the render size, that measured **34 ms at 4K** — seven times the
//! entire fused point chain, for one slider — which is `docs/dev/technical-debt.md`
//! TD-4 and is what [`Recipe::base_scale`] now answers. The base is computed on
//! a grid a quarter the size on each axis: a sixteenth of the pixels at a
//! quarter of the radius.
//!
//! **This is not the strided kernel wearing a hat**, and the difference is
//! exactly the paragraph above. A stride samples an image that is not band-
//! limited and aliases; the reduction *band-limits first* — that is what the
//! `reduce` pass is for and why it is a separate dispatch — and only then
//! samples. What is thrown away is content the base could not represent at any
//! resolution, because a Gaussian at σ = 26 px has nothing above one cycle per
//! 26 px in it and the quarter-scale grid carries one cycle per 8 px. So the
//! reduced base is not an approximation of the full-resolution base; it is the
//! same band-limited function, sampled where it is still fully determined.
//!
//! Which is also why [`LocalContrast::reduction`] steps down and why texture
//! never reduces at all. The argument holds only while the reduced grid can
//! still carry the Gaussian, and the moment it cannot, the honest answer is
//! the full-resolution one — which is the cheap case anyway, because the
//! viewport that produced it is small.
use std::marker::PhantomData;
use std::sync::{Arc, LazyLock};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::detail::{DetailPass, DetailStage, RenderScale};
use crate::operation::{Affects, Helper, Operation, Uniform};
use crate::ops::helpers;
pub const CLARITY: OpId = OpId("clarity");
pub const TEXTURE: OpId = OpId("texture");
/// The one parameter each control has. Both are called `amount`, so the
/// sidecar keys read `clarity.amount` and `texture.amount`.
pub const AMOUNT: ParamId = ParamId("amount");
/// How far the kernel runs, in standard deviations.
///
/// Two, not three. A Gaussian truncated at 2σ and renormalised keeps 95.4% of
/// its mass and is still a perfectly monotone low-pass; the missing tail
/// changes the base by less than the difference between two adjacent settings
/// of the slider, and it halves the tap count of the widest pass in the
/// pipeline.
const TRUNCATION: f32 = 2.0;
/// The smallest σ, in reduced pixels, worth running a Gaussian over.
///
/// One pixel, which with [`TRUNCATION`] is a five-tap kernel — the narrowest
/// that still has a shape. Below it the weights collapse towards a single tap
/// and the blur that survives is the reduce pass's box, which is a different
/// filter with a different edge response. See [`LocalContrast::reduction`].
const MIN_REDUCED_SIGMA: f32 = 1.0;
/// Everything that makes one of these two controls the control it is.
///
/// A struct rather than four associated constants so that the differences
/// between clarity and texture can be read side by side, which is the one
/// thing a reader comes to this file to do.
pub struct Recipe {
/// The static this operation's descriptor is built in. A `LazyLock`
/// rather than a reference to a descriptor, because a descriptor is an
/// owned value handed out as an `Arc` now (FR-PLG-2), and a `const`
/// recipe cannot hold an `Arc` — only a reference to the static that
/// makes one.
descriptor: &'static LazyLock<Arc<OpDescriptor>>,
helpers: &'static [Helper],
/// The Gaussian's σ, as a fraction of the frame's shorter edge.
sigma: f32,
/// Where the soft limit starts to bite, in stops. See the module
/// documentation, halo control (2).
threshold: f32,
/// Stops of local contrast added at full slider travel.
gain: f32,
/// Whether the effect is tapered away from the midtones. See halo control
/// (4) — true for clarity, false for texture, and that asymmetry is
/// deliberate.
midtone_taper: bool,
/// The most this band's base may be shrunk before it is blurred.
///
/// A ceiling, not the answer — [`LocalContrast::reduction`] steps it down
/// on a viewport too small to carry it. `1` refuses the optimisation
/// outright, which is the only correct value for a band whose σ is already
/// a few pixels.
///
/// Must be a power of two: the step-down halves.
base_scale: u32,
}
/// The band of spatial frequencies a control acts on.
///
/// The type parameter of [`LocalContrast`], because the scale is the *only*
/// thing that differs between clarity and texture and it differs at compile
/// time. One implementation, two nodes, and no branch anywhere that could
/// drift.
pub trait Band: Send + Sync + 'static {
const RECIPE: Recipe;
}
/// Clarity's band: roughly a hundredth of the frame.
pub struct Coarse;
/// Texture's band: a decade finer, a few pixels at any size.
pub struct Fine;
impl Band for Coarse {
const RECIPE: Recipe = Recipe {
descriptor: &CLARITY_DESCRIPTOR,
helpers: CLARITY_HELPERS,
// 1.2% of the shorter edge — about 48 px on a 4000 px frame. Wide
// enough that the base is the subject rather than the surface, narrow
// enough that the result is still local contrast and not a second
// exposure slider.
sigma: 0.012,
// A third of a stop. Clarity's whole difficulty is that a wide kernel
// sees an enormous detail signal at every real edge, so the limit has
// to bite early: at full travel the largest overshoot any edge can
// produce is a third of a stop, about 26%, before the midtone taper
// reduces it further.
threshold: 0.35,
gain: 1.0,
midtone_taper: true,
// A quarter, which is what `docs/dev/technical-debt.md` TD-4 bought back.
//
// σ is 1.2% of the shorter edge — 26 px at 4K — so the base holds no
// spatial frequency anywhere near the quarter-scale Nyquist of one
// cycle per 8 px. Computing it there is not an approximation of the
// full-resolution base; it is the same band-limited function sampled
// where it is still fully determined. What it costs is a sixteenth of
// the pixels at a quarter of the radius, about a sixty-fourth of the
// work, against the 34 ms this control measured at 4K.
//
// Not an eighth. σ/8 is 3.2 px at 4K and under two on a 1080p
// viewport, which is where the reduce pass's own box filter starts
// doing more of the blurring than the Gaussian does — and the halo
// behaviour this operation is careful about is a property of the
// Gaussian.
base_scale: 4,
};
}
impl Band for Fine {
const RECIPE: Recipe = Recipe {
descriptor: &TEXTURE_DESCRIPTOR,
helpers: TEXTURE_HELPERS,
// Exactly a decade below clarity, which is what makes the two controls
// separable in use: at a ten-to-one ratio of scales, neither can
// substantially do the other's job, so a photographer setting both is
// setting two things and not the same thing twice.
sigma: 0.0012,
// Three times clarity's, because at this scale the overshoot is a band
// two or three pixels wide and the eye reads that as acutance. Limiting
// it as hard as clarity would take the crispness out of the one control
// that exists to provide it.
threshold: 1.0,
// A narrow overshoot carries less visual weight than a wide one, so
// equal numbers on the two sliders should land at comparable strength.
gain: 1.25,
midtone_taper: false,
// Never reduced, and this is the reason the scale belongs to the band
// rather than to the stage. Texture's σ is a decade finer — 2.6 px at
// 4K — so a quarter-scale grid would not hold its base at all: the
// reduce pass's 4x4 box is already wider than the Gaussian it would be
// prefiltering, and what came back would be a blur of the wrong width
// rather than a cheaper blur of the right one.
base_scale: 1,
};
}
static CLARITY_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: CLARITY,
label: LocalizedKey("op.clarity"),
params: vec![ParamDescriptor::amount("amount", "param.clarity.amount")],
attributes: vec![Attribute::Detail],
})
});
static TEXTURE_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: TEXTURE,
label: LocalizedKey("op.texture"),
params: vec![ParamDescriptor::amount("amount", "param.texture.amount")],
attributes: vec![Attribute::Detail],
})
});
/// Luminance as a position on a logarithmic scale, floored.
///
/// Declared here rather than in `_helpers.yaml` because it is not a shared
/// idea: it exists so that the base can be a mean of *log* luminance, which is
/// the first of this file's four halo decisions and means nothing outside it.
const LOG_LUMA: Helper = Helper {
name: "log_luma",
source: "\
// Luminance in stops, floored fourteen stops below white.
//
// The floor is what makes the logarithm safe on the values this stage
// actually receives: the intermediate is unclipped and scene-referred, so a
// pixel can be exactly zero and an out-of-gamut colour can be negative. It
// sits far enough down that no real signal is affected — a fourteen-stop
// range is more than any sensor delivers — and it turns both of those into a
// very dark pixel rather than an infinity that would propagate through the
// blur into every pixel within the kernel's reach.
fn log_luma(c: vec3<f32>) -> f32 {
return log2(max(luminance(c), 0.00006103515625));
}",
};
/// How much of clarity applies at a given luminance.
const MIDTONE_WEIGHT: Helper = Helper {
name: "midtone_weight",
source: "\
// A parabola over the perceptual tone position: one in the midtones, zero at
// both black and white.
//
// Clarity is midtone structure by definition, and this is also where two of
// its three worst halos live — a bright sky beside a dark subject blooms, and
// deep shadow turns to mud. Tapering to nothing at both ends removes them, and
// stops a control the photographer reads as `contrast` from pushing a
// recovered highlight back over one and clipping it.
//
// `tone_position` rather than the raw value, so the taper is even to the eye
// rather than crowded into the bottom of the range the way a linear weight
// would be.
fn midtone_weight(luma: f32) -> f32 {
let p = 2.0 * tone_position(luma) - 1.0;
return 1.0 - p * p;
}",
};
static CLARITY_HELPERS: &[Helper] = &[
helpers::LUMINANCE,
LOG_LUMA,
helpers::TONE_POSITION,
MIDTONE_WEIGHT,
];
static TEXTURE_HELPERS: &[Helper] = &[helpers::LUMINANCE, LOG_LUMA];
/// An unsharp mask at one fixed scale.
///
/// See the module documentation for why the scale is a type parameter rather
/// than a parameter, and why there are two nodes rather than one.
pub struct LocalContrast<B: Band> {
/// −100…100, exactly as the slider reports it.
amount: f32,
band: PhantomData<B>,
}
/// Clarity: local contrast at roughly a hundredth of the frame.
pub type Clarity = LocalContrast<Coarse>;
/// Texture: local contrast a decade finer than clarity.
pub type Texture = LocalContrast<Fine>;
impl<B: Band> Default for LocalContrast<B> {
fn default() -> Self {
Self {
amount: 0.0,
band: PhantomData,
}
}
}
impl<B: Band> LocalContrast<B> {
pub fn new() -> Self {
Self::default()
}
/// Start from a slider position, for tests and presets.
pub fn with_amount(amount: f32) -> Self {
Self {
amount,
band: PhantomData,
}
}
/// The Gaussian's σ at this render, in **render pixels**.
///
/// The one conversion this operation performs, and the reason it happens
/// here rather than in WGSL: `frame_fraction` is named after its unit,
/// where a bare `f32` in a shader would not be.
pub fn sigma(&self, scale: RenderScale) -> f32 {
scale.frame_fraction(B::RECIPE.sigma)
}
/// The kernel radius at this render, in render pixels.
///
/// Exposed so a test can state what it expects without repeating the
/// rounding rule — a test that recomputed it would agree with a bug.
pub fn kernel(&self, scale: RenderScale) -> u32 {
(self.sigma(scale) * TRUNCATION).round().max(0.0) as u32
}
/// TRACES: FR-DSP-3
/// The factor this render's base is computed at — 1 meaning "the render
/// size", as everything did before TD-4.
///
/// [`Recipe::base_scale`] is a ceiling rather than the answer, because a
/// reduced grid still has to hold a Gaussian. At a quarter of a small
/// viewport clarity's σ falls under a pixel, and a kernel of one or two
/// taps is not a Gaussian — it is the reduce pass's own box filter with a
/// rounding error on top, which would make the control change character on
/// a window resize rather than merely get cheaper.
///
/// So the reduction steps down by halves until the reduced σ is worth
/// convolving: a quarter on a desktop viewport, a half on a small one,
/// none on a thumbnail. Stepping down rather than switching off keeps most
/// of the saving in the middle of the range, and the case it gives up on
/// is the one that was already cheap — the cost is `radius x pixels` and a
/// small viewport is small in both.
pub fn reduction(&self, scale: RenderScale) -> u32 {
let sigma = self.sigma(scale);
let mut reduction = B::RECIPE.base_scale.max(1);
while reduction > 1 && sigma / (reduction as f32) < MIN_REDUCED_SIGMA {
reduction /= 2;
}
reduction
}
/// Stops of local contrast at this slider position.
fn gain(&self) -> f32 {
self.amount / 100.0 * B::RECIPE.gain
}
/// The largest excursion this control can produce at its current setting,
/// in stops — the bound the soft limit guarantees.
///
/// `gain * threshold`, because `t * tanh(d / t)` saturates at `t` however
/// violent the edge. Public because it is the one number a test can hold
/// the halo to without re-deriving the shader: whatever the picture, no
/// pixel may move further than this. See the module documentation, halo
/// control (2).
pub fn overshoot_bound(&self) -> f32 {
self.gain().abs() * B::RECIPE.threshold
}
}
impl<B: Band> Operation for LocalContrast<B> {
fn descriptor(&self) -> Arc<OpDescriptor> {
Arc::clone(B::RECIPE.descriptor)
}
fn set_param(&mut self, _id: ParamId, value: f32) {
self.amount = value;
}
fn param(&self, _id: ParamId) -> f32 {
self.amount
}
fn is_active(&self) -> bool {
self.amount != 0.0
}
/// Never called: a neighbourhood operation contributes no fused fragment,
/// and `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)
}
fn helpers(&self) -> &'static [Helper] {
B::RECIPE.helpers
}
}
impl<B: Band> DetailStage for LocalContrast<B> {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
let sigma = self.sigma(scale);
let radius = self.kernel(scale);
// A kernel that rounded to nothing is not "blur by zero" — it is a
// scale this render is too small to show. Texture reaches this on a
// thumbnail and the honest answer is to contribute no pass, which is
// also what stops a degenerate one-tap Gaussian from burning two
// dispatches to copy the image.
if radius == 0 {
return Vec::new();
}
// 1/σ², so the shader's inner loop is a multiply rather than a
// division per tap.
let inv_variance = 1.0 / (sigma * sigma);
let shape = vec![
Uniform {
name: "radius",
value: radius as f32,
},
Uniform {
name: "inv_variance",
value: inv_variance,
},
];
let mut combine = shape.clone();
combine.push(Uniform {
name: "threshold",
value: B::RECIPE.threshold,
});
combine.push(Uniform {
name: "gain",
value: self.gain(),
});
let reduction = self.reduction(scale);
if reduction == 1 {
// The full-resolution form, unchanged: blur x into the scratch
// lane, then finish along y and apply the mask in one pass.
return vec![
DetailPass {
output_scale: 1,
label: "base",
radius,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: shape,
wgsl: BASE_X.to_string(),
},
DetailPass {
output_scale: 1,
label: "combine",
radius,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: combine,
wgsl: combine_body(B::RECIPE.midtone_taper, Base::Convolved),
},
];
}
// The reduced form. Four passes rather than two, and cheaper than the
// two by a factor of about `reduction²`: three of them run on a grid
// that many times smaller on each axis, and the one that does not is a
// single bilinear read.
//
// The σ and the radius are the band's own, divided — not recomputed
// from `RenderScale`, which knows nothing about this grid. Deriving
// them from the numbers the full-resolution path uses is what keeps
// the two forms the same filter, so that crossing the threshold in
// `reduction` does not change the picture.
let reduced_sigma = sigma / reduction as f32;
// At least one tap either side. `reduction` has already guaranteed
// σ >= MIN_REDUCED_SIGMA, so this floor is a belt on top of a brace.
let reduced_radius = ((reduced_sigma * TRUNCATION).round() as u32).max(1);
let reduced_shape = vec![
Uniform {
name: "radius",
value: reduced_radius as f32,
},
Uniform {
name: "inv_variance",
value: 1.0 / (reduced_sigma * reduced_sigma),
},
];
// The combining pass no longer convolves anything, so it needs neither
// the radius nor the variance — only the two numbers the unsharp mask
// itself is made of.
let mask = vec![
Uniform {
name: "threshold",
value: B::RECIPE.threshold,
},
Uniform {
name: "gain",
value: self.gain(),
},
];
vec![
DetailPass {
output_scale: reduction,
label: "reduce",
// Reads only the block it writes, so it reaches no further
// than the pixel it is producing and a tile needs no halo for
// it. The halo the *chain* needs comes from the blurs below.
radius: 0,
storage: Vec::new(),
uniforms: vec![Uniform {
name: "reduction",
value: reduction as f32,
}],
wgsl: REDUCE.to_string(),
},
DetailPass {
output_scale: reduction,
label: "base-x",
radius: reduced_radius,
storage: Vec::new(),
uniforms: reduced_shape.clone(),
wgsl: reduced_blur(Axis::X),
},
DetailPass {
output_scale: reduction,
label: "base-y",
radius: reduced_radius,
storage: Vec::new(),
uniforms: reduced_shape,
wgsl: reduced_blur(Axis::Y),
},
DetailPass {
output_scale: 1,
label: "combine",
// One bilinear read of the reduced chain, which reaches one
// reduced pixel — `reduction` render pixels — around itself.
// Stated rather than left at zero because an understated
// radius is a tile seam, and a seam is worth more than the
// three lines it costs to be accurate here.
radius: reduction,
storage: Vec::new(),
uniforms: mask,
wgsl: combine_body(B::RECIPE.midtone_taper, Base::Reduced),
},
]
}
}
/// Which way a separable half runs.
enum Axis {
X,
Y,
}
/// Where the combining pass finds the base it subtracts.
enum Base {
/// Convolved along y by the combining pass itself, out of the scratch
/// lane the previous pass wrote. The full-resolution form.
Convolved,
/// Already finished, on the reduced chain, and read back up.
Reduced,
}
/// Half of the base, along x.
///
/// Deliberately does not touch `c`: the pass after this one needs the
/// *original* colour as well as the blur, which is what an unsharp mask is and
/// why the `aux` lane exists at all.
const BASE_X: &str = "\
// Half of a separable Gaussian, over log luminance, along x.
//
// The colour is left exactly as it arrived. An unsharp mask needs the blur and
// the original in the same place at the same time, and the ping-pong hands
// each pass only what the pass before it wrote — so the blur travels in `aux`
// and the colour rides through untouched. See `DetailPass::wgsl`.
//
// Weights are evaluated rather than tabulated: a table would need a uniform
// array sized for the largest kernel any resolution could ask for, and `exp`
// is cheaper than the bandwidth that array would cost.
let r = i32(radius);
var sum = 0.0;
var weight = 0.0;
for (var i = -r; i <= r; i = i + 1) {
let f = f32(i);
let w = exp(-0.5 * f * f * inv_variance);
sum = sum + w * log_luma(tap(coord, vec2<i32>(i, 0)));
weight = weight + w;
}
// Normalised by the weights actually summed, not by an analytic constant, so
// truncating the Gaussian at 2σ leaves a true mean rather than a slightly dark
// one — and so a kernel clamped at the image border averages the pixels that
// exist.
aux = sum / weight;";
/// Band-limit the image onto the reduced grid, in log luminance.
///
/// A pass of its own rather than something the first blur half does on the
/// way past, because it is a different filter doing a different job: this one
/// exists so that the Gaussian's *input* is representable on the coarse grid.
/// Sampling every fourth pixel instead would alias — a shimmer that changes
/// when the viewport is resized, which is the classic way a mip-based blur
/// goes wrong and is very hard to attribute to a clarity slider.
///
/// A box over exactly the block the output pixel covers. Not a wider or
/// prettier prefilter: the Gaussian that follows is 8σ wide on this grid, so
/// what a better prefilter would buy is a correction of a fraction of a
/// reduced pixel to a curve four pixels across, and it would cost taps on the
/// only pass here that reads the full-resolution image.
///
/// **In log luminance, not linear.** The base is a mean of logarithms — that
/// is what makes `detail` a ratio and the whole operation exposure-invariant
/// (see the module documentation, halo control 1). Averaging linear values
/// here and taking the logarithm later is a different number, and the
/// difference is precisely the local contrast this operation exists to
/// measure: it would be quietly subtracted out of every block.
const REDUCE: &str = "\
// The colour rides through untouched, as it does in every pass of this
// operation — though here it is untouched and also unused: the reduced chain
// carries a scalar, and the colour the combining pass subtracts from is the
// full-resolution one it reads from the other chain.
let s = i32(reduction);
var sum = 0.0;
for (var y = 0; y < s; y = y + 1) {
for (var x = 0; x < s; x = x + 1) {
sum = sum + log_luma(tap(coord, vec2<i32>(x, y)));
}
}
aux = sum / f32(s * s);";
/// One half of the reduced separable Gaussian.
///
/// Reads the scratch lane rather than the colour, which is the one line that
/// differs from [`BASE_X`]: by this point the log-luminance conversion has
/// already been done, once, by the reduce pass. Doing it again per tap would
/// be a logarithm inside the inner loop for a value that cannot have changed.
fn reduced_blur(axis: Axis) -> String {
let offset = match axis {
Axis::X => "vec2<i32>(i, 0)",
Axis::Y => "vec2<i32>(0, i)",
};
format!(
"\
// Half of a separable Gaussian over the reduced base, in log luminance.
//
// Weights are evaluated rather than tabulated, as in `BASE_X` and for the same
// reason — and here the loop is a quarter as long, which is the whole point.
let r = i32(radius);
var sum = 0.0;
var weight = 0.0;
for (var i = -r; i <= r; i = i + 1) {{
let f = f32(i);
let w = exp(-0.5 * f * f * inv_variance);
sum = sum + w * tap_aux(coord, {offset});
weight = weight + w;
}}
// Normalised by the weights actually summed, so a kernel clamped at the
// border averages the pixels that exist rather than fading towards zero.
aux = sum / weight;"
)
}
/// The second pass: finish the base along y, then apply the mask.
///
/// Generated rather than constant because the midtone taper is present for
/// clarity and absent for texture. Emitting the line only where it applies
/// keeps texture's shader honest about not having one, and saves it a uniform
/// and two helper functions it would never call.
fn combine_body(midtone_taper: bool, base: Base) -> String {
// Where the base comes from — the one thing that differs between the two
// forms. Everything below this line is the unsharp mask itself, written
// once, so the reduced form cannot drift into being a different operation
// from the full-resolution one it replaces.
let base = match base {
Base::Convolved => "\
// The other half of the base, then the unsharp mask itself.
//
// `tap_aux` reads the previous pass's log-luminance blur, while `c` is still
// the colour the colour pass produced — which is the arrangement that makes an
// unsharp mask expressible in a chain that hands on one texture per pass.
let r = i32(radius);
var sum = 0.0;
var weight = 0.0;
for (var i = -r; i <= r; i = i + 1) {
let f = f32(i);
let w = exp(-0.5 * f * f * inv_variance);
sum = sum + w * tap_aux(coord, vec2<i32>(0, i));
weight = weight + w;
}
let base = sum / weight;"
.to_string(),
Base::Reduced => "\
// The base, finished on the reduced chain and read back up bilinearly. `c` is
// the full-resolution colour, straight off the other chain — which is why the
// reduced passes had to leave that chain alone, and why this pass reads two
// textures rather than one.
//
// One read where the full-resolution form runs a 105-tap convolution. That
// difference *is* TD-4.
let base = reduced_at(coord);"
.to_string(),
};
let weight = if midtone_taper {
"\n\
// Clarity only: tapered to nothing at both ends of the range. See\n\
// `midtone_weight` for what that is worth against a halo.\n\
let stops = gain * shaped * midtone_weight(luminance(c));"
} else {
"\n\
// Texture is deliberately *not* tapered towards black and white.\n\
// Skin in a highlight and fabric in a shadow are what the control is\n\
// for, and at this scale an overshoot is two pixels wide — acutance,\n\
// not a bloom.\n\
let stops = gain * shaped;"
};
format!(
"\
{base}
// Local contrast, in **stops**. Both terms are logarithms, so this is a ratio:
// `detail` says how much brighter this pixel is than its surroundings, and
// says it in a unit that means the same thing in a highlight and in a shadow.
// The linear-light difference an unsharp mask usually takes does not, which is
// why it blows out bright edges and does nothing to dark ones.
let detail = log_luma(c) - base;
// The halo control. Below the threshold `tanh` is the identity to within a
// percent, so structure and surface pass through at full strength; far above
// it the output saturates at the threshold, so a four-stop edge contributes no
// more overshoot than a one-stop one. Smooth everywhere, so unlike a clamp it
// leaves no contour along the locus where the detail signal crosses it.
let shaped = threshold * tanh(detail / threshold);
{weight}
// Applied as a scale on the whole triple, which leaves chromaticity exactly
// where it was. Boosting the channels independently would put a *coloured*
// fringe along every edge, arriving from a control the photographer reads as
// contrast — the hardest kind of halo to attribute to its cause.
c = c * exp2(stops);"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detail::compose_detail;
use dr_types::ColourSpace;
/// The two controls, as the graph would hold them.
fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> {
vec![
Box::new(Clarity::with_amount(clarity)),
Box::new(Texture::with_amount(texture)),
]
}
fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail {
compose_detail(&ops(clarity, texture), scale, ColourSpace::Srgb)
}
#[test]
fn both_controls_start_neutral_and_cost_nothing() {
// The rule the whole pipeline rests on. An unedited photograph must
// not pay for a clarity slider nobody has touched — and, because these
// are the widest kernels in the pipeline, "nothing" here is a large
// amount of nothing.
assert!(!Clarity::new().is_active());
assert!(!Texture::new().is_active());
assert!(composed(0.0, 0.0, RenderScale::full((2000, 1500))).is_empty());
}
#[test]
fn one_control_moving_does_not_dispatch_the_other() {
// The concrete reason these are two nodes rather than one with two
// sliders. Merged, the node would be active whenever either had moved
// and would need a hand-written guard per half to avoid running a
// forty-pixel blur for a control sitting at zero.
let scale = RenderScale::full((2000, 1500));
let only_clarity = composed(50.0, 0.0, scale);
// Four at this viewport, because clarity's base is computed reduced
// here — reduce, two blur halves, combine. The number is the band's
// and the viewport's, not a constant; what this test is about is that
// *all* of them belong to clarity.
assert_eq!(only_clarity.len(), 4, "one operation, its own passes");
assert!(only_clarity
.passes
.iter()
.all(|p| p.label.starts_with("clarity/")));
// Both on: clarity's four plus texture's two. Texture stays at the
// render size whatever the viewport — its band is a decade finer, so
// a reduced grid could not hold its base — and that asymmetry is the
// reason the scale belongs to the band rather than to the stage.
let both = composed(50.0, 50.0, scale);
assert_eq!(both.len(), 6);
assert_eq!(
both.passes
.iter()
.filter(|p| p.label.starts_with("texture/"))
.count(),
2
);
assert!(both
.passes
.iter()
.filter(|p| p.label.starts_with("texture/"))
.all(|p| p.output_scale == 1));
}
#[test]
fn the_two_controls_differ_by_a_decade_of_scale() {
// The whole point of there being two of them. If these ever converge,
// one of the controls has stopped doing its job and the second slider
// has become a duplicate of the first.
let scale = RenderScale::full((4000, 3000));
let clarity = Clarity::with_amount(100.0).kernel(scale);
let texture = Texture::with_amount(100.0).kernel(scale);
// Both derived rather than observed, because a number copied out of a
// test run agrees with whatever the code did on the day.
//
// `frame_fraction` takes the *shorter* edge: min(4000, 3000) = 3000.
// clarity σ = 0.012 × 3000 = 36.0 → round(36.0 × 2) = 72
// texture σ = 0.0012 × 3000 = 3.6 → round( 3.6 × 2) = 7
//
// (7.2 rounds down, which is why texture is 7 and not 8 — the
// truncation is two sigmas, and two sigmas of 3.6 px is 7.2 px.)
assert_eq!(clarity, 72);
assert_eq!(texture, 7, "a decade finer");
assert!(
clarity >= texture * 8,
"clarity {clarity} and texture {texture} are not separable scales"
);
}
#[test]
fn a_radius_is_a_fraction_of_the_frame_and_not_a_count_of_source_pixels() {
// TRACES: FR-DSP-1 — the decision this whole file's units rest on.
//
// Clarity is compositional: "separate the subject from its background"
// is a statement about how much of the frame the subject occupies, and
// it stays true at every size the frame is rendered at. So the kernel
// must cover the same *proportion* of the picture on a proxy as in the
// export, which is what `frame_fraction` gives and what
// `source_pixels` would not.
// The declared proportion is σ × 2 = 0.012 × 2 = 0.024 of the shorter
// edge, and the tolerance is what rounding to a whole pixel costs:
//
// 300 → round(0.024 × 300) = 7 → 7/300 = 0.02333 (−0.00067)
// 1500 → round(0.024 × 1500) = 36 → 36/1500 = 0.02400 ( 0.00000)
// 4500 → round(0.024 × 4500) = 108 → 108/4500 = 0.02400 ( 0.00000)
//
// Half a pixel over the smallest frame here is 0.5/300 = 0.0017, so
// 0.002 is the tolerance a rounded kernel can actually hold — and it
// is a bound, not a fitted number.
let sizes = [(400u32, 300u32), (2000, 1500), (6000, 4500)];
let proportions: Vec<f32> = sizes
.iter()
.map(|&(w, h)| {
let scale = RenderScale::full((w, h));
Clarity::with_amount(60.0).kernel(scale) as f32 / w.min(h) as f32
})
.collect();
for p in &proportions {
assert!(
(p - 0.024).abs() < 0.002,
"the kernel drifted from its declared fraction: {proportions:?}"
);
}
// And the contrast with the other unit, stated rather than implied: on
// a one-third proxy a source-pixel radius *shrinks* to a third of the
// proportion it had, which is the bug this choice avoids.
//
// ratio = (2000/6000 + 1500/4500) / 2 = 1/3
// frame_fraction(0.012) → 0.012 × 1500 = 18 px, the same 18 px the
// export of that framing gets, because the
// unit is a proportion of what is rendered;
// source_pixels(96) → 96 × 1/3 = 32 px, a third of the reach
// the same edit had at full size.
//
// 96 is clarity's kernel at 4000 px of shorter edge, so the second line
// is what this control would have done had it been written in the
// acutance family's unit.
let proxy = RenderScale::new((2000, 1500), (6000, 4500));
let clarity = Clarity::with_amount(60.0);
assert_eq!(
clarity.kernel(proxy),
clarity.kernel(RenderScale::full((2000, 1500)))
);
assert!(
proxy.source_pixels(96.0) < 40.0,
"the same length in the other unit would have collapsed"
);
}
#[test]
fn texture_stops_rather_than_lying_when_the_render_is_too_small() {
// A two-pixel surface structure is not present in a 300-pixel
// rendering of the frame, so the honest thing is to contribute no
// pass. Unlike the acutance family this is not an approximation being
// hidden: zoom in and the kernel comes back, exactly.
//
// shorter edge = 120
// texture σ = 0.0012 × 120 = 0.144 → round(0.288) = 0 — no pass
// clarity σ = 0.012 × 120 = 1.44 → round(2.88) = 3 — two passes
//
// Texture's kernel crosses back above zero at round(0.0024 × e) ≥ 1,
// i.e. a shorter edge of about 209 px, which is a little larger than a
// contact sheet thumbnail and a great deal smaller than any view a
// photographer judges surface detail in.
let thumbnail = RenderScale::full((160, 120));
assert_eq!(Texture::with_amount(100.0).kernel(thumbnail), 0);
assert!(Texture::with_amount(100.0).passes(thumbnail).is_empty());
// Clarity is a hundred times wider and survives, which is what a
// thumbnail should show: the modelling, not the surface.
assert!(Clarity::with_amount(100.0).kernel(thumbnail) > 0);
assert_eq!(Clarity::with_amount(100.0).passes(thumbnail).len(), 2);
}
#[test]
fn the_first_pass_hands_the_original_colour_to_the_second() {
// The property that makes an unsharp mask expressible in a chain that
// passes on one texture per pass. If the blur pass ever writes `c`,
// the combining pass has nothing to subtract the base *from* and the
// operation silently becomes a blur.
// Both forms, because they are two chains and the property has to
// hold in each. A thumbnail is too small to carry a reduced base and
// takes the two-pass path; a desktop viewport takes the four-pass one.
for scale in [
RenderScale::full((160, 120)),
RenderScale::full((2000, 1500)),
] {
let passes = Clarity::with_amount(50.0).passes(scale);
let (combine, blurs) = passes.split_last().expect("clarity is active");
for blur in blurs {
assert!(
!blur.wgsl.contains("c = "),
"{} must leave the colour alone: {}",
blur.label,
blur.wgsl
);
assert!(
blur.wgsl.contains("aux ="),
"{} must put its result in the scratch lane",
blur.label
);
}
// However the base was arrived at, the mask subtracts it from the
// colour the *colour* pass produced. That is the whole property:
// an unsharp mask needs the blur and the original together, and
// neither chain may have overwritten the original on the way.
assert!(combine.wgsl.contains("log_luma(c) - base"));
}
// And the two forms differ in exactly one place — where `base` came
// from. A reduced combine does no convolution at all.
let reduced = Clarity::with_amount(50.0).passes(RenderScale::full((2000, 1500)));
let combine = reduced.last().expect("clarity is active");
assert!(combine.wgsl.contains("let base = reduced_at(coord);"));
assert!(
!combine.wgsl.contains("tap_aux("),
"a reduced combine has nothing left to convolve"
);
}
#[test]
fn the_declared_radius_is_the_halo_a_tile_would_need() {
// ARCH §5.3 grows a tile by the widest reach of the pass computing it,
// and nothing can infer that from the WGSL because the offsets come
// from a uniform. An understated radius shows as a seam at every tile
// boundary — an artefact that reads as a driver bug.
//
// shorter edge = 1500
// clarity → round(0.024 × 1500) = 36 px
// texture → round(0.0024 × 1500) = 4 px
//
// so the halo the four passes together need is clarity's 36, taken as
// the maximum rather than the sum: the passes are separate dispatches,
// and a tile is grown for whichever of them reaches furthest.
let scale = RenderScale::full((2000, 1500));
let composed = composed(50.0, 50.0, scale);
let clarity = Clarity::with_amount(50.0).kernel(scale);
assert_eq!(composed.radius(), clarity, "the widest pass sets the halo");
for pass in &composed.passes {
// The reduce pass is the one honest exception: it reads exactly
// the block it writes and no further, so a tile computing it needs
// no halo at all. Every other pass reaches somewhere and must say
// so.
if pass.label.ends_with("/reduce") {
assert_eq!(pass.radius, 0, "the reduce pass reads only its own block");
continue;
}
assert!(pass.radius > 0, "{} declared no reach", pass.label);
}
// The equality above is the claim worth restating: a reduced base
// reaches exactly as far across the photograph as the full-resolution
// one it replaces. `radius x output_scale`, 9 x 4 against 36, which is
// what makes the reduction invisible to a tile scheduler.
let reduced = Clarity::with_amount(50.0);
assert_eq!(reduced.reduction(scale), 4);
let base_x = composed
.passes
.iter()
.find(|p| p.label.ends_with("/base-x"))
.expect("a reduced chain has an x half");
assert_eq!(base_x.radius * base_x.output_scale, clarity);
}
#[test]
fn the_halo_limit_is_in_the_shader_and_bounds_the_overshoot() {
// The single most common way this feature is got wrong. `tanh`
// saturates at the threshold, so the largest overshoot a full-travel
// slider can produce is `gain * threshold` stops however violent the
// edge — a bound that holds by construction rather than by tuning.
let passes = Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)));
let combine = passes.last().expect("clarity is active");
assert!(combine
.wgsl
.contains("threshold * tanh(detail / threshold)"));
let bound = |u: &[Uniform]| {
let get = |n| u.iter().find(|x| x.name == n).unwrap().value;
get("gain").abs() * get("threshold")
};
// A third of a stop for clarity, before the midtone taper takes more
// off; a little over a stop for texture, whose overshoot is two pixels
// wide and reads as acutance.
//
// clarity gain = 100/100 × 1.00 = 1.00 ; × 0.35 = 0.35 stops (26%)
// texture gain = 100/100 × 1.25 = 1.25 ; × 1.00 = 1.25 stops
//
// Both at full travel, which is what makes them a bound and not a
// measurement: no picture, and no edge in any picture, can produce more.
assert!((bound(&combine.uniforms) - 0.35).abs() < 1e-6);
let texture_passes = Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)));
let texture = texture_passes.last().expect("texture is active");
assert!((bound(&texture.uniforms) - 1.25).abs() < 1e-6);
}
#[test]
fn only_clarity_tapers_towards_black_and_white() {
// The asymmetry is the deliberate part: texture must work on skin in a
// highlight and fabric in a shadow, which is exactly where clarity
// must not.
let scale = RenderScale::full((2000, 1500));
let clarity_passes = Clarity::with_amount(50.0).passes(scale);
let texture_passes = Texture::with_amount(50.0).passes(scale);
let clarity = clarity_passes.last().expect("clarity is active");
let texture = texture_passes.last().expect("texture is active");
assert!(clarity.wgsl.contains("midtone_weight(luminance(c))"));
assert!(!texture.wgsl.contains("midtone_weight"));
// And texture does not carry the helpers it would need for one, so its
// shader says what it does rather than merely not calling it.
assert!(Texture::new()
.helpers()
.iter()
.any(|h| h.name == "log_luma"));
assert!(!Texture::new()
.helpers()
.iter()
.any(|h| h.name == "midtone_weight"));
assert!(Clarity::new()
.helpers()
.iter()
.any(|h| h.name == "midtone_weight"));
}
#[test]
fn the_amount_is_symmetric_about_neutral() {
// Working in stops is what buys this: −50 removes exactly the
// proportion of local contrast that +50 adds, at every brightness.
// In linear light it would not, and the pair of settings that looked
// balanced would depend on exposure.
let scale = RenderScale::full((2000, 1500));
let up = Clarity::with_amount(50.0).passes(scale);
let down = Clarity::with_amount(-50.0).passes(scale);
let gain = |p: &[DetailPass]| {
p.last()
.expect("clarity is active")
.uniforms
.iter()
.find(|u| u.name == "gain")
.unwrap()
.value
};
assert!((gain(&up) + gain(&down)).abs() < 1e-6);
// Same kernel either way — the direction is a sign, not a scale. Read
// off the blur halves rather than the first pass, which since the
// reduced base exists is a downscale carrying no radius at all.
let radii = |p: &[DetailPass]| {
p.iter()
.map(|d| (d.radius, d.output_scale))
.collect::<Vec<_>>()
};
assert_eq!(radii(&up), radii(&down));
}
#[test]
fn every_pass_declares_a_uniform_block_the_gpu_will_accept() {
// A uniform struct whose size is not a multiple of 16 is rejected
// outright by the WGSL uniform address space rules, and arrives as a
// compilation failure against generated source.
for pass in composed(70.0, -40.0, RenderScale::full((1600, 1200))).passes {
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
assert!(
pass.uniforms.iter().all(|v| v.is_finite()),
"{} uploaded a non-finite uniform",
pass.label
);
}
}
#[test]
fn the_parameter_round_trips_under_its_own_id() {
// Mechanical, and exactly why it is worth checking: a `set_param` that
// read the wrong field would look perfect and silently break the
// sidecar.
let mut op = Clarity::new();
op.set_param(AMOUNT, -37.0);
assert_eq!(op.param(AMOUNT), -37.0);
assert_eq!(op.descriptor().id, CLARITY);
assert_eq!(Texture::new().descriptor().id, TEXTURE);
}
}