//! 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 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/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`. 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 { /// Static description, driving UI generation (FR-DEV-3a). fn descriptor(&self) -> &'static OpDescriptor; /// 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. fn helpers(&self) -> &'static [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 } } /// 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, } /// 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, } /// 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 + BASE_CURVE_UNIFORM_FIELDS; /// 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 = 16; /// 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 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(), ) } /// 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, ) -> ComposedShader { // 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. let output_mode = 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 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", ); uniform_values.extend_from_slice(&framing.uniforms()); 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(masks); 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`. let prologue = format!( "{}\n{}", framing.wgsl_prologue(), sample_source(framing.needs_interpolation()) ); let sampler_helper = if framing.needs_interpolation() { BILINEAR_HELPER } else { "" }; // 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(), ), }; // 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() }; 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; {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(0.985, 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} {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, } } /// 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) -> &'static str { 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, 1.0)); 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. 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, 1.0)); 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); var c = textureLoad(source, coord, 0).rgb; " } } /// 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 crate::descriptor::Attribute; use crate::descriptor::{LocalizedKey, OpId, ParamDescriptor}; static DESC_A: OpDescriptor = OpDescriptor { id: OpId("op_a"), label: LocalizedKey("a"), params: &[ParamDescriptor::amount("amount", "a.amount")], attributes: &[Attribute::Tone], }; static DESC_B: OpDescriptor = OpDescriptor { id: OpId("op_b"), label: LocalizedKey("b"), params: &[ParamDescriptor::amount("amount", "b.amount")], attributes: &[Attribute::Tone], }; struct Fake { desc: &'static OpDescriptor, amount: f32, helper: Option, } impl Operation for Fake { fn descriptor(&self) -> &'static OpDescriptor { self.desc } 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: &'static OpDescriptor, 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, 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, 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, 1.0, false), fake(&DESC_B, 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, 1.5, false), fake(&DESC_B, 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, 1.0, true), fake(&DESC_B, 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 } else { &DESC_B }, 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() ); } } #[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, 1.0, false)]).structure_hash; let a2 = compose(&[fake(&DESC_A, 9.0, false)]).structure_hash; assert_eq!(a1, a2, "a value change must reuse the compiled pipeline"); let both = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 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, 1.0, false), fake(&DESC_B, 1.0, false)]); let ba = compose(&[fake(&DESC_B, 1.0, false), fake(&DESC_A, 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, 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, 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, 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, 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, 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, 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, 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_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, 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, 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); } }