//! The `Operation` trait and WGSL fragment composition. //! //! # Composable shaders //! //! Each operation contributes a **WGSL fragment**: a function taking a linear //! RGB colour and returning one. The pipeline concatenates the fragments of //! the enabled operations into a single generated shader, run as one compute //! dispatch. This buys the performance of a fused pass without the coupling: //! //! - **One texture read and one write per frame**, not one pair per operation. //! At 24 MP the difference is the whole frame budget. //! - **Operations stay independent.** Adding one is a new file implementing //! this trait; no central shader to edit and no ordering table to update. //! - **A disabled operation vanishes from the source** rather than costing a //! branch, so an image with two active adjustments compiles to a shader //! doing exactly two things. //! - **Each distinct op-set compiles once** and is cached by the hash of its //! generated source (ARCH §5.6). //! //! The cost is that WGSL compile errors point at generated source, so the //! generator emits readable, commented output — see [`compose`]. use std::fmt::Write as _; use std::sync::Arc; use dr_types::{ColourSpace, Transfer}; use crate::descriptor::{OpDescriptor, ParamId, Presentation}; use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS}; use crate::mask::MaskStack; /// TRACES: FR-DEV-3d /// What an operation's parameters affect, for cache invalidation scoping. /// /// Adjusting exposure must not invalidate the demosaic result; this is what /// lets the tile cache reuse everything up to the first changed stage /// (ARCH §5.3). /// /// **The ordering is the pipeline order**, which is why this derives `Ord` /// rather than merely `Eq`: geometry decides which source pixel a colour comes /// from, the fused colour pass transforms it, and the detail stage reads the /// neighbourhood the colour pass produced. A change at one stage invalidates /// that stage and every later one, and nothing earlier — see [`Invalidation`], /// which is where that rule is actually written down and tested. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Affects { /// Pixel positions — crop, rotate, straighten. The framing prologue, which /// also decides the resolution everything downstream runs at. Geometry, /// Per-pixel colour. Every operation fused into the single adjust /// dispatch, and every mask layer's chain. Colour, /// TRACES: FR-DEV-3d /// A pixel's *neighbourhood* — sharpening, noise reduction, clarity, /// texture, dehaze, spot removal. /// /// The seam `docs/dev/requirements.md` §3.3 designed and nothing cut until /// [`crate::detail`] existed. It is a separate variant rather than a flavour /// of `Colour` because it is a separate *dispatch*: a fragment in the fused /// pass is handed a colour and has no way back to a coordinate, so a /// kernel cannot be expressed there at any price. /// /// What the distinction buys, concretely: the fused pass's result is held /// in a linear intermediate, so dragging a sharpening slider re-runs the /// detail dispatches and **not** the colour pass — which is exactly the /// reuse FR-DEV-3d asks for, and it is asserted in `dr-gpu`'s /// `detail_stage` tests rather than merely hoped for. Detail, } /// TRACES: FR-DEV-3d /// One cache key per pipeline stage, derived from the edit. /// /// # The rule /// /// A cached result for stage *S* stays valid while *S*'s own key and the keys /// of every stage **before** it are unchanged. [`Self::of`] is the first half; /// [`Self::through`] folds in the second and is what a cache should actually /// store. /// /// That reads as pedantry until it is applied, at which point it settles the /// two questions FR-DEV-3d asks: /// /// - **Changing a detail parameter must not re-run demosaic**, or the framing, /// or the fused colour pass. It does not: `of(Detail)` moves and /// `through(Colour)` does not, so the linear intermediate the colour pass /// wrote is still good and only the detail dispatches run again. /// /// - **Changing exposure must not re-run anything upstream of colour.** It /// does not: `through(Geometry)` is untouched, so a tile cache keyed on it /// survives, and the demosaiced texture — which no key here mentions at all /// — is never in question. /// /// It also settles what is *not* true, and the temptation is real: changing /// exposure **does** re-run the detail passes, because the detail stage reads /// what the colour pass wrote and that changed. There is no arrangement of /// keys that avoids it while keeping sharpening after the tone curve, and /// sharpening after the tone curve is the correct place (see /// [`crate::detail`]). Anyone who wants exposure to leave the detail stage /// alone is asking for detail to run *before* tone, which is a different /// pipeline and a worse picture. /// /// # Why the demosaic is not in here /// /// Because no parameter in this graph can change it. The demosaiced texture is /// a function of the file and the decode settings, both of which live outside /// the edit graph; a caller keying a cache on it mixes in whatever names the /// photograph — a `VersionId` — and these keys ride on top. /// /// # Integer state only /// /// Every value folded in here is a parameter: a slider position or a number /// from a sidecar, never a float that came back from the GPU. That is what /// ARCH §6.13 requires of a cache key, and it is why hashing the raw bit /// patterns is sound rather than reckless. Negative zero is canonicalised on /// the way in, because `-0.0 == 0.0` while their bit patterns differ, and a /// slider that arrived at zero from below would otherwise invalidate a cache /// that is perfectly valid. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Invalidation { geometry: u64, colour: u64, detail: u64, } impl Invalidation { /// Build from the three per-stage hashes. [`crate::EditGraph::invalidation`] /// is what computes them; this is public so a caller with its own notion /// of a stage can construct one. pub fn new(geometry: u64, colour: u64, detail: u64) -> Self { Self { geometry, colour, detail, } } /// The key for `stage`'s own parameters, ignoring everything upstream. /// /// Useful for asserting that a change was correctly *scoped* — that moving /// a detail slider left the colour stage's parameters alone. Not a cache /// key: a stage whose own parameters are unchanged still has to re-run if /// its input changed, which is what [`Self::through`] is for. pub fn of(&self, stage: Affects) -> u64 { match stage { Affects::Geometry => self.geometry, Affects::Colour => self.colour, Affects::Detail => self.detail, } } /// The key for the **output** of `stage` — this stage and everything /// upstream of it. What a cached texture should be keyed on. pub fn through(&self, stage: Affects) -> u64 { let mut h = FNV_OFFSET; h = mix(h, self.geometry); if stage >= Affects::Colour { h = mix(h, self.colour); } if stage >= Affects::Detail { h = mix(h, self.detail); } h } } /// Fold one operation's identity and settings into a running hash. /// /// Shared by the stage keys so that two stages cannot come to disagree about /// what "this operation's state" means — which would show as a cache that is /// occasionally, unreproducibly stale. pub(crate) fn hash_op(h: u64, op: &dyn Operation) -> u64 { let desc = op.descriptor(); let mut h = hash_bytes(h, desc.id.0.as_bytes()); for p in &desc.params { h = hash_bytes(h, p.id.0.as_bytes()); h = mix(h, u64::from(canonical_bits(op.param(p.id)))); } h } /// A parameter's bits, with negative zero folded onto zero. /// /// `-0.0 == 0.0` as far as every operation is concerned — a slider that /// reached zero from below produces the same shader and the same picture — but /// the two have different bit patterns. Hashing them apart would invalidate a /// cache for a change that is not one. pub(crate) fn canonical_bits(v: f32) -> u32 { if v == 0.0 { 0 } else { v.to_bits() } } pub(crate) fn hash_bytes(mut h: u64, bytes: &[u8]) -> u64 { for byte in bytes { h ^= u64::from(*byte); h = h.wrapping_mul(0x100_0000_01b3); } h } /// FNV-1a's offset basis. No dependency, and stable across runs and platforms, /// which a cache key requires. pub(crate) const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; /// A single scalar a fragment reads from the generated uniform block. /// /// Operations declare uniforms by name and value; the composer assigns them /// slots and emits the struct. An operation never knows its own offset, which /// is what allows fragments to be reordered or omitted freely. #[derive(Debug, Clone, PartialEq)] pub struct Uniform { /// Field name as it appears in WGSL. Prefixed with the op id by the /// composer, so two operations may both declare `amount`. /// /// `&'static str` for the reason [`crate::descriptor::intern`] gives about /// ids: a uniform name is a small, deduplicated, process-lifetime piece of /// vocabulary, and a declared operation interns its names once when it is /// parsed rather than allocating them on every `uniforms()` call — which /// happens per composition, and composition happens per frame. pub name: &'static str, pub value: f32, } /// A develop operation. /// /// Object-safe: the pipeline holds `Box` in graph order, so /// order is data rather than code (ARCH §3.4). pub trait Operation: Send + Sync { /// TRACES: FR-DEV-3a | FR-PLG-2 /// This operation's description, driving UI generation (FR-DEV-3a). /// /// **Shared and owned rather than `&'static`.** See [`OpDescriptor`] for /// why — in short, a `&'static` descriptor is one a compile-time literal /// can produce and a load-time declaration cannot, which would make a /// plugin a second-class kind of operation for a reason that is purely an /// artefact of how the built-ins happen to be written. /// /// # What this costs, and where /// /// One `Arc` clone and drop per call. Descriptors are read when a panel is /// built (`EditGraph::capabilities`), when a sidecar is written or read, /// and when the history names what changed — none of which is a per-frame /// path. /// /// There is **one** exception, and it is worth stating plainly rather than /// letting somebody discover it with a profiler: [`compose_full`] reads /// `descriptor().id` once per *active* operation to prefix its uniforms, /// and `dr-ui` composes on every frame it draws. That is a handful of /// atomic increments — a dozen or so, against a composition that is /// already building several kilobytes of WGSL text from scratch on the /// same call. If composition ever stops being a per-frame operation, this /// stops being a question at all; while it is one, the refcount is not /// what makes it expensive. fn descriptor(&self) -> Arc; /// Set a parameter. Values arrive already clamped to the descriptor. fn set_param(&mut self, id: ParamId, value: f32); /// Read a parameter back, for the sidecar and for the UI's initial state. fn param(&self, id: ParamId) -> f32; /// Whether this operation currently changes the image. /// /// An operation at its neutral settings returns `false` and is omitted /// from the generated shader entirely. This is what makes the common case /// — a handful of active adjustments out of many available — cost only /// what is actually used. fn is_active(&self) -> bool; /// The WGSL body of this operation's transform. /// /// Receives `c` (a `vec3` of linear RGB) and must produce the /// result in `c`. Uniforms are addressed by the names declared in /// [`Self::uniforms`], accessed as `u.`; the composer /// rewrites them, so a fragment writes the bare name. /// /// The fragment runs inside its own block, so locals need no unique /// names. /// /// Never called on an operation that declares a [`Self::detail`] stage — /// a neighbourhood operation is a dispatch of its own and contributes /// nothing to the fused shader, so it returns an empty string. fn wgsl_body(&self) -> String; /// Uniform values this operation's fragment reads. fn uniforms(&self) -> Vec; /// What this operation's parameters affect. fn affects(&self) -> Affects { Affects::Colour } /// TRACES: FR-DEV-3f /// Hand this operation a stock's measured tables, if it wants them. /// /// Default: ignore them, which is right for every operation that is a /// function of its parameters alone. /// /// A named method rather than a downcast or a bag of profiles, because /// there is one caller and inventing a general mechanism for it would be /// guessing at the shape of the next one. `vignetting`, `distortion` and /// `aberration` already carry lens measurements through `set_profile` and /// are not yet reached from the graph at all; when they are, this is the /// shape it should take. fn set_film_tables(&mut self, _tables: Option<&crate::ops::film_sim::FilmTables>) {} /// TRACES: FR-DEV-3e | FR-DEV-3f /// Whether this operation *is* the rendering, rather than an adjustment to /// one. /// /// Almost everything returns `false`. An operation that returns `true` /// takes camera RGB and hands back linear sRGB, and in exchange the /// composer emits neither the camera profile's base curve nor the /// conversion out of camera space — because this operation has done both. /// /// The reason it is a trait method and not a flag the caller sets is the /// one [`compose_full`] gives for deciding the output mode the same way: a /// caller that got it wrong would produce a shader that compiles, runs, and /// renders the picture twice. `film_sim` is the operation this exists for — /// a stock's characteristic curve does the base curve's job, from /// measurements, and running both is the camera's rendering of the scene /// followed by a film's rendering of *that*. fn renders(&self) -> bool { false } /// TRACES: FR-DEV-3 | FR-DEV-8 /// This operation's neighbourhood stage, if it has one. /// /// `None` — the default, and true of every operation that is a function of /// one colour — means the operation is fused into the single adjust /// dispatch in the ordinary way. /// /// `Some` means the opposite: the operation reads pixels it is not /// writing, cannot be a fragment in a fused shader, and runs as its own /// dispatch or dispatches after the colour pass. See [`crate::detail`] for /// where that sits and why, and for what a sharpening operation has to /// write. An operation returning `Some` must also return /// [`Affects::Detail`] from [`Self::affects`], which /// `detail_operations_agree_with_themselves` checks — the two saying /// different things would leave the operation in neither stage, silently /// doing nothing. fn detail(&self) -> Option<&dyn crate::detail::DetailStage> { None } /// Any WGSL helper functions the fragment calls. /// /// Emitted once per *distinct* function name even if several operations /// request it, so shared helpers (luminance, soft clipping) are declared /// exactly once. /// /// Borrowed from `self` rather than `'static`, for the reason /// [`Self::descriptor`] is owned: a generated operation returns a /// `&'static [Helper]` and coerces, while an operation built from a /// declaration at load time owns its list. The [`Helper`] *strings* /// themselves stay `&'static` — they are interned, like the ids. fn helpers(&self) -> &[Helper] { &[] } /// TRACES: FR-DEV-3a | FR-DEV-3b /// How this operation would like its parameters presented. /// /// `None` — the default, and the right answer for nearly every operation /// — means one control per parameter, chosen from its /// [`crate::ParamKind`]. Returning a [`Presentation`] says that several /// parameters form a single conceptual control and names the widget that /// draws it. /// /// Purely a hint. The parameters remain individually addressable /// scalars, so a UI that does not implement the named widget falls back /// to sliders and stays fully functional. fn presentation(&self) -> Option { None } /// Take whatever this operation needs from a lens profile. /// /// The counterpart of [`crate::lens::Warp::set_profile`], and it exists on /// this trait as well because the optical corrections do not all live on /// the same side of the fetch. Distortion and CA rewrite coordinates and /// are warps; vignetting applies a gain to the pixel already there and is /// an ordinary node. Splitting the fan-out by which trait a correction /// happens to implement would make the caller reason about that, so both /// traits carry the same door and `EditGraph::set_lens_profile` walks /// both lists the same way. /// /// Defaulted: fourteen of the fifteen operations have nothing to take. fn set_lens_profile(&mut self, _profile: Option<&crate::lens::LensProfile>) {} } /// A named WGSL helper function, deduplicated across operations. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Helper { pub name: &'static str, pub source: &'static str, } /// TRACES: FR-DEV-2 | FR-DEV-3d /// What the fused pass writes, and therefore what has to be bound to it. /// /// The fused shader ends one of two ways, and the difference is not cosmetic — /// it decides the storage texture's format, so a shader composed for one and /// dispatched against the other is a validation failure rather than a wrong /// picture. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutputMode { /// `rgba8unorm`, display-encoded in the composed output space. What the /// pass has always written, and still writes for the overwhelmingly common /// edit that has no detail stage: one dispatch, one read, one write. Encoded, /// `rgba16float`, linear sRGB, **unclipped**, scene-referred. /// /// Emitted when the edit has an active neighbourhood operation. The detail /// passes read this, and the last of them performs the output transform, /// so the pipeline still quantises exactly once (FR-DEV-2) — it simply /// happens two dispatches later. /// /// Unclipped matters: a recovered highlight is above 1.0 here, and /// clamping before a sharpener sees it would draw a hard edge at precisely /// the luminance a sharpener is most visible at. LinearWorking, /// TRACES: FR-MRG-2 /// `rgba32float`, **camera space**: after the lens warp and nothing else. /// /// What a merge stitches (FR-MRG-2). The shader is the linear tail with /// no operations, and the caller fills the reserved uniforms neutral — /// unit white balance, identity matrix, base curve off — so what is /// stored is the sensor's own numbers, demosaiced and undistorted. Only /// [`compose_camera_probe`] produces it — for a merge through /// [`compose_camera_linear`], and for the white balance picker under the /// edit's own framing — and only `AdjustPass::render_camera_linear` /// accepts it, so the neutral uniforms cannot be forgotten by a caller /// that composed it by mistake. /// /// Thirty-two bits rather than sixteen because the composite is written /// back as a RAW at the sensor's scale (FR-MRG-3): a 14-bit sensor has /// 16 384 steps to white and `f16` keeps 2 048 of them in the top octave. CameraLinear, } /// The result of composing a set of operations into one shader. #[derive(Debug, Clone, PartialEq)] pub struct ComposedShader { /// Complete, compilable WGSL. pub source: String, /// Uniform values in the order the generated struct declares them. pub uniforms: Vec, /// Identifies this shader's *structure* — the op-set and their order, /// not their values. Two edits differing only in slider positions share /// a compiled pipeline and differ only in the uniform upload. pub structure_hash: u64, /// What this shader writes. See [`OutputMode`]. pub output_mode: OutputMode, /// What decides which source texel each output pixel reads, when that /// texel is read whole — `None` when it is interpolated. /// /// A fit view reads one texel in every three or four of a 60 MP source, /// on a stride, and that gather is most of what the fused pass costs /// there: the texel it wants shares a cache line with neighbours nobody /// reads. But the gather depends on the framing and nothing else, so it /// is the same on every frame of a slider drag. The shader can therefore /// write what it gathered to a viewport-sized texture once and read it /// back contiguously thereafter; the flags in the uniform block at /// [`SAMPLE_CACHE_UNIFORM_OFFSET`] say which, and `dr-gpu` decides. /// /// This key is the half of that decision only the composer can make: a /// hash of the generated prologue and the framing and warp uniforms, which /// together are everything that maps an output pixel to a source texel. /// The caller mixes in the source image and the render size. `None` for /// the interpolating paths, whose sample is a blend of four texels and /// not representable exactly in the source's own format. pub sample_key: Option, } /// Fields the generated uniform struct always carries, before op uniforms. /// /// WGSL requires a uniform struct to be non-empty and 16-byte aligned; these /// are needed by every generated shader in any case. /// /// Twelve of the twenty-eight are the camera profile's base curve /// ([`BASE_CURVE_UNIFORM_FIELDS`]); the rest are the matrix, the as-shot /// balance and framing's own block. const BASE_UNIFORM_FIELDS: usize = 16 + SAMPLE_CACHE_UNIFORM_FIELDS + BASE_CURVE_UNIFORM_FIELDS; /// Slots the sample cache's two flags occupy: read, write, and two spare to /// keep the block a whole `vec4`. See [`ComposedShader::sample_key`]. const SAMPLE_CACHE_UNIFORM_FIELDS: usize = 4; /// Where the sample cache's flags sit in the generated uniform block: `x` says /// read the source colour from the cache, `y` says write it there. /// /// Exported for the reason [`BASE_CURVE_UNIFORM_OFFSET`] is — `dr-gpu` writes /// these by index — and zero in every block the composer hands out, so a /// caller that never heard of the cache gets the direct read it always had. pub const SAMPLE_CACHE_UNIFORM_OFFSET: usize = 16; /// TRACES: FR-DEV-3e /// Slots the base curve occupies: five `(x, y)` points and an active flag. /// /// Twelve rather than eleven so the block stays a whole number of `vec4`s, /// which is what std140 requires of a uniform struct's members. The spare /// float is left zero rather than repurposed — a uniform slot that means one /// thing today and two things next year is how a shader comes to read a /// highlight rolloff out of a crop rectangle. const BASE_CURVE_UNIFORM_FIELDS: usize = 12; /// TRACES: FR-DEV-3e /// Where the base curve's slots begin in the generated uniform block. /// /// Exported for the same reason [`RESERVED_UNIFORM_FIELDS`] is: `dr-gpu` /// writes these by index, and an offset computed independently at both ends is /// an offset that will eventually disagree with itself. pub const BASE_CURVE_UNIFORM_OFFSET: usize = SAMPLE_CACHE_UNIFORM_OFFSET + SAMPLE_CACHE_UNIFORM_FIELDS; /// How many control points a base curve carries. /// /// The same five the tone curve widget has, deliberately — see the helper /// selection in [`compose_full`]. pub const BASE_CURVE_POINTS: usize = 5; /// Where the highlight desaturation begins: the fraction of the white level /// above which a photosite is treated as clipped. /// /// A photosite this close to saturation has stopped counting, so its ratio /// to its neighbours is not a colour. The generated prologue fades a pixel /// above this toward a neutral of the same brightness before any operation /// runs, and the white balance probe refuses to sample one: a blown sky is /// sensor white, which the as-shot multipliers make magenta, and a solve /// over that slams tint to its stop. One number, so the two cannot drift /// apart — a probe that accepted what the shader had already desaturated /// would be balancing against a pixel the photographer cannot see. pub const CLIP_ONSET: f32 = 0.985; /// Where an operation's own uniforms begin in the generated block. /// /// The base fields, then framing's. Exported because `dr-gpu` writes the /// camera matrix into the leading slots by index and would otherwise carry /// its own copy of this arithmetic — a duplicate that silently corrupts every /// operation's uniforms the moment either block changes size. pub const RESERVED_UNIFORM_FIELDS: usize = BASE_UNIFORM_FIELDS + FRAMING_UNIFORM_FIELDS; /// Compose enabled operations into a single compute shader, for the display. /// /// Inactive operations are skipped entirely — they contribute no code, no /// uniforms, and nothing to the structure hash. /// /// Equivalent to [`compose_with_framing`] with neutral framing and an sRGB /// output. pub fn compose(ops: &[Box]) -> ComposedShader { compose_with_framing(ops, &Framing::new(), ColourSpace::Srgb) } /// TRACES: FR-EXP-2 | FR-DSP-6 /// Compose operations and framing into a single compute shader. /// /// Framing generates the shader's **prologue** — the map from an output pixel /// back to a source position — where [`compose`] would emit a fixed identity /// scale. The fused-dispatch property is unaffected: a cropped, straightened /// edit with three adjustments is still one dispatch, one read, one write. /// /// # The output space is a parameter, not a constant /// /// `output` decides the primaries and transfer function the last two lines of /// the shader encode into. It is passed per composition rather than held /// anywhere because it is a property of *this render*: the same edit goes to /// the screen in the display's space and to a file in whatever the export asks /// for, and neither is more authoritative than the other. /// /// It also enters the structure hash, so the two do not collide in the /// pipeline cache — a screen render and a Display P3 export are different /// shaders, however identical their sliders. pub fn compose_with_framing( ops: &[Box], framing: &Framing, output: ColourSpace, ) -> ComposedShader { compose_full( ops, framing, output, &MaskStack::new(), &crate::spot::SpotSet::new(), // No lens corrections. This entry point exists for callers that have // an operation chain and nothing else — the codegen tests, and the // export path before it grew a graph — and a warp is not something a // caller can hold without one. &[], ) } /// TRACES: FR-DEV-3 /// Compose the global chain, the framing, and the local adjustments. /// /// Mask layers are emitted **after** every global operation and before the /// conversion out of camera space, so a local exposure acts on the tones the /// global chain settled on — which is what a photographer means by "and then /// lift the shadows on her face". /// /// The fused-dispatch property survives: three global adjustments and two /// masked ones are still one shader, one read and one write. The masks /// themselves arrive as a pre-rasterised texture array (ARCH §5.4), so a /// slider drag over a mask recompiles a shader but re-rasterises nothing. pub fn compose_full( ops: &[Box], framing: &Framing, output: ColourSpace, masks: &MaskStack, spots: &crate::spot::SpotSet, warps: &[Box], ) -> ComposedShader { compose_full_revealing(ops, framing, output, masks, spots, warps, None) } /// TRACES: FR-DEV-19c /// [`compose_full`], with one layer's mask drawn over the finished picture. /// /// Separate from [`compose_full`] rather than an argument on it, and that is /// the safety property rather than a convenience: the reveal is a thing the /// screen does, and every other consumer of the pipeline — the exporter, the /// thumbnail, the neutral probe — calls the function that has no way to ask /// for it. A flag reachable from the graph would have been one forgotten reset /// away from a red tint baked into an exported file. #[allow(clippy::too_many_arguments)] pub fn compose_full_revealing( ops: &[Box], framing: &Framing, output: ColourSpace, masks: &MaskStack, spots: &crate::spot::SpotSet, warps: &[Box], reveal: Option<&crate::mask::Reveal>, ) -> ComposedShader { compose_inner( ops, framing, output, masks, spots, warps, reveal, None, false, ) } /// TRACES: FR-MRG-2 /// The camera-space tap: the fused pass with no operations, stopping after /// the lens warp and storing `rgba32float` ([`OutputMode::CameraLinear`]). /// /// Takes the warps and a view, because that is all the tap uses of an edit: /// no crop (a merge wants the whole frame, and crops the composite), no /// masks, no spots, no operations. `view` is the tile — the fraction of the /// undistorted frame to render, in `Framing::set_view`'s terms — so that a /// merge pulls source tiles on demand (FR-MRG-11) rather than a frame that /// may not fit. The camera profile's uniforms are still declared — the /// prologue is the same — and the GPU side fills them neutral. pub fn compose_camera_linear( warps: &[Box], baseline: dr_types::Orientation, view: crate::framing::CropRect, ) -> ComposedShader { let mut framing = Framing::default(); // The file's orientation and nothing of the user's: a merge aligned // its frames upright (`dr_pano::Gray::oriented`), so its tiles must be // upright too, and `view` is a fraction of the upright frame. framing.set_baseline(baseline); framing.set_view(view); compose_camera_tap(warps, &framing, false) } /// TRACES: FR-DEV-3 /// The same tap under a framing the caller chose: the white balance probe. /// /// A neutral picked off the canvas has to be measured in the space the /// white balance gains multiply, and that is camera RGB — the operation /// runs before the body's matrix, and a probe read after the matrix would /// be solving the wrong equation on any body whose matrix mixes the /// channels, which is every body. It also has to be measured at the pixel /// the canvas is showing, which is why this takes the edit's own framing /// where a merge passes the file's orientation and a tile. /// /// **Interpolated whatever the framing says.** The point of rendering a /// patch is to average what is under it, and the nearest sampling an /// unrotated frame otherwise gets is a comb: at two source pixels per /// probe pixel it lands on the same column of any pattern every time, and /// the average of a thousand samples is then the average of nothing. pub fn compose_camera_probe( warps: &[Box], framing: &Framing, ) -> ComposedShader { compose_camera_tap(warps, framing, true) } /// The camera-space tap proper: no operations, `rgba32float`, and the /// profile uniforms left for the GPU side to fill neutral. `smooth` forces /// the interpolating sampler; see the two callers for who wants it and why. fn compose_camera_tap( warps: &[Box], framing: &Framing, smooth: bool, ) -> ComposedShader { compose_inner( &[], framing, ColourSpace::Srgb, &MaskStack::new(), &crate::spot::SpotSet::new(), warps, None, Some(OutputMode::CameraLinear), smooth, ) } #[allow(clippy::too_many_arguments)] fn compose_inner( ops: &[Box], framing: &Framing, output: ColourSpace, masks: &MaskStack, spots: &crate::spot::SpotSet, warps: &[Box], reveal: Option<&crate::mask::Reveal>, forced: Option, smooth: bool, ) -> ComposedShader { // The lens corrections, composed into one coordinate transform. Beside // `framing` because they are the other half of the same stage: framing // says which part of the source this output pixel comes from, and a warp // says where the lens put it once it got there. let warp = crate::lens::compose_warps(warps); // Active *point* operations. A neighbourhood operation is filtered out // here rather than asked for a fragment it cannot write: it reads pixels // it is not writing, so it belongs to the detail stage that runs after // this one (see `crate::detail`). Filtering on the declared stage rather // than on `affects()` means the shader and the stage agree by // construction — there is one place an operation says which it is. let active: Vec<&dyn Operation> = ops .iter() .map(|o| o.as_ref()) .filter(|o| o.is_active() && o.detail().is_none()) .collect(); // Whether a detail stage follows. If one does, this pass stops short of // the output transform and hands on a linear intermediate; the last detail // pass finishes the job. Decided from the operations themselves rather // than from a flag the caller sets, because a caller that got the flag // wrong would produce a shader whose storage format does not match the // texture bound to it. // // TRACES: FR-DEV-8 // The repairs count too, and they are the reason this takes a spot set at // all: a photograph with a spot on it and no sharpening still has a detail // stage, and a fused pass that encoded its own output there would quantise // twice and be bound to a texture of the wrong format. // // `forced` is the one exception, and it is not a caller flag in the // sense above: `compose_camera_probe` is the only function that passes // it, with an empty operation list, and the mode it forces has its own // storage format and its own render entry on the GPU side. let output_mode = forced.unwrap_or( if ops.iter().any(|o| o.is_active() && o.detail().is_some()) || !spots.is_neutral() { OutputMode::LinearWorking } else { OutputMode::Encoded }, ); // Whether an operation has taken over the rendering. Decided from the // operations for the same reason `output_mode` is: a caller that got it // wrong would produce a shader that compiles and renders the picture twice. let op_renders = ops.iter().any(|o| o.is_active() && o.renders()); let mut uniform_fields = String::new(); let mut uniform_values: Vec = Vec::new(); let mut body = String::new(); let mut helpers: Vec = Vec::new(); // The base block: the camera matrix and output settings every generated // shader needs. Declared first so their slots are fixed regardless of // which operations are present. uniform_fields.push_str( " // Camera RGB -> linear sRGB. Rows padded to vec4 for std140\n\ \x20 // alignment; a bare mat3x3 is laid out as three vec4 anyway.\n\ \x20 cam_to_srgb_0: vec4,\n\ \x20 cam_to_srgb_1: vec4,\n\ \x20 cam_to_srgb_2: vec4,\n\ \x20 // As-shot white balance, the neutral point for the WB control.\n\ \x20 // `.w` is not padding: it flags a non-linear source (1.0 for a\n\ \x20 // gamma-encoded JPEG, 0.0 for demosaiced sensor data), which the\n\ \x20 // prologue reads to decide whether to linearise.\n\ \x20 as_shot_wb: vec4,\n\ \x20 // The sample cache (see `ComposedShader::sample_key`): `.x` reads\n\ \x20 // the source colour from `sampled`, `.y` writes it to\n\ \x20 // `sample_out`. Zero for both is the direct read.\n\ \x20 sample_cache: vec4,\n\ \x20 // The camera profile's base curve (FR-DEV-3e): five points on a\n\ \x20 // monotone spline, packed as x0..x3, y0..y3, then (x4, y4, on).\n\ \x20 // `.z` of the last is the flag, not padding — it is 0 for a\n\ \x20 // body with no profile and for an already-rendered source.\n\ \x20 base_curve_x: vec4,\n\ \x20 base_curve_y: vec4,\n\ \x20 base_curve_last: vec4,\n", ); uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0); // TRACES: FR-DEV-3e // The spline the base curve is evaluated on is the *tone curve's* spline, // reached through the trait rather than reimplemented here. // // Two reasons, and the second is the one that matters. The obvious one is // that a shader carrying two `curve_eval`s would not compile, and the // composer's helper de-duplication is what makes both stages able to ask // for it. The real one is that a profile author placing a control point // and a photographer dragging one must mean the same thing by it — down to // the Fritsch-Carlson tangent limiting, which is what decides how a // shoulder actually rolls off. Two implementations that agreed today would // be two that could disagree later, and the disagreement would show up as // a body whose profile renders subtly differently from the curve someone // drew to match it. // // Emitted unconditionally, unlike an operation's helpers. The base curve // is active for every RAW frame — an unprofiled body still gets the // database's default rendering — so making the shader's shape depend on it // would split the pipeline cache in two for no benefit. The uniform flag // above turns it off for the cases that are genuinely already rendered, // and a branch on a uniform is coherent across the whole dispatch. for h in crate::ops::ToneCurve::new().helpers() { if matches!(h.name, "curve_span" | "curve_eval") { helpers.push(*h); } } // Framing's block follows the base one at a fixed offset, for the same // reason: the prologue is emitted whether or not any operation is active, // so these slots cannot be positioned by the op loop below. uniform_fields.push_str( " // Framing: the crop rect (origin, extent) and the straightening\n\ \x20 // angle as sin/cos — a trig call per pixel would recompute a\n\ \x20 // value that is constant across the dispatch.\n\ \x20 crop_rect: vec4,\n\ \x20 framing_angle: vec4,\n\ \x20 // The perspective map (FR-DEV-20) by columns, `.w` unused.\n\ \x20 keystone_c0: vec4,\n\ \x20 keystone_c1: vec4,\n\ \x20 keystone_c2: vec4,\n", ); uniform_values.extend_from_slice(&framing.uniforms()); // The warps, immediately after framing and before any operation, matching // where they sit in the shader. Slot order is emission order and nothing // addresses a slot by number, so this only has to be consistent with // itself — but keeping it in pipeline order is what makes the generated // struct readable next to the generated body. uniform_fields.push_str(&warp.uniform_fields); uniform_values.extend_from_slice(&warp.uniforms); for h in &warp.helpers { if !helpers.iter().any(|existing| existing.name == h.name) { helpers.push(*h); } } for op in &active { let id = op.descriptor().id.0; let prefix = sanitise(id); // Each op's uniforms are prefixed, so two operations may both declare // a field called `amount` without colliding. let op_uniforms = op.uniforms(); if !op_uniforms.is_empty() { let _ = writeln!(uniform_fields, " // {id}"); } for u in &op_uniforms { let _ = writeln!(uniform_fields, " {prefix}_{}: f32,", u.name); uniform_values.push(u.value); } for h in op.helpers() { if !helpers.iter().any(|existing| existing.name == h.name) { helpers.push(*h); } } // Rewrite bare uniform names to their prefixed struct fields, so a // fragment is written without knowing about any other operation. let mut fragment = op.wgsl_body(); for u in &op_uniforms { fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name)); } let _ = writeln!(body, "\n // ---- {id} ----"); let _ = writeln!(body, " {{"); for line in fragment.lines() { let _ = writeln!(body, " {line}"); } let _ = writeln!(body, " }}"); } // The local adjustments, after every global one: a masked exposure should // act on the tones the global chain arrived at, not on the ones it started // from. Their uniforms follow the global ops' in the block for the same // reason those follow framing's — slot order is emission order, and // nothing addresses a slot by number. let layers = crate::mask::compose_layers_revealing(masks, reveal); // TRACES: FR-DEV-19c // Held apart from the body, because it belongs after the output transform // rather than among the operations — see `mask::LayerShader::reveal`. // Empty for every composition nobody is looking at a mask through, which // is all of them but the screen's. let reveal_block = layers.reveal.clone(); uniform_fields.push_str(&layers.uniform_fields); uniform_values.extend_from_slice(&layers.uniform_values); body.push_str(&layers.body); for h in &layers.helpers { if !helpers.iter().any(|existing| existing.name == h.name) { helpers.push(*h); } } // Pad the uniform block to a 16-byte boundary. A struct whose size is not // a multiple of 16 is rejected by the WGSL uniform address space rules. let pad = (4 - (uniform_values.len() % 4)) % 4; for i in 0..pad { let _ = writeln!(uniform_fields, " _pad{i}: f32,"); uniform_values.push(0.0); } let mut helper_src = String::new(); for h in &helpers { let _ = writeln!(helper_src, "{}\n", h.source.trim_end()); } // The coordinate stage: output pixel -> source position -> colour. Emitted // ahead of the operation fragments, which receive the sampled `c`. // A warp puts an output pixel between source pixels exactly as a free // angle does, so either one forces the interpolating sampler. Asking // framing alone — which is what this did before the warps existed — would // have nearest-neighboured a distortion correction on an unstraightened // frame, and the aliasing would have looked like a bad profile. // `smooth` is the third reason, and the only one a caller states: the // white balance probe averages a patch and cannot do that through a // nearest-neighbour comb (see `compose_camera_probe`). let interpolate = smooth || framing.needs_interpolation() || warp.is_active(); // Declared ahead of the warp block, which assigns to them. They enter // equal to `p` so that a chain mixing a splitting warp with a // non-splitting one still carries every earlier correction into the red // and blue paths — a distortion correction must move all three channels, // and only the CA that follows it may move them apart. let channel_positions = if warp.splits_channels { "\n // Per-channel source positions, for the lateral CA correction.\n\ \x20 var p_r = p;\n\ \x20 var p_b = p;\n" } else { "" }; // TRACES: FR-MRG-2 // A pixel whose source coordinate leaves the frame — the corners a lens // correction pulls in — is stored black. For the display that is the // right picture; for a merge it is a pixel that does not exist and must // not be averaged in as if it did, so the camera-space tap marks it // with alpha 0 and the warp reads the alpha as validity. let void_alpha = match output_mode { OutputMode::CameraLinear => "0.0", _ => "1.0", }; let prologue = format!( "{}{}{}\n{}", framing.wgsl_prologue(), channel_positions, warp.body, sample_source(interpolate, warp.splits_channels).replace("VOID_ALPHA", void_alpha) ); let sampler_helper = if interpolate { BILINEAR_HELPER } else { "" }; // Everything that decides which texel an output pixel reads: the code that // computes `coord`, and the uniforms that code reads. Only on the path that // reads a texel whole — see `ComposedShader::sample_key`. let sample_key = (!interpolate && !warp.splits_channels).then(|| { framing .uniforms() .iter() .chain(&warp.uniforms) .fold(hash_source(&prologue), |h, v| { mix(h, u64::from(v.to_bits())) }) }); // The tail, and it is the whole of the difference between the two output // modes. Everything above — the prologue, the fragments, the mask layers, // the camera matrix — is emitted identically either way, so an operation // cannot tell whether a detail stage follows it and does not have to. let (store_format, to_output, encode_output, store) = match output_mode { OutputMode::Encoded => ( "rgba8unorm", primaries_conversion(output), encode_output_fn(output), " // Clip to the output gamut and encode. The clip is last for the reason the // matrix above is: a colour outside sRGB is still inside a wider space, and // clipping before the conversion would throw it away for no one's benefit. c = clamp(c, vec3(0.0), vec3(1.0)); textureStore(output, vec2(gid.xy), vec4(encode_output(c), 1.0));" .to_string(), ), OutputMode::LinearWorking => ( "rgba16float", String::new(), String::new(), " // Stop here: a detail stage follows, and it needs linear values it // can average. No primaries conversion, no clip and no encode — the // last detail pass performs all three, so the pipeline still quantises // exactly once (FR-DEV-2). // // Deliberately *not* clamped. A recovered highlight is above 1.0 at this // point and an out-of-gamut colour can be below 0.0; clipping them here // would put a hard edge into the very neighbourhood the next pass is // about to convolve, which is how sharpeners come to draw dark rings // around specular highlights. textureStore(output, vec2(gid.xy), vec4(c, 1.0));" .to_string(), ), OutputMode::CameraLinear => ( "rgba32float", String::new(), String::new(), " // Camera space, for a merge (FR-MRG-2): the sensor's numbers after the // lens warp, with the profile uniforms filled neutral by the caller so the // prologue above changed nothing. Not clamped, not encoded, full precision. textureStore(output, vec2(gid.xy), vec4(c, 1.0));" .to_string(), ), }; // The camera profile's rendering, which an operation may have taken over. // // Emitted as a unit because the two halves belong together: the base curve // is defined in camera RGB and the matrix is what leaves it, so an // operation that replaces one has necessarily replaced the other. Keeping // them as one string is what makes that impossible to get half right. let rendering_tail = if op_renders { " // The camera profile's base curve and the conversion out of camera\n // space are both absent: an operation declaring `Operation::renders`\n // has done both, and doing them again would render the picture twice.\n" .to_string() } else { " // ==== camera profile: the base curve (FR-DEV-3e) ==== // // Marked with `====` and not the `----` an operation block carries: this // is not one, and the difference is what several tests count on to tell // an edit apart from the reading of a file. // // The stage between demosaic and the working space that turns a correct // exposure into a photograph. Sensor data is scene-referred and nearly // linear; nothing anybody looks at is. Rendering it straight out is the // dcraw default, and it is flat, dark through the midtones and clips its // highlights instead of rolling them off. // // **In camera RGB, and after the adjustments**, which is a deliberate pair // of choices: // // - Before the matrix, because that is where a base curve is defined and // where every other converter applies one. The curve was tuned against // this body's own primaries; moving it after the conversion would apply // a Canon rendering to sRGB values and change what it does. // - After exposure and the tonal operations, because those are corrections // to *capture* and are only meaningful on linear values. A stop is a // doubling; run exposure after a curve and it stops being one. // // Per channel rather than on luminance. It desaturates the extremes // slightly, and that is the point — it is what makes a blown sky roll // toward white rather than toward a saturated corner of the gamut, and it // is what the camera's own JPEG does. // // The branch is on a uniform, so the whole dispatch takes the same path. // It is off for a JPEG and any other already-rendered source, which must // not be rendered twice, and for a body the profile database declines to // offer any curve for at all. if (u.base_curve_last.z > 0.5) { c = vec3( curve_eval( u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, u.base_curve_last.x, u.base_curve_last.y, c.r, ), curve_eval( u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, u.base_curve_last.x, u.base_curve_last.y, c.g, ), curve_eval( u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, u.base_curve_last.x, u.base_curve_last.y, c.b, ), ); } // Camera space -> linear sRGB. Applied after the adjustments so white // balance and exposure act on sensor-native values, which is where they // are physically meaningful. // // Identity for a non-linear source, which is already in sRGB primaries. c = vec3( dot(u.cam_to_srgb_0.rgb, c), dot(u.cam_to_srgb_1.rgb, c), dot(u.cam_to_srgb_2.rgb, c), ); " .to_string() }; // Formatted with Rust's `Display` so the shader reads the same threshold // the probe checks against; see `CLIP_ONSET`. let clip_onset = CLIP_ONSET; let source = format!( "// GENERATED — do not edit. // // Composed by dr-pipeline from {} active operation(s). Each block below is // one operation's fragment, run in graph order over a linear scene-referred // colour. Operations at neutral settings are omitted rather than branched // over, so this shader does exactly the work the current edit requires. struct Params {{ {uniform_fields}}} @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var u: Params; @group(0) @binding(2) var output: texture_storage_2d<{store_format}, write>; // The local adjustment masks, one array layer each, rasterised by a separate // pass (ARCH §5.4). Declared unconditionally even when no layer is active, so // that every generated shader shares one bind group layout — a layout that // changed with the edit would mean rebuilding the pipeline layout, and the // cost of the unused declaration is a 1x1 placeholder texture. @group(0) @binding(3) var masks: texture_2d_array; // A film stock's baked tables (FR-DEV-3f): the characteristic curves, and the // density lookup that carries everything downstream of them. Declared // unconditionally for the same reason the masks above are — one bind group // layout for every generated shader — and bound to 1x1 placeholders when no // stock is loaded, which costs eight bytes and no branch. @group(0) @binding(4) var film_curves: texture_2d; @group(0) @binding(5) var film_lut_texture: texture_3d; // The sample cache: the source texel each output pixel read on an earlier // frame with this framing, and where this frame writes it when asked. See // `ComposedShader::sample_key`. Declared unconditionally, like the masks, and // bound to 1x1 placeholders whenever the flags say not to touch them. @group(0) @binding(6) var sampled: texture_2d; @group(0) @binding(7) var sample_out: texture_storage_2d; {sampler_helper}{helper_src}{encode_output} // Display-encoded sRGB back to linear, for sources that arrive that way. // // A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded // where the demosaicer's are linear. Every operation below assumes linear // scene-referred colour — exposure is a multiply, and doubling a gamma-encoded // value is not a stop — so the encoding is undone here, once, at the only // point where the two source kinds still differ. fn decode_srgb(c: vec3) -> vec3 {{ let lo = c / 12.92; let hi = pow((max(c, vec3(0.04045)) + 0.055) / 1.055, vec3(2.4)); return select(hi, lo, c <= vec3(0.04045)); }} @compute @workgroup_size(8, 8, 1) fn main(@builtin(global_invocation_id) gid: vec3) {{ let dims = textureDimensions(output); if (gid.x >= dims.x || gid.y >= dims.y) {{ return; }} {prologue} // A non-linear source is already display-encoded; undo that so the // operations below see linear colour whatever the source was. let non_linear = u.as_shot_wb.w > 0.5; if (non_linear) {{ c = decode_srgb(c); }} // As-shot white balance. Applied unconditionally, before any operation, // because it is part of *interpreting* the sensor rather than an edit: a // Bayer sensor's green photosites collect far more signal than its red // and blue, so raw camera-space values are strongly green and no amount // of later correction recovers a neutral image from them. The white // balance operation, when active, applies its own offset on top of this. // // A non-linear source has already had this applied in-camera; the uniform // is neutral there, so this is a multiply by one rather than a branch. // How close this pixel was to saturation before any balance was applied. // A photosite at its white level carries no colour information — every // channel simply stopped counting — so the balance below must not be // allowed to tint it. let clipped = smoothstep({clip_onset}, 1.0, max(c.r, max(c.g, c.b))); c = c * u.as_shot_wb.rgb; // **Highlight desaturation, and without it every blown sky is magenta.** // // A fully clipped pixel arrives as (1, 1, 1). The as-shot multipliers are // not neutral — on a Canon 6D they are (1.93, 1.00, 1.68) — so balancing // sends it to exactly that, and the camera matrix then produces R 2.88, // G 0.51, B 2.03. Red and blue clip at one and green does not, which is // magenta. The balance is correct; the input was not a colour. // // So a saturated pixel is pulled back toward the neutral its raw values // actually represent, fading in over the last 1.5% of range. Smoothly, // because a hard switch puts a visible edge around every highlight where // the two treatments meet — a rim light on skin is the worst case, and it // is the one people notice. // // The neutral chosen is the balanced grey of the same brightness, so the // highlight keeps its luminance and loses only the cast. if (clipped > 0.0) {{ let neutral = vec3(max(c.r, max(c.g, c.b))); c = mix(c, neutral, clipped); }} {body} {rendering_tail}{to_output}{reveal_block} {store} }} ", active.len() ); // Taken over the generated source, because the source *is* the structure: // it is what gets compiled, and two compositions that produce different // WGSL are two pipelines however alike their op-sets look. // // Hashing the list of active operation ids instead — which is what this // did — assumed every operation emits the same code whatever its // parameters say. The colour mixer does not: it emits a block and a // uniform only for the bands that are set, so a red adjustment and a blue // one are the same op-set and different shaders. They shared a cache // entry, so the second was rendered with the first's compiled pipeline // while its uniforms were uploaded in an order that pipeline never agreed // to — whichever band was adjusted first kept acting, and every other // band appeared dead. // // Values still do not enter it, since no operation writes a parameter // value into its source; they arrive as uniforms, and dragging a slider // regenerates identical text. One that did inline a value would have to // recompile to be correct, and hashing the source says so rather than // silently reusing the wrong pipeline. // // Framing and the output space are mixed in as well, though both already // shape the source: the prologue's branches and the encode function are // written into it. Belt and braces on the two inputs whose contribution to // the source is indirect. let structure_hash = mix( mix(hash_source(&source), framing.structure_key()), output as u64, ); ComposedShader { source, uniforms: uniform_values, structure_hash, output_mode, sample_key, } } /// The WGSL converting linear sRGB into the output space's primaries. /// /// A constant matrix rather than a uniform: the space is chosen when the /// shader is composed, so the numbers are known at generation time and the /// driver can fold them into the surrounding arithmetic. /// /// Empty for sRGB, which is the space the pipeline already works in — the /// camera matrix converts into it, which is what `cam_to_srgb` is named for. /// Emitting an identity there would put nine constants and three dot products /// into the display path's shader, the one compiled most often, to compute the /// value it already had. The identity is detected rather than special-cased by /// name, so a space that happens to share sRGB's primaries would be spared /// too. pub(crate) fn primaries_conversion(output: ColourSpace) -> String { let m = output.from_linear_srgb(); const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]; // A tolerance rather than equality: the matrix is an inverse multiplied by // a product, so sRGB's own comes back a few ULP off the identity. A // millionth of a channel is four decimal places below an 8-bit step. if m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < 1e-6) { return String::new(); } let mut out = format!( "\n // Linear sRGB -> linear {}. The last colour transform before the\n\ \x20 // encode, and the reason a colour sRGB could not hold survives\n\ \x20 // this far: it is still inside this gamut.\n\ \x20 c = vec3(\n", output.label() ); // Entries below the printed precision are zero as far as the shader is // concerned, and a shared primary produces one every time. Snapping them // avoids emitting `-0.000000`, which reads as a sign error to whoever is // debugging a shader at the time. let show = |v: f32| if v.abs() < 5e-7 { 0.0 } else { v }; for row in 0..3 { let _ = writeln!( out, " dot(vec3({:.6}, {:.6}, {:.6}), c),", show(m[row * 3]), show(m[row * 3 + 1]), show(m[row * 3 + 2]) ); } out.push_str(" );\n"); out } /// The WGSL of the output space's transfer function. /// /// Named `encode_output` whatever the space, so the call site at the end of /// `main` does not have to know which one it got. pub(crate) fn encode_output_fn(output: ColourSpace) -> String { let body = match output.transfer() { Transfer::Srgb => " let lo = c * 12.92; let hi = 1.055 * pow(max(c, vec3(0.0031308)), vec3(1.0 / 2.4)) - 0.055; return select(hi, lo, c <= vec3(0.0031308));" .to_string(), // No linear segment at all, so no `select`: Adobe RGB (1998) is a // pure power curve, and inventing a toe for it would be a different // space wearing its name. Transfer::Gamma(g) => format!(" return pow(c, vec3(1.0 / {g:.8}));"), Transfer::Prophoto => " let lo = c * 16.0; let hi = pow(max(c, vec3(0.001953125)), vec3(1.0 / 1.8)); return select(hi, lo, c < vec3(0.001953125));" .to_string(), }; format!( "// Linear {} to its transfer function. // // The one place quantisation happens: everything above runs in linear f16, // and this is the final encode (ARCH §5.2). fn encode_output(c: vec3) -> vec3 {{ {body} }} ", output.label() ) } /// The WGSL turning the framed source position `p` into the colour `c`. /// /// Split out because it is the join between the coordinate stage and the /// colour stage, and because the choice it makes — an exact integer load, or /// a filtered sample — is the one thing the free-angle case changes. pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'static str { if splits_channels { // Lateral chromatic aberration is a per-channel radial magnification, // so red and blue are fetched from positions green is not — which is // the whole reason a warp is not an `Operation`. By the time a colour // reaches an operation the three channels have been sampled together // and the divergence is gone. // // Green never moves. It is the reference the other two are scaled // about, so a correction that is wrong still leaves one channel sharp // rather than softening all three. return " // Back to texture coordinates, once per channel. let uv_src = p / aspect + vec2(0.5); let uv_r = p_r / aspect + vec2(0.5); let uv_b = p_b / aspect + vec2(0.5); // Tested on green alone, not on all three. // // The three positions differ by a fraction of a pixel at any correction a // real lens needs, so testing each would only let the outermost row of the // frame disagree with itself about whether it exists — which draws a // coloured fringe along the edge, the exact artefact this is here to // remove. `sample_bilinear` clamps its own texel indices, so red and blue // land on the edge pixel rather than out of bounds. if (any(uv_src < vec2(0.0)) || any(uv_src >= vec2(1.0))) { textureStore(output, vec2(gid.xy), vec4(0.0, 0.0, 0.0, VOID_ALPHA)); return; } // TRACES: FR-DEV-3f // Where this pixel sits on the *source*, in source pixels. Green's // position, since that is the one that did not move. let source_px = uv_src * vec2(src_dims); let radius = length(p) / (0.5 * length(aspect)); // Three fetches, one channel kept from each. Two thirds of the work is // discarded, which is why `Warp::splits_channels` exists: with no CA in // the chain the single-sample path below is emitted instead. var c = vec3( sample_bilinear(uv_r, src_dims).r, sample_bilinear(uv_src, src_dims).g, sample_bilinear(uv_b, src_dims).b, ); "; } if interpolate { " // Back to texture coordinates. let uv_src = p / aspect + vec2(0.5); // Outside the source there is no pixel. A straightened frame exposes its // corners; render them black rather than clamping, which would smear an // edge pixel across them. if (any(uv_src < vec2(0.0)) || any(uv_src >= vec2(1.0))) { textureStore(output, vec2(gid.xy), vec4(0.0, 0.0, 0.0, VOID_ALPHA)); return; } // TRACES: FR-DEV-3f // Where this pixel sits on the *source*, in source pixels. Published for // fragments that need a position and not only a colour. // // The source and not the output, and that is the whole point: a pattern // seeded from the render swims as the photograph is zoomed, and grain is a // property of the film rather than of the view. Seeded from here it stays // put, and its *amount* is handled separately by how much film a pixel // covers -- see `dr_film::Grain`. let source_px = uv_src * vec2(src_dims); // A free angle puts output pixels between source pixels. Nearest-neighbour // here is what makes a straightened horizon stair-step, so interpolate. // Distance from the optical axis, normalised so the corner is exactly 1. // // Published beside `source_px` and for the same reason: a fragment is // handed a colour with no way back to a coordinate, and the radial // corrections need one. Derived from `p` after the whole coordinate stage, // so it measures the *source* frame — which is what a lens profile is // calibrated against, and why an off-centre crop still gets the falloff // its corner actually had rather than one centred on the crop. // // **The division is the part that is easy to leave out.** `p` spans // `+/-0.5 * aspect`, so at the corner its length is `0.5 * length(aspect)` // -- about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted // against a corner radius of 1, so passing `length(p)` straight in // evaluates every one of them short of where it was measured, and by an // amount that changes with the aspect ratio. It reads as a correction that // is simply too weak, which is indistinguishable from a bad profile. let radius = length(p) / (0.5 * length(aspect)); var c = sample_bilinear(uv_src, src_dims); " } else { " // Back to texture coordinates. let uv_src = p / aspect + vec2(0.5); // Outside the source there is no pixel — possible once the frame has been // transformed at all. Render it black rather than clamping, which would // smear an edge pixel across the gap. if (any(uv_src < vec2(0.0)) || any(uv_src >= vec2(1.0))) { textureStore(output, vec2(gid.xy), vec4(0.0, 0.0, 0.0, VOID_ALPHA)); return; } // Every output pixel lands on a source pixel, so load it directly: exact, // and with no interpolation to soften detail. let coord = min(vec2(uv_src * vec2(src_dims)), vec2(src_dims) - vec2(1)); // TRACES: FR-DEV-3f // Where this pixel sits on the *source*, in source pixels. Published for // fragments that need a position and not only a colour. // // The source and not the output, and that is the whole point: a pattern // seeded from the render swims as the photograph is zoomed, and grain is a // property of the film rather than of the view. Seeded from here it stays // put, and its *amount* is handled separately by how much film a pixel // covers -- see `dr_film::Grain`. let source_px = uv_src * vec2(src_dims); // Distance from the optical axis, normalised so the corner is exactly 1. // // Published beside `source_px` and for the same reason: a fragment is // handed a colour with no way back to a coordinate, and the radial // corrections need one. Derived from `p` after the whole coordinate stage, // so it measures the *source* frame — which is what a lens profile is // calibrated against, and why an off-centre crop still gets the falloff // its corner actually had rather than one centred on the crop. // // **The division is the part that is easy to leave out.** `p` spans // `+/-0.5 * aspect`, so at the corner its length is `0.5 * length(aspect)` // -- about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted // against a corner radius of 1, so passing `length(p)` straight in // evaluates every one of them short of where it was measured, and by an // amount that changes with the aspect ratio. It reads as a correction that // is simply too weak, which is indistinguishable from a bad profile. let radius = length(p) / (0.5 * length(aspect)); // The texel itself, from the source or from the cache of it an earlier // frame wrote (see `ComposedShader::sample_key`). Both branches yield the // same bits: the source is `rgba16float` and so is the cache. The flags // are uniforms, so the whole dispatch takes one branch. var c: vec3; if (u.sample_cache.x > 0.5) { c = textureLoad(sampled, vec2(gid.xy), 0).rgb; } else { c = textureLoad(source, coord, 0).rgb; if (u.sample_cache.y > 0.5) { textureStore(sample_out, vec2(gid.xy), vec4(c, 1.0)); } } " } } /// Bilinear sampling against an unfiltered `texture_2d`. /// /// Hand-rolled rather than done with a sampler: the source is bound as a plain /// texture, and adding a sampler for the straightening case alone would change /// a bind group layout that every pass shares. const BILINEAR_HELPER: &str = "fn sample_bilinear(uv: vec2, dims: vec2) -> vec3 { let last = vec2(dims) - vec2(1); // Half-texel offset: sample positions are texel *centres*. Without it the // image shifts by half a pixel and every rotation comes out slightly soft. let q = uv * vec2(dims) - vec2(0.5); let base = floor(q); let f = q - base; let i0 = clamp(vec2(base), vec2(0), last); let i1 = min(i0 + vec2(1), last); let c00 = textureLoad(source, vec2(i0.x, i0.y), 0).rgb; let c10 = textureLoad(source, vec2(i1.x, i0.y), 0).rgb; let c01 = textureLoad(source, vec2(i0.x, i1.y), 0).rgb; let c11 = textureLoad(source, vec2(i1.x, i1.y), 0).rgb; return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y); } "; /// Fold a value into a hash. FNV-1a's mixing step, over eight bytes. pub(crate) fn mix(mut h: u64, value: u64) -> u64 { for byte in value.to_le_bytes() { h ^= u64::from(byte); h = h.wrapping_mul(0x100_0000_01b3); } h } /// Hash the generated WGSL — the structure of the shader, not the values. /// /// Whole-source rather than a summary of what went into it: a summary has to /// be kept in step with every operation's code generation by hand, and the /// one that was here fell out of step with the colour mixer, which emits /// different code for different bands. /// /// Still integer state hashed on the CPU, as ARCH §6.13 requires of a cache /// key: the text is generated from parameters that are neutral or not, never /// from a rendered float. pub(crate) fn hash_source(source: &str) -> u64 { // FNV-1a: no dependency, stable across runs and platforms, which the // shader cache key requires. let mut h: u64 = 0xcbf2_9ce4_8422_2325; for byte in source.as_bytes() { h ^= u64::from(*byte); h = h.wrapping_mul(0x100_0000_01b3); } h } /// Replace whole-word occurrences of `name` with `replacement`. /// /// Whole-word matching matters: an operation with uniforms `amount` and /// `amount_hi` must not have the first rewrite corrupt the second. /// /// Shared with [`crate::lens`], which prefixes its uniforms by the same rule /// and must not diverge from it. /// Comments are skipped. A fragment explaining what `factor` does should not /// have its prose rewritten to `u.saturation_factor` — the generated source /// is meant to be read when a shader fails to compile, and mangled comments /// make that harder rather than easier. pub(crate) fn rewrite_uniform(src: &str, name: &str, replacement: &str) -> String { let mut out = String::with_capacity(src.len()); let bytes = src.as_bytes(); let mut i = 0; // Tracks whether the cursor sits inside a `//` comment. WGSL fragments // use line comments only, so this needs no block-comment handling. let mut in_comment = false; while i < src.len() { if bytes[i] == b'\n' { in_comment = false; } else if !in_comment && src[i..].starts_with("//") { in_comment = true; } if !in_comment && src[i..].starts_with(name) { let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]); let after = i + name.len(); let after_ok = after >= src.len() || !is_ident_byte(bytes[after]); if before_ok && after_ok { out.push_str(replacement); i = after; continue; } } // Push one full character, not one byte, so non-ASCII in a comment // does not split a UTF-8 sequence. let ch = src[i..].chars().next().expect("in bounds"); out.push(ch); i += ch.len_utf8(); } out } fn is_ident_byte(b: u8) -> bool { b.is_ascii_alphanumeric() || b == b'_' } /// Make an operation id safe to embed in a WGSL identifier. pub(crate) fn sanitise(id: &str) -> String { id.chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect() } #[cfg(test)] mod tests { use super::*; use std::sync::LazyLock; use crate::descriptor::Attribute; use crate::descriptor::{LocalizedKey, OpId, ParamDescriptor}; static DESC_A: LazyLock> = LazyLock::new(|| { Arc::new(OpDescriptor { id: OpId("op_a"), label: LocalizedKey("a"), params: vec![ParamDescriptor::amount("amount", "a.amount")], attributes: vec![Attribute::Tone], }) }); static DESC_B: LazyLock> = LazyLock::new(|| { Arc::new(OpDescriptor { id: OpId("op_b"), label: LocalizedKey("b"), params: vec![ParamDescriptor::amount("amount", "b.amount")], attributes: vec![Attribute::Tone], }) }); struct Fake { desc: Arc, amount: f32, helper: Option, } impl Operation for Fake { fn descriptor(&self) -> Arc { self.desc.clone() } 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 } fn wgsl_body(&self) -> String { "c = c * amount;".into() } fn uniforms(&self) -> Vec { vec![Uniform { name: "amount", value: self.amount, }] } fn helpers(&self) -> &'static [Helper] { match self.helper { Some(_) => SHARED, None => &[], } } } static SHARED: &[Helper] = &[Helper { name: "luma", source: "fn luma(c: vec3) -> f32 { return c.g; }", }]; fn fake(desc: Arc, amount: f32, helper: bool) -> Box { Box::new(Fake { desc, amount, helper: helper.then_some(SHARED[0]), }) } #[test] fn an_inactive_operation_contributes_nothing() { // The point of composing rather than branching: an op at neutral // must not appear in the source at all. let ops = vec![fake(DESC_A.clone(), 0.0, false)]; let shader = compose(&ops); assert!( !shader.source.contains("op_a"), "a neutral operation must not reach the generated shader" ); assert_eq!( shader.uniforms.len(), PREAMBLE_FIELDS, "it must contribute no uniforms either" ); } /// Uniform slots reserved before any operation's own: the camera matrix /// and as-shot white balance, plus framing. The same constant `dr-gpu` /// writes against, so these offsets cannot agree with each other while /// disagreeing with the shader. const PREAMBLE_FIELDS: usize = RESERVED_UNIFORM_FIELDS; #[test] fn an_active_operation_appears_once() { let ops = vec![fake(DESC_A.clone(), 2.0, false)]; let shader = compose(&ops); assert!(shader.source.contains("---- op_a ----")); assert!(shader.source.contains("u.op_a_amount")); } #[test] fn uniforms_are_prefixed_so_operations_cannot_collide() { // Both fakes declare a uniform called `amount`. Without prefixing, // the generated struct would have a duplicate field and fail to // compile — the failure mode that makes naive concatenation fragile. let ops = vec![ fake(DESC_A.clone(), 1.0, false), fake(DESC_B.clone(), 2.0, false), ]; let shader = compose(&ops); assert!(shader.source.contains("op_a_amount: f32")); assert!(shader.source.contains("op_b_amount: f32")); assert!(shader.source.contains("c = c * u.op_a_amount;")); assert!(shader.source.contains("c = c * u.op_b_amount;")); } #[test] fn uniform_values_follow_declaration_order() { let ops = vec![ fake(DESC_A.clone(), 1.5, false), fake(DESC_B.clone(), 2.5, false), ]; let shader = compose(&ops); assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5); assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5); } #[test] fn a_shared_helper_is_emitted_once() { // Two operations wanting the same helper must not produce a // duplicate function definition. let ops = vec![ fake(DESC_A.clone(), 1.0, true), fake(DESC_B.clone(), 1.0, true), ]; let shader = compose(&ops); assert_eq!( shader.source.matches("fn luma(").count(), 1, "a helper requested twice must be declared once" ); } #[test] fn the_uniform_block_is_16_byte_aligned() { // WGSL rejects a uniform struct whose size is not a multiple of 16. for n in 0..6 { let ops: Vec> = (0..n) .map(|i| { fake( if i % 2 == 0 { DESC_A.clone() } else { DESC_B.clone() }, 1.0, false, ) }) .collect(); let shader = compose(&ops); assert_eq!( shader.uniforms.len() % 4, 0, "{n} operations produced {} floats, not a multiple of 4", shader.uniforms.len() ); } } use crate::lens::Warp as _; /// A neutral warp list must leave the shader exactly as it was. /// /// The property the whole `is_active` filter exists for: an unedited /// photograph keeps the integer `textureLoad` path, and pays nothing — /// not a bilinear fetch, not a uniform slot, not a recompile — for /// corrections nobody has asked for. #[test] fn warps_at_neutral_change_nothing_at_all() { let ops = crate::ops::chain(); let framing = Framing::new(); let without = compose_full( &ops, &framing, ColourSpace::Srgb, &MaskStack::new(), &crate::spot::SpotSet::new(), &[], ); let with_neutral = compose_full( &ops, &framing, ColourSpace::Srgb, &MaskStack::new(), &crate::spot::SpotSet::new(), &[ Box::new(crate::ops::Distortion::new()) as Box, Box::new(crate::ops::Aberration::new()), ], ); assert_eq!(without.source, with_neutral.source); assert_eq!(without.structure_hash, with_neutral.structure_hash); assert_eq!(without.uniforms, with_neutral.uniforms); assert!( !without.source.contains("sample_bilinear"), "an unwarped, unstraightened frame must keep the integer load path" ); } /// TRACES: FR-DEV-19c /// **The property that keeps a reveal off an exported file.** /// /// `compose_full` is the entry point the exporter, the thumbnail and the /// neutral probe all use, and it has no argument that could ask for a /// mask overlay. Only `compose_full_revealing` does, and only the canvas /// calls it. Asserted rather than left to the type signature because the /// tempting simplification — a flag on the graph — would type-check, be /// shorter, and bake a red tint into every file the photographer sold. #[test] fn an_ordinary_composition_cannot_draw_a_mask_over_the_picture() { use crate::mask::{MaskLayer, MaskSource, Reveal, RevealStyle}; let mut stack = MaskStack::new(); let mut layer = MaskLayer::new("m1", MaskSource::brush()); layer.set_param("exposure", crate::descriptor::ParamId("exposure"), 1.0); stack.push(layer); let plain = compose_full( &crate::ops::chain(), &Framing::new(), ColourSpace::Srgb, &stack, &crate::spot::SpotSet::new(), &[], ); assert!( !plain.source.contains("==== showing mask"), "an export must never carry the overlay" ); let shown = compose_full_revealing( &crate::ops::chain(), &Framing::new(), ColourSpace::Srgb, &stack, &crate::spot::SpotSet::new(), &[], Some(&Reveal::one("m1", RevealStyle::Tint)), ); assert!(shown.source.contains("==== showing mask")); assert_ne!( plain.structure_hash, shown.structure_hash, "two different shaders must not share a pipeline cache entry" ); } /// Distortion alone samples once; chromatic aberration samples three times. /// /// `splits_channels` is the whole reason for this test. Lateral CA fetches /// red and blue from positions green is not, and paying that everywhere /// would triple the texture bandwidth of the common case — a distortion /// correction with no CA, which is most lens profiles. #[test] fn only_chromatic_aberration_splits_the_channels() { let compose_with = |warps: Vec>| { compose_full( &crate::ops::chain(), &Framing::new(), ColourSpace::Srgb, &MaskStack::new(), &crate::spot::SpotSet::new(), &warps, ) .source }; let mut distortion = crate::ops::Distortion::new(); distortion.set_param(crate::ops::distortion::AMOUNT, 40.0); let only_distortion = compose_with(vec![Box::new(distortion)]); assert!( only_distortion.contains("---- warp: distortion ----"), "an active distortion must reach the shader" ); assert!( only_distortion.contains("sample_bilinear"), "a warp puts output pixels between source pixels, so it forces \ the interpolating sampler even on an unstraightened frame" ); assert!( !only_distortion.contains("var p_r"), "distortion moves all three channels together and must not pay \ for the per-channel path" ); let mut ca = crate::ops::Aberration::new(); ca.set_param(crate::ops::aberration::RED, 25.0); let with_ca = compose_with(vec![Box::new(ca)]); assert!( with_ca.contains("var p_r"), "CA needs per-channel positions" ); assert_eq!( with_ca.matches("sample_bilinear(").count(), // Three fetches in the body, plus the helper's own definition. 4, "CA must fetch each channel from its own position" ); } /// An active warp is a different shader and must not reuse the cached one. /// /// Covered by `hash_source` rather than by anything warp-specific — the /// body is written into the source — but asserted because the alternative /// failure is silent: the correction would simply never appear, exactly as /// a zoom did before `Framing::structure_key` gained its last bit. #[test] fn arming_a_warp_recompiles_but_moving_its_slider_does_not() { let compose_at = |amount: f32| { let mut d = crate::ops::Distortion::new(); d.set_param(crate::ops::distortion::AMOUNT, amount); compose_full( &crate::ops::chain(), &Framing::new(), ColourSpace::Srgb, &MaskStack::new(), &crate::spot::SpotSet::new(), &[Box::new(d) as Box], ) }; let neutral = compose_at(0.0); let armed = compose_at(40.0); let further = compose_at(70.0); assert_ne!( neutral.structure_hash, armed.structure_hash, "arming a warp adds a block to the shader and must recompile" ); assert_eq!( armed.structure_hash, further.structure_hash, "its magnitude is a uniform, so a drag must not recompile" ); assert_ne!(armed.uniforms, further.uniforms); } #[test] fn structure_hash_ignores_values_but_tracks_the_op_set() { // The property the shader cache depends on: moving a slider must not // trigger a recompile, but enabling an operation must. let a1 = compose(&[fake(DESC_A.clone(), 1.0, false)]).structure_hash; let a2 = compose(&[fake(DESC_A.clone(), 9.0, false)]).structure_hash; assert_eq!(a1, a2, "a value change must reuse the compiled pipeline"); let both = compose(&[ fake(DESC_A.clone(), 1.0, false), fake(DESC_B.clone(), 1.0, false), ]); assert_ne!(a1, both.structure_hash, "a different op-set must recompile"); } #[test] fn structure_hash_is_order_sensitive() { // Operation order is data (ARCH §3.4); two orders are different // shaders and must not share a cache entry. let ab = compose(&[ fake(DESC_A.clone(), 1.0, false), fake(DESC_B.clone(), 1.0, false), ]); let ba = compose(&[ fake(DESC_B.clone(), 1.0, false), fake(DESC_A.clone(), 1.0, false), ]); assert_ne!(ab.structure_hash, ba.structure_hash); } #[test] fn rewriting_respects_word_boundaries() { // `amount` must not corrupt `amount_hi` — the bug a naive // string replace would introduce. let got = rewrite_uniform("x = amount + amount_hi;", "amount", "u.p_amount"); assert_eq!(got, "x = u.p_amount + amount_hi;"); } #[test] fn rewriting_leaves_comments_alone() { // Found in generated source: a comment reading "A factor of 0 is // monochrome" came out as "A u.saturation_factor of 0 is monochrome". // The generated source is what gets read when a shader fails to // compile, so mangling it works against the one time it matters. let got = rewrite_uniform( "// A factor of 0 is monochrome\nc = c * factor;", "factor", "u.op_factor", ); assert_eq!(got, "// A factor of 0 is monochrome\nc = c * u.op_factor;"); } #[test] fn rewriting_resumes_after_a_comment_ends() { let got = rewrite_uniform( "// factor here is prose\nlet x = factor;\n// factor again\n", "factor", "u.p", ); assert_eq!( got, "// factor here is prose\nlet x = u.p;\n// factor again\n" ); } #[test] fn rewriting_leaves_substrings_alone() { let got = rewrite_uniform("total_amount = 1.0;", "amount", "u.a"); assert_eq!(got, "total_amount = 1.0;"); } #[test] fn as_shot_white_balance_is_applied_even_with_no_operations() { // The bug this catches, seen on a real CR2: a Bayer sensor's green // photosites collect roughly twice the signal of its red and blue, // so an image rendered without the as-shot multipliers comes out // violently green. It must not depend on the white balance operation // being active — that one carries only the user's offset. let shader = compose(&[]); assert!( shader.source.contains("u.as_shot_wb"), "a neutral edit must still apply as-shot white balance" ); } #[test] fn white_balance_is_applied_before_the_operations() { // Exposure and the tonal controls act on white-balanced values; if // the multiply came afterwards, every operation would be reasoning // about a green-cast image. let ops = vec![fake(DESC_A.clone(), 2.0, false)]; let source = compose(&ops).source; let wb = source.find("u.as_shot_wb").expect("wb applied"); let op = source.find("---- op_a ----").expect("op present"); assert!(wb < op, "as-shot white balance must precede the operations"); } #[test] fn a_rendering_operation_takes_over_the_base_curve_and_the_camera_matrix() { // TRACES: FR-DEV-3e | FR-DEV-3f // A film stock's characteristic curve does the base curve's job, and // the film node converts out of camera space itself. Emitting the // profile's rendering as well would render the scene twice and convert // it twice — a picture that comes out looking like neither the camera's // rendering nor the film's, with a colour-management bug's signature // and no colour-management bug to find. let mut film = crate::ops::FilmSim::new(); film.set_film_tables(Some(&crate::ops::FilmTables { exposure_matrix: [[5.0, 0.5, 0.2], [0.1, 5.0, 0.3], [0.2, 0.5, 4.0]], curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES], curve_log_min: -3.0, curve_log_max: 4.0, lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], density_max: 3.0, lut_size: 32, grain_particles: [0.0; 3], grain_density_max: [3.0; 3], grain_uniformity: 0.97, })); assert!(film.is_active(), "the fixture did not load"); let source = compose(&[Box::new(film) as Box]).source; assert!( source.contains("---- film_sim ----"), "the operation itself must still be emitted" ); assert!( !source.contains("base_curve_last.z > 0.5"), "the base curve is still being applied on top of the film" ); // Asserted on the composer's own comment, not on the conversion // itself: the film fragment performs exactly the same three dot // products, so a substring search cannot tell the composer's copy from // the operation's. What must be gone is the *second* one. assert!( !source.contains("Camera space -> linear sRGB"), "the composer converted out of camera space after the film already had" ); assert_eq!( source.matches("dot(u.cam_to_srgb_0.rgb, c)").count(), 1, "camera space is left exactly once, and it is the film that does it" ); } #[test] fn an_operation_that_does_not_render_leaves_the_profile_alone() { // The other half, and the one that would fail silently: a bug that // suppressed the tail unconditionally renders every ordinary edit // flat and uncorrected, which reads as a broken camera profile. let source = compose(&[fake(DESC_A.clone(), 2.0, false)]).source; assert!(source.contains("base_curve_last.z > 0.5")); assert!(source.contains("Camera space -> linear sRGB")); } #[test] fn an_inactive_film_node_leaves_the_profile_alone() { // `renders()` is a property of the type, but the suppression must key // off whether it is *active*. A film node sitting in the chain with no // stock loaded is the default state of every photograph in the // catalogue, and it must not disturb the camera's own rendering. let film: Box = Box::new(crate::ops::FilmSim::new()); let source = compose(&[film, fake(DESC_A.clone(), 2.0, false)]).source; assert!(source.contains("base_curve_last.z > 0.5")); assert!(source.contains("Camera space -> linear sRGB")); } #[test] fn the_camera_matrix_is_applied_after_the_operations() { // Adjustments are meaningful in sensor-native space, where highlight // headroom still exists; converting first would clip it away. let ops = vec![fake(DESC_A.clone(), 2.0, false)]; let source = compose(&ops).source; let op = source.find("---- op_a ----").expect("op present"); let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied"); assert!(op < matrix, "the camera matrix must come after operations"); } #[test] fn the_base_curve_runs_after_the_operations_and_before_the_camera_matrix() { // TRACES: FR-DEV-3e // Both halves matter and for different reasons. // // After the operations: exposure and the tonal controls are // corrections to capture, and they are only meaningful on linear // values. A stop is a doubling; run exposure after a curve and it is // not one any more, and every slider in the panel starts lying about // what it does. // // Before the matrix: the curve was tuned against this body's own // primaries. Applied after the conversion it would be a Canon // rendering acting on sRGB values, which is a different curve. let ops = vec![fake(DESC_A.clone(), 2.0, false)]; let source = compose(&ops).source; let op = source.find("---- op_a ----").expect("op present"); let curve = source .find("if (u.base_curve_last.z > 0.5)") .expect("base curve applied"); let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied"); assert!(op < curve, "the base curve must come after the operations"); assert!(curve < matrix, "and before the camera matrix"); } #[test] fn the_base_curve_reaches_a_shader_with_no_operations_at_all() { // TRACES: FR-DEV-3e // The same property as as-shot white balance, and for the same reason: // it is part of interpreting the file, not part of the edit. An // unedited RAW must open looking like a photograph rather than like a // scan of one. let shader = compose(&[]); assert!(shader.source.contains("u.base_curve_x")); assert!( shader.source.contains("fn curve_eval("), "the spline it is evaluated on must be emitted too" ); } #[test] fn the_base_curve_and_the_tone_curve_share_one_spline() { // TRACES: FR-DEV-3e // Two `curve_eval`s in one shader would not compile — but the reason // the helper is *shared* rather than merely renamed is that a profile // author placing a control point and a photographer dragging one must // mean the same thing by it, down to the tangent limiting that decides // how a shoulder rolls off. let mut curve = crate::ops::ToneCurve::new(); curve.set_param(crate::ops::curve::P2_Y, 0.7); assert!( curve.is_active(), "the fixture must actually reach the shader" ); let source = compose(&[Box::new(curve)]).source; assert_eq!( source.matches("fn curve_eval(").count(), 1, "the spline must be declared exactly once" ); assert_eq!(source.matches("fn curve_span(").count(), 1); } #[test] fn the_base_curve_owns_the_slots_dr_gpu_writes() { // TRACES: FR-DEV-3e // `dr-gpu` fills these by index. The offset is exported rather than // recomputed there, and this asserts the exported number still points // at the block the shader declares — the failure otherwise is a // highlight rolloff read out of a crop rectangle, which renders as // nonsense rather than as an error. assert_eq!( BASE_CURVE_UNIFORM_OFFSET + BASE_CURVE_UNIFORM_FIELDS, BASE_UNIFORM_FIELDS, "the base curve must be the last thing in the base block" ); assert_eq!(BASE_CURVE_POINTS * 2 + 1, BASE_CURVE_UNIFORM_FIELDS - 1); assert!(compose(&[]).uniforms.len() >= BASE_UNIFORM_FIELDS); } /// Compose with neutral framing into a chosen output space. fn compose_to(ops: &[Box], output: ColourSpace) -> ComposedShader { compose_with_framing(ops, &Framing::new(), output) } #[test] fn an_srgb_render_is_byte_for_byte_what_it_was_before_output_spaces_existed() { // The display path is the shader compiled on nearly every frame, and // it must not pick up an identity matrix multiply for the sake of // generality. Asserted against the source rather than against timing, // which would not fail reliably. let ops = vec![fake(DESC_A.clone(), 1.0, false)]; let srgb = compose_to(&ops, ColourSpace::Srgb).source; assert!( !srgb.contains("Linear sRGB -> linear sRGB"), "sRGB in, sRGB out must emit no conversion:\n{srgb}" ); assert_eq!(srgb, compose(&ops).source); } #[test] fn a_wide_output_space_converts_after_the_camera_matrix_and_before_the_clip() { // The whole point of the ordering. The camera matrix lands the colour // in linear sRGB, the primaries conversion carries it into the wider // space, and only then is it clipped — clipping first would discard // exactly the colours the wider space was chosen to keep. let source = compose_to(&[fake(DESC_A.clone(), 1.0, false)], ColourSpace::DisplayP3).source; let camera = source.find("u.cam_to_srgb_0").expect("camera matrix"); let convert = source .find("Linear sRGB -> linear Display P3") .expect("primaries conversion"); let clip = source.find("c = clamp(c,").expect("clip"); assert!(camera < convert, "the camera matrix must come first"); assert!(convert < clip, "the clip must come after the conversion"); } #[test] fn the_camera_space_tap_marks_a_pixel_off_the_sensor_with_alpha_zero() { // TRACES: FR-MRG-2 // A lens correction pulls the corners in, and the pixels it leaves // behind have no source. The display stores them black and opaque; // the tap stores them black and *transparent*, so a merge can tell // "nothing here" from "black here" and never averages the fringe in. let tap = compose_camera_linear( &[], dr_types::Orientation::default(), crate::framing::CropRect::default(), ) .source; assert!( tap.contains("vec4(0.0, 0.0, 0.0, 0.0)"), "the tap must store alpha 0 off the sensor:\n{tap}" ); assert!( !tap.contains("VOID_ALPHA"), "the placeholder must be substituted:\n{tap}" ); let display = compose(&[]).source; assert!( !display.contains("vec4(0.0, 0.0, 0.0, 0.0)"), "the display keeps its opaque black:\n{display}" ); assert!(!display.contains("VOID_ALPHA")); } #[test] fn the_generated_matrix_is_the_one_the_profile_writer_will_use() { // The shader encodes the pixels and `dr-export` describes them, from // the same table in `dr-types`. If the composer ever grew its own copy // of these numbers the file would be labelled with primaries it does // not contain, which is the failure the whole feature exists to avoid. let m = ColourSpace::DisplayP3.from_linear_srgb(); let first_row = format!("dot(vec3({:.6}, {:.6}, {:.6}), c)", m[0], m[1], m[2]); let source = compose_to(&[], ColourSpace::DisplayP3).source; assert!( source.contains(&first_row), "expected {first_row} in:\n{source}" ); } #[test] fn every_output_space_encodes_with_its_own_transfer_function() { // Adobe RGB's pure 2.199 gamma and ProPhoto's 1.8-with-a-toe are not // the sRGB curve, and a file encoded with the wrong one is wrong in a // way no amount of correct primaries repairs. let marks = [ (ColourSpace::Srgb, "1.0 / 2.4"), (ColourSpace::DisplayP3, "1.0 / 2.4"), (ColourSpace::AdobeRgb, "1.0 / 2.19921875"), (ColourSpace::ProPhoto, "1.0 / 1.8"), ]; for (space, mark) in marks { let source = compose_to(&[], space).source; assert!( source.contains(mark), "{space:?} should encode with {mark}:\n{source}" ); } } #[test] fn the_output_space_changes_the_structure_hash() { // The pipeline cache is keyed on this hash. Two spaces sharing one // would have the second rendered with the first's compiled shader — // an export that came out sRGB and claimed to be Display P3. let mut seen: Vec = Vec::new(); for space in ColourSpace::ALL { let h = compose_to(&[fake(DESC_A.clone(), 1.0, false)], space).structure_hash; assert!(!seen.contains(&h), "{space:?} collides with another space"); seen.push(h); } } #[test] fn generated_source_carries_a_do_not_edit_banner() { // Someone will eventually find this in a debugger and try to fix it // in place. let shader = compose(&[fake(DESC_A.clone(), 1.0, false)]); assert!(shader.source.starts_with("// GENERATED")); } #[test] fn detail_operations_agree_with_themselves() { // An operation says which stage it belongs to in two places — through // `affects()` and through `detail()` — and the two must say the same // thing. Disagreement is the worst possible failure mode here, because // it is silent: an operation claiming `Affects::Detail` while // returning `None` from `detail()` is fused as a point op and asked // for a fragment it does not have, and one returning `Some` while // claiming `Affects::Colour` is filtered out of the fused pass and put // in the wrong invalidation bucket. Either way the slider moves and // nothing happens. // // Checked over the real chain, plus the test consumer, so that a // sharpening operation added later is covered by this without anyone // remembering to extend it. let mut ops = crate::ops::chain(); ops.push(Box::new(crate::detail::probe::BoxBlur::new())); for op in &ops { let id = op.descriptor().id; assert_eq!( op.detail().is_some(), op.affects() == Affects::Detail, "{id} disagrees with itself about whether it is a \ neighbourhood operation" ); } } #[test] fn a_detail_operation_never_contributes_a_fused_uniform() { // Slot order in the generated block is emission order, and nothing // addresses a slot by number — so an operation that contributed a // uniform without contributing the fragment that reads it would shift // every later operation's uniforms out from under its shader. The // filter in `compose_full` prevents it; this is the assertion that the // filter is on the right side of the loop. let mut ops = crate::ops::chain(); ops.push(Box::new(crate::detail::probe::BoxBlur::with_radius(0.05))); let before = compose(&crate::ops::chain()).uniforms.len(); assert_eq!(compose(&ops).uniforms.len(), before); } }