//! 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/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. //! //! 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. Four cheap full-resolution passes cost //! less than that coupling, and the decomposition is what makes them cheap. //! //! # 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. This is the arithmetic of that split, in one place, because /// both axes need it and a second copy is a second chance to get the centring /// wrong. #[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, /// What the second pass subtracts from its offsets to centre the window. /// /// 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 need 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 furthest the second pass reads, in pixels. /// /// Stated rather than assumed symmetric: the composite 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 reach(&self) -> u32 { let far = (self.span.saturating_sub(1) * self.run) as i32 - 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)); // The run stage's uniforms are the same on both axes, and so are the // span stage's. Only the offset expression differs, which is what // `erode_run` and `erode_span` take as an argument — each filter // written once, so the two axes cannot drift into being different // filters. let run = vec![Uniform { name: "run", value: split.run as f32, }]; let span = vec![ Uniform { name: "span", value: split.span as f32, }, Uniform { name: "stride", value: split.run as f32, }, Uniform { name: "shift", value: split.shift as f32, }, ]; vec![ DetailPass { output_scale: 1, label: "veil-run-x", // The run starts at this pixel and walks forward, so it reads // `run - 1` beyond itself and nothing behind. radius: split.run.saturating_sub(1), storage: Vec::new(), uniforms: run.clone(), wgsl: erode_run(Axis::X), }, DetailPass { output_scale: 1, label: "veil-span-x", radius: split.reach(), storage: Vec::new(), uniforms: span.clone(), wgsl: erode_span(Axis::X), }, DetailPass { output_scale: 1, label: "veil-run-y", radius: split.run.saturating_sub(1), storage: Vec::new(), uniforms: run, wgsl: erode_run(Axis::Y), }, DetailPass { output_scale: 1, label: "veil-span-y", radius: split.reach(), storage: Vec::new(), uniforms: span, wgsl: erode_span(Axis::Y), }, DetailPass { output_scale: 1, label: "clear", // Reads only the pixel it writes: the veil arrived in the // scratch lane four passes ago. radius: 0, storage: Vec::new(), uniforms: vec![ Uniform { name: "omega", value: self.omega(), }, Uniform { name: "min_transmission", value: MIN_TRANSMISSION, }, ], wgsl: CLEAR.to_string(), }, ] } } /// 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 first stage: the minimum over a contiguous run. /// /// Along x it reads the colour and reduces it to the dark channel; along y the /// dark channel 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. /// /// Neither stage touches `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_run(axis: Axis) -> String { let source = match axis { Axis::X => "dark_channel(tap(coord, OFFSET))", Axis::Y => "tap_aux(coord, OFFSET)", }; let first = source.replace("OFFSET", &axis.offset("0")); let rest = source.replace("OFFSET", &axis.offset("i")); format!( "\ // Half of the erosion's first stage: the minimum over `run` contiguous pixels, // walking forward from this one. The second stage chains these together, and // the two structuring elements add up to the whole patch — which is why this // one is not centred and does not need to be. let n = i32(run); var veil = {first}; for (var i = 1; i < n; i = i + 1) {{ veil = min(veil, {rest}); }} aux = veil;" ) } /// The second stage: the minimum over `span` points spaced `stride` apart. /// /// Each of those points already holds the minimum over the run that starts /// there, so this reads the whole window while touching `span` pixels of it. /// `shift` is what centres the composite on the pixel being written; without /// it the veil would be measured from a patch lying entirely to one side, and /// the correction would appear to lag the picture by half a patch. fn erode_span(axis: Axis) -> String { let offset = axis.offset("j * s - o"); let first = axis.offset("-o"); format!( "\ // The erosion's second stage. `span` taps, spaced a whole run apart, each // standing for the run that begins at it — so the minimum over the patch costs // `run + span` taps rather than the `run * span` pixels it covers, and it is // the exact minimum over all of them rather than a sample of them. let n = i32(span); let s = i32(stride); let o = i32(shift); var veil = tap_aux(coord, {first}); for (var j = 1; j < n; j = j + 1) {{ veil = min(veil, tap_aux(coord, {offset})); }} aux = veil;" ) } /// The recovery: invert the scattering model with the transmission the erosion /// implies. const CLEAR: &str = "\ // The veil the four erosion passes 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 five 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(), 5); } #[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.reach(), 31); } #[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); assert_eq!(split.reach(), 1); } #[test] fn the_chain_is_four_erosions_and_a_recovery() { // The shape of the operation, asserted where it is cheap to assert. // The erosions leave the colour alone and hand the veil forward in the // scratch lane; only the last pass touches `c`, which is what makes an // unsharp-mask-shaped operation expressible in a chain that hands each // pass exactly one texture. 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-run-x", "dehaze/veil-span-x", "dehaze/veil-run-y", "dehaze/veil-span-y", "dehaze/clear", ] ); // Only the last writes the display texture, so the output transform // happens exactly once (FR-DEV-2). assert!(composed.passes[..4].iter().all(|p| !p.writes_output)); assert!(composed.passes[4].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[2].source.contains("dark_channel(tap(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("))); } }