//! TRACES: FR-DEV-18 | FR-DSP-1 //! Dehaze — measuring the veil distance puts over a subject, and dividing it //! back out. //! //! Haze is not a tone problem wearing a spatial disguise, which is why none of //! the controls already in the chain can remove it. Atmospheric scattering //! composites an *airlight* over the scene in proportion to how far away each //! part of it is: //! //! ```text //! I = J·t + A·(1 − t), t = e^(−β·d) //! ``` //! //! `J` is the scene, `A` the airlight, `t` the transmission and `d` the //! distance. The two things it does — lifting the black point towards `A` and //! compressing contrast towards it — are both *per-pixel*, because `d` is. A //! black point that clears the mountains crushes the foreground; a contrast //! curve that clears the mountains does the same. So the operation has to //! estimate `t` at every pixel, and estimating it is the whole of the work. //! //! # How the transmission is estimated //! //! The dark-channel prior: in a haze-free patch of an ordinary photograph, at //! least one of the three channels is close to zero *somewhere* — a shadow, a //! dark surface, a saturated colour whose complementary channels are empty. //! Wherever that local minimum sits well above zero instead, something //! additive has lifted it, and the amount it has been lifted by is `A·(1 − t)`. //! So the veil is a local minimum over the channels and over a patch, and the //! transmission follows from it. //! //! The prior fails on a genuinely bright, genuinely haze-free subject — snow, //! a white wall filling the patch — where it reads the brightness as veil and //! this operation darkens it. That is the known failure of the prior and it is //! why the amount is a slider a photographer sets by eye rather than an //! automatic correction applied on open. //! //! # Why the airlight is taken as neutral, and as one //! //! The literature estimates `A` from the brightest few pixels of the dark //! channel — a *whole-frame* reduction, which this stage cannot perform. The //! detail chain hands each pass the pass before it at a fixed fraction of the //! render size; there is no reduction to a single value in it, and adding one //! would be a second chain of `log(n)` dispatches whose shape changes with //! every viewport. //! //! It is not needed. Airlight is the illuminant scattered towards the camera, //! and white balance is the first node in the chain — by the time the detail //! stage runs, the illuminant has already been driven to neutral, so `A` is //! grey and only its *magnitude* is unknown. Taking that magnitude as one — as //! bright as diffuse white — makes the estimated veil a fixed multiple of the //! true one, and a fixed multiple of the veil is exactly what the amount //! slider already scales. The unknown lands on a control the photographer is //! setting by eye anyway, rather than on a reduction the architecture would //! have to grow to compute. //! //! # In linear light, deliberately unlike clarity //! //! [`crate::ops::local_contrast`] works in stops, because a perceptual local //! contrast control has to mean the same thing in a highlight and in a shadow. //! This operation is the opposite case: it inverts a physical model stated in //! linear radiance, where the veil is an *additive* term. Taking logarithms //! first would turn the subtraction into something that is not the inverse of //! anything, and the correction would stop being a correction. The intermediate //! this stage reads is linear and scene-referred, so the model applies to it as //! written. //! //! # The radius is a fraction of the frame //! //! [`RenderScale`] names two units and choosing the wrong one is the mistake a //! neighbourhood operation makes silently. The patch is compositional, so it //! takes [`RenderScale::frame_fraction`] — the unit clarity, texture and a mask //! feather already use — and never [`RenderScale::source_pixels`]. //! //! The test is whose property the length is. Capture sharpening's radius //! belongs to the *sensor*: it stands for the spread of a point across //! photosites and does not change when the frame is cropped. This one belongs //! to the *picture*: the patch has to be large enough to contain something //! dark and small enough that the veil it measures is still local, and both of //! those are statements about how much of the composition it covers. Crop into //! a quarter of the frame and the depth structure now fills it, so the patch //! that measures it really has grown — which `frame_fraction` gives, because //! [`crate::EditGraph::render_scale`] folds the crop in before this code runs. //! //! Stated in raw pixels it would be a different photograph on screen and in //! the file: the develop view renders at whatever the viewport needs //! (FR-DSP-1), so a patch tuned at one-third scale would be three times too //! narrow relative to the picture in the export — and the export is the only //! render anybody keeps. //! //! Unlike texture, the pass is **not** dropped when the patch rounds small. //! Texture's absence on a thumbnail is the honest answer, because a two-pixel //! surface structure is not present in a 300-pixel rendering of the frame at //! all. Dehaze changes the overall tone and colour of the picture, and a //! thumbnail disagreeing with the develop view about *that* reads as a bug //! rather than as a scale. So the patch is floored at one pixel instead. //! //! # What it costs, and the identity that makes it affordable //! //! A minimum over a patch is separable, as a Gaussian is: minimum along x, //! then along y. That alone is not enough. The patch is 1% of the shorter edge //! — 61 taps across at 4K — and two passes of 61 taps is the arithmetic that //! measured 34 ms for clarity and became `docs/dev/technical-debt.md` TD-4. //! //! A minimum has a property a Gaussian does not: **erosions compose by adding //! their structuring elements**. The minimum over a contiguous run of `d` //! pixels, followed by the minimum over `k` pixels spaced `d` apart, is the //! minimum over the whole `k·d`-wide window, because `{0…d−1} ⊕ {0, d, …} = //! {0…kd−1}`. With `d ≈ √W` the window costs `d + k ≈ 2√W` taps instead of //! `W` — 16 rather than 61 at 4K — and it is the **same filter**, not an //! approximation of one. //! //! That distinction is the whole reason this is allowed here while the strided //! kernel `local_contrast` refuses is not. A stride samples an image that is //! not band-limited and aliases: high-frequency content folds down into the //! base, the base is subtracted, and the aliasing arrives as low-frequency //! mottling across smooth gradients. This decomposition samples nothing — it //! evaluates the exact minimum over every pixel of the window, in two steps. //! //! # Why it is now two passes and not five //! //! The decomposition was run as four erosion passes — run and span along x, //! then along y — and a fifth for the recovery. On the reference laptop the //! taps turned out not to be what a pass costs: with the memory clock held at //! 810 MHz by the power cap, a pass that only reads the render-sized //! `rgba16float` intermediate and writes the other one costs about 4 ms at //! 2560 x 1600, and dehaze's five came to 22 ms, of which the taps were about //! 2. So each axis is now one pass that takes the minimum over the whole //! window directly — 36 texture reads per pixel at that size instead of 12, //! nearly all of them served by the cache — and the recovery rides in the y //! pass, which already has the veil and this pixel's colour in hand. Two //! passes: 9.2 ms. //! //! It is the same picture, bit for bit. A minimum is exact in any order, so //! the minimum over the window's pixels is one value however it is grouped, //! and the window is the one [`Split`] always covered, surplus pixel //! included. The veil reaching the recovery was always exactly representable //! in the `rgba16float` lane it crossed — a minimum of channel values that //! were themselves read from `rgba16float` — so no rounding was lost by not //! storing it between passes. //! //! It is also why this operation does not use the reduced chain that TD-4 gave //! clarity. The runner holds one reduced buffer, so every scaled pass in a //! chain must declare the same `output_scale`; clarity's steps down with the //! viewport, so a second operation choosing its own would disagree with it at //! some window sizes and not others. Two full-resolution passes cost less than //! that coupling. //! //! # The artefact this does not fix //! //! An erosion by a wide element carries a dark object's value out to the //! patch radius around it, so the transmission map says "no haze here" in a //! band around every dark foreground shape and the correction falls off //! inside it. That band is the classic dark-channel halo, and the published //! answer to it is a guided filter or a matting Laplacian, which refines the //! transmission against the picture's own edges. //! //! Neither is done. A guided filter is a second chain carrying two more //! moments per pixel, and this stage hands each pass exactly one scalar lane //! (see [`DetailPass::wgsl`]); its regularisation parameter is a number no //! requirement supplies and would be a guess dressed up as a constant. The //! erosion of a continuous image is continuous, so what is left is a gradient //! rather than an edge — an under-correction near dark objects, not a ring //! around them. 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}; pub const ID: OpId = OpId("dehaze"); /// The one parameter, so the sidecar key reads `dehaze.amount`. pub const AMOUNT: ParamId = ParamId("amount"); /// The patch the veil is measured over, as a fraction of the frame's shorter /// edge — a **half**-width, so the window is twice this plus one. /// /// 1% — 30 px either side on a 4000 x 3000 frame. The two ends of the range /// are set by opposite failures of the prior. Narrower, and an ordinary patch /// of sky or skin contains nothing dark, so the minimum reads brightness as /// veil and the operation darkens the subject. Wider, and the minimum stops /// being local: it starts reporting the darkest thing in a large region of the /// frame, so a single shadow suppresses the correction across a quarter of the /// picture. /// /// It is within a factor of two of the 15 x 15 patch the dark-channel /// literature uses on the ~600 px images it is demonstrated at, which is the /// same fraction of a frame stated the other way round. const PATCH: f32 = 0.01; /// The fraction of the estimated veil that full slider travel removes. /// /// Not one, and this is a decision about the photograph rather than a safety /// margin. Aerial perspective is how a picture says "far away"; removing all /// of it flattens a landscape into a cut-out, which is the look that makes /// heavy dehaze recognisable as an effect. Keeping a twentieth of the veil /// leaves distance reading as distance at the top of the slider. const MAX_OMEGA: f32 = 0.95; /// The smallest transmission the recovery will divide by. /// /// Where the veil is nearly total there is nothing left to recover — the /// signal that survived is a few percent of the airlight, and dividing it back /// up amplifies whatever noise came with it by the same factor. A tenth is the /// floor the dark-channel literature uses and it means the same thing here: at /// worst this operation multiplies by ten, and a region that hits the floor /// keeps a trace of haze rather than becoming a picture of its own noise. const MIN_TRANSMISSION: f32 = 0.1; static DESCRIPTOR: LazyLock> = LazyLock::new(|| { Arc::new(OpDescriptor { id: ID, label: LocalizedKey("op.dehaze"), params: vec![ParamDescriptor::amount("amount", "param.dehaze.amount")], attributes: vec![Attribute::Detail], }) }); /// The darkest channel at a pixel, floored at zero. /// /// Declared here rather than in `_helpers.yaml` because it is not a shared /// idea: it is the dark-channel prior's own statistic and means nothing /// outside this file. const DARK_CHANNEL: Helper = Helper { name: "dark_channel", source: "\ // The smallest of the three channels — the dark-channel prior's statistic, // before the minimum over a patch is taken. // // Floored at zero because the intermediate is unclipped and scene-referred, so // an out-of-gamut colour arrives with a negative channel. A negative veil // would come back through the recovery as extra contrast in exactly the // pixels the working space could not represent, which is a bright fringe // arriving from a control the photographer reads as haze removal. fn dark_channel(c: vec3) -> f32 { return max(min(min(c.r, c.g), c.b), 0.0); }", }; static HELPERS: &[Helper] = &[DARK_CHANNEL]; /// TRACES: FR-DEV-18 /// Dehaze: estimate the atmospheric veil from the picture and divide it out. /// /// `Default` is derived rather than written out: the neutral of this operation /// is an amount of zero and nothing else, so there is no second fact for a /// hand-written impl to state. Capture sharpening needs one because its radius /// has no neutral value — a blur of zero width is not the identity, it is a /// kernel that does not exist — and the patch here is a constant rather than a /// parameter, so that problem does not arise. #[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct Dehaze { /// −100…100, exactly as the slider reports it. Negative *adds* haze — see /// [`Self::omega`]. amount: f32, } impl Dehaze { pub fn new() -> Self { Self::default() } /// Start from a slider position, for tests and presets. pub fn with_amount(amount: f32) -> Self { Self { amount } } /// The patch's half-width at this render, in **render pixels**. /// /// The one unit conversion this operation performs, and a method rather /// than a line inside [`Self::passes`] so that a test can state what it /// expects without repeating the rounding rule — a test that recomputed it /// would agree with a bug in it. /// /// Floored at one pixel rather than allowed to reach zero: see the module /// documentation for why this operation does not drop its pass on a /// thumbnail the way texture does. pub fn patch(&self, scale: RenderScale) -> u32 { scale.frame_fraction(PATCH).round().max(1.0) as u32 } /// How much of the estimated veil this setting removes. /// /// Negative at a negative amount, and the same formula then *adds* haze: /// the recovery becomes a composite of airlight over the picture in /// proportion to the veil already measured. So negative dehaze deepens the /// aerial perspective a scene already has rather than fogging it evenly, /// which is what a photographer asking for atmosphere means — and a scene /// with no haze in it stays clear, because there is no veil to scale. fn omega(&self) -> f32 { self.amount / 100.0 * MAX_OMEGA } } /// A minimum filter of half-width `radius`, split into two exact stages. /// /// See the module documentation: eroding by a contiguous run and then by a set /// of points spaced one run apart erodes by the sum of the two, which is the /// whole window. The passes no longer run the two stages apart (see "Why it is /// now two passes"), but the window they read is still the one this split /// covers — [`Self::first`] and [`Self::width`] — surplus pixel included, /// because that is the window every edit made so far was tuned against. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Split { /// Length of the contiguous run the first pass takes the minimum over. pub run: u32, /// How many runs the second pass chains together, spaced `run` apart. pub span: u32, /// How far before the pixel being written the window starts. /// /// The composite covers `run * span` pixels, which is at least the window /// asked for and can be one or two more; the surplus falls on the far side /// rather than being trimmed, because trimming it would have needed a /// third pass and a patch a pixel wider on one side is not a visible /// difference in a field this smooth. pub shift: i32, } impl Split { /// Split a window of half-width `radius`. /// /// `run` is the square root of the window rather than any other divisor /// because `d + ceil(W/d)` is smallest there — the two stages cost the /// same, which is what minimises their sum for a fixed product. pub fn of(radius: u32) -> Self { let width = 2 * radius + 1; let run = (width as f32).sqrt().ceil().max(1.0) as u32; let span = width.div_ceil(run); Self { run, span, shift: ((run * span - 1) / 2) as i32, } } /// The window's first offset from the pixel being written: `-shift`. pub fn first(&self) -> i32 { -self.shift } /// How many pixels the window covers, `run * span` — the patch asked for /// and the one or two surplus pixels on the far side the split leaves. pub fn width(&self) -> u32 { self.run * self.span } /// The furthest the window reads from the pixel being written, in pixels. /// /// Stated rather than assumed symmetric: the window is centred to within a /// pixel and not exactly, so the two directions can differ by one. An /// understated radius is a seam at every tile boundary (ARCH §5.3), which /// is the kind of artefact that looks like a driver bug. pub fn extent(&self) -> u32 { let far = self.width() as i32 - 1 - self.shift; self.shift.max(far).max(0) as u32 } } impl Operation for Dehaze { fn descriptor(&self) -> Arc { Arc::clone(&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] { HELPERS } } impl DetailStage for Dehaze { fn passes(&self, scale: RenderScale) -> Vec { let split = Split::of(self.patch(scale)); // Both axes erode over the same window; only the offset expression // differs, which is what `erode` takes as an argument — one filter // written once, so the two axes cannot drift into being different // filters. let window = vec![ Uniform { name: "first", value: split.first() as f32, }, Uniform { name: "width", value: split.width() as f32, }, ]; let mut recovery = window.clone(); recovery.extend([ Uniform { name: "omega", value: self.omega(), }, Uniform { name: "min_transmission", value: MIN_TRANSMISSION, }, ]); vec![ DetailPass { output_scale: 1, label: "veil-x", radius: split.extent(), storage: Vec::new(), uniforms: window, wgsl: erode(Axis::X), }, DetailPass { output_scale: 1, label: "veil-y-clear", radius: split.extent(), storage: Vec::new(), uniforms: recovery, // The erosion in a block of its own, so its locals do not // collide with the recovery's. wgsl: format!("{{\n{}\n}}\n\n{CLEAR}", erode(Axis::Y)), }, ] } } /// Which way a separable half runs. #[derive(Clone, Copy)] enum Axis { X, Y, } impl Axis { /// The offset expression for a scalar step `i` along this axis. fn offset(&self, step: &str) -> String { match self { Axis::X => format!("vec2({step}, 0)"), Axis::Y => format!("vec2(0, {step})"), } } } /// The minimum over the window along one axis. /// /// Along x it reads the colour and reduces it to the dark channel; along y the /// dark channel's x minimum is already in the scratch lane, so it reads that /// instead. Doing the channel minimum again on the second axis would be /// reducing a scalar and would quietly discard the x erosion. /// /// The x pass does not touch `c`. The recovery needs the original colour *and* /// the veil in the same place at the same time, and the ping-pong hands each /// pass only what the pass before it wrote — so the veil travels in `aux` and /// the colour rides through untouched. See [`DetailPass::wgsl`]. fn erode(axis: Axis) -> String { let source = match axis { Axis::X => "dark_channel(tap(coord, OFFSET))", Axis::Y => "tap_aux(coord, OFFSET)", }; let head = source.replace("OFFSET", &axis.offset("o")); let rest = source.replace("OFFSET", &axis.offset("o + i")); format!( "\ // The minimum over every pixel of the window along this axis, from `first` // for `width` pixels. A minimum is exact in any order, so this is the same // value, bit for bit, as chaining a run and a span over the same pixels. let o = i32(first); let n = i32(width); var veil = {head}; for (var i = 1; i < n; i = i + 1) {{ veil = min(veil, {rest}); }} aux = veil;" ) } /// The recovery: invert the scattering model with the transmission the erosion /// implies. const CLEAR: &str = "\ // The veil the two erosions measured: the smallest channel anywhere in // the patch around this pixel, which the dark-channel prior reads as the // airlight that has been composited over the scene here. // // Capped at one because the airlight is taken as diffuse white (see the module // documentation) and the intermediate is unclipped: a specular highlight // arrives at three, and three units of `veil` would drive the transmission // negative and turn the recovery inside out. A value above one is a highlight, // not more haze. The lower bound is already guaranteed by `dark_channel`. let veil = min(aux, 1.0); // `omega * veil` is the part of the veil this setting removes — negative at a // negative amount, where the same expression composites airlight back on and // deepens the aerial perspective instead. let lifted = omega * veil; // The transmission implied by that veil, floored. Where the veil is nearly // total the surviving signal is a few percent of the airlight, and dividing it // back up amplifies its noise by the same factor; the floor is what makes the // worst case a trace of remaining haze rather than a picture of the noise. // Adding haze cannot reach it — `lifted` is negative there, so the // transmission is above one and the max never bites. let t = max(1.0 - lifted, min_transmission); // The scattering model, inverted: I = J·t + A·(1 − t) with A taken as one, so // J = (I − A·(1 − t)) / t. Applied per channel rather than as a gain on // luminance, and that is the difference from every other control in this // stage: the veil is grey, so subtracting it *is* a chromaticity change, and // it is the right one — a haze-veiled distance is desaturated because the // airlight diluted it, and removing the airlight is what gives the colour // back. c = (c - lifted) / t;"; #[cfg(test)] mod tests { use super::*; use crate::detail::compose_detail; use dr_types::ColourSpace; fn ops(amount: f32) -> Vec> { vec![Box::new(Dehaze::with_amount(amount))] } fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail { compose_detail(&ops(amount), scale, ColourSpace::Srgb) } #[test] fn dehaze_starts_neutral_and_costs_nothing() { // The rule the whole pipeline rests on. An unedited photograph must not // pay for a slider nobody has touched — and this one is two dispatches // when it is on, so "nothing" here is a worthwhile amount of nothing. assert!(!Dehaze::new().is_active()); assert!(composed(0.0, RenderScale::full((2000, 1500))).is_empty()); } #[test] fn the_patch_is_a_fraction_of_the_frame_and_not_a_count_of_source_pixels() { // TRACES: FR-DSP-1 — the decision the units of this file rest on. // // The patch has to contain something dark and has to stay local, and // both are statements about how much of the *composition* it covers. So // it must be the same proportion of the picture on a proxy as in the // export, which `frame_fraction` gives and `source_pixels` would not: // at one-third scale a source-pixel patch would be three times too // narrow relative to the frame, and the export — the only render // anybody keeps — would be the one that looked nothing like what was // tuned. // // frame_fraction takes the *shorter* edge, and PATCH is 0.01: // 300 → 0.01 × 300 = 3 // 3000 → 0.01 × 3000 = 30 // 4500 → 0.01 × 4500 = 45 for (w, h, expected) in [(400u32, 300u32, 3u32), (4000, 3000, 30), (6000, 4500, 45)] { let patch = Dehaze::with_amount(50.0).patch(RenderScale::full((w, h))); assert_eq!(patch, expected, "{w}x{h}"); assert!( (patch as f32 / w.min(h) as f32 - PATCH).abs() < 0.002, "the patch drifted from its declared fraction at {w}x{h}" ); } } #[test] fn a_thumbnail_still_gets_the_correction() { // Deliberately unlike texture, which contributes no pass once its // kernel rounds to nothing. Texture's absence at that size is honest — // a two-pixel surface structure is not in the picture. Dehaze changes // the overall tone and colour, so a thumbnail that disagreed with the // develop view about it would read as a bug. let tiny = RenderScale::full((48, 32)); assert_eq!(Dehaze::with_amount(50.0).patch(tiny), 1); assert_eq!(composed(50.0, tiny).len(), 2); } #[test] fn the_split_erosion_covers_the_whole_patch_and_costs_its_square_root() { // The identity the affordability of this operation rests on: eroding by // a run of `d` and then by `k` points spaced `d` apart erodes by the // whole `k·d` window, because structuring elements add. If the product // ever falls short the patch is silently narrower than the one this // file documents, and nothing else would say so. // // 30 px either side → a window of 61 // run = ceil(sqrt(61)) = 8 // span = ceil(61 / 8) = 8 → covers 64 >= 61 let split = Split::of(30); assert_eq!(split.run, 8); assert_eq!(split.span, 8); assert!(split.run * split.span >= 61, "the window is not covered"); assert!( split.run + split.span < 61, "the split costs more taps than the window it replaces" ); // Centred to within the pixel the odd surplus leaves over: the window // covers [-31, 32] around the pixel being written. assert_eq!(split.shift, 31); assert_eq!((split.first(), split.width()), (-31, 64)); assert_eq!(split.extent(), 32); } #[test] fn a_patch_of_one_pixel_still_splits_into_something_that_runs() { // The degenerate end, which a thumbnail reaches. A window of three // splits into two runs of two, covering four — one pixel more than // asked for, on the far side, which is the surplus `Split::shift` // documents rather than an error to correct with a third pass. let split = Split::of(1); assert_eq!(split.run, 2); assert_eq!(split.span, 2); assert!(split.run * split.span >= 3, "the window is not covered"); assert_eq!(split.shift, 1); // [-1, 2]: the surplus pixel is on the far side. assert_eq!((split.first(), split.width()), (-1, 4)); assert_eq!(split.extent(), 2); } #[test] fn the_chain_is_an_erosion_per_axis_the_second_carrying_the_recovery() { // The shape of the operation, asserted where it is cheap to assert. // The x erosion leaves the colour alone and hands the veil forward in // the scratch lane; only the y pass touches `c`, which is what makes an // unsharp-mask-shaped operation expressible in a chain that hands each // pass exactly one texture. Two passes and not five: each extra pass // is a render-sized read and write, which is what a pass costs (see // "Why it is now two passes"). let composed = composed(60.0, RenderScale::full((2000, 1500))); let labels: Vec<&str> = composed.passes.iter().map(|p| p.label.as_str()).collect(); assert_eq!(labels, ["dehaze/veil-x", "dehaze/veil-y-clear"]); // Both declare the whole window they read. let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500)))); assert!(composed.passes.iter().all(|p| p.radius == split.extent())); // Only the last writes the display texture, so the output transform // happens exactly once (FR-DEV-2). assert!(!composed.passes[0].writes_output); assert!(composed.passes[1].writes_output); // Nothing here uses the reduced chain — see the module documentation // for why a second operation cannot pick its own `output_scale` while // the runner holds one reduced buffer. assert!(composed.passes.iter().all(|p| p.output_scale == 1)); // Every pass declares a uniform block the GPU will accept: a struct // whose size is not a multiple of sixteen bytes is rejected outright by // the WGSL uniform address space rules. assert!(composed.passes.iter().all(|p| p.uniforms.len() % 4 == 0)); assert!(composed .passes .iter() .all(|p| p.uniforms.iter().all(|v| v.is_finite()))); } #[test] fn adding_haze_is_the_same_expression_with_the_sign_turned_round() { // Negative dehaze is the forward model rather than a second operation: // it composites airlight back on in proportion to the veil already // measured, so it deepens the aerial perspective a scene has instead of // fogging it evenly — and a scene with no haze in it stays clear. assert!(Dehaze::with_amount(-100.0).is_active()); assert!(Dehaze::with_amount(-40.0).omega() < 0.0); let symmetric = Dehaze::with_amount(-40.0).omega() + Dehaze::with_amount(40.0).omega(); assert!(symmetric.abs() < 1e-6, "the two directions disagree"); // And at the top of the range some veil is deliberately left behind, so // a landscape keeps its distance rather than becoming a cut-out. assert!(Dehaze::with_amount(100.0).omega() < 1.0); } #[test] fn the_veil_is_measured_from_the_colour_once_and_then_from_the_lane() { // The one asymmetry between the two axes, and the one that would be // invisible if it were wrong: x reduces the colour to its dark channel, // y erodes the scalar x left behind. Taking the channel minimum again // on the second axis would reduce a scalar and quietly discard the // whole x erosion — a patch half the width this file documents, with // nothing to say so. let composed = composed(60.0, RenderScale::full((2000, 1500))); assert!(composed.passes[0].source.contains("dark_channel(tap(coord")); assert!(!composed.passes[1].source.contains("dark_channel(tap(coord")); assert!(composed.passes[1].source.contains("tap_aux(coord")); // The helper is still emitted for every pass of the operation, and it // must define the function it is named for or the shader fails to // compile a long way from here. assert!(composed .passes .iter() .all(|p| p.source.contains("fn dark_channel("))); } }