//! 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/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>, 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/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> = 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> = 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 { 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 { /// −100…100, exactly as the slider reports it. amount: f32, band: PhantomData, } /// Clarity: local contrast at roughly a hundredth of the frame. pub type Clarity = LocalContrast; /// Texture: local contrast a decade finer than clarity. pub type Texture = LocalContrast; impl Default for LocalContrast { fn default() -> Self { Self { amount: 0.0, band: PhantomData, } } } impl LocalContrast { 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 Operation for LocalContrast { fn descriptor(&self) -> Arc { 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 { 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 DetailStage for LocalContrast { fn passes(&self, scale: RenderScale) -> Vec { 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(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(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(i, 0)", Axis::Y => "vec2(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(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> { 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 = 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::>() }; 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); } }