//! 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-3f /// The stock's tables this operation holds, for a mask layer's copy of it. /// /// The other half of [`Self::set_film_tables`]. A layer holds offsets, /// never a stock of its own — the photograph is made on one film — so the /// composer hands the global operation's tables to the layer's combined /// copy through this pair. fn film_tables(&self) -> Option<&crate::ops::film_sim::FilmTables> { None } /// TRACES: FR-DEV-3 | FR-DEV-3f /// Whether a mask layer's version of this operation is blended as /// *settings* rather than as a result. /// /// Default `false`: each layer's version runs on the same input as the /// global one and the results are blended by weight, which is right for an /// adjustment. An operation that answers `true` has every uniform linear /// in what it controls, so the composer can blend the uniforms instead — /// the weighted average of every overlapping layer's settings, the global /// setting taking whatever weight the layers leave — and run the fragment /// once. See `local_settings_block`. fn blends_settings(&self) -> bool { false } /// TRACES: FR-DEV-3e | FR-DEV-3f /// Whether this operation *is* the rendering, rather than an adjustment to /// one. /// /// Almost everything returns `false`. A [`Stage::View`] operation that /// returns `true` is a complete rendering: while it is active the composer /// emits it in the view transform's place and not the default view /// transform beside it (FR-DEV-3j). /// /// 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 view transform's job, from /// measurements, and running both is the default rendering of the scene /// followed by a film's rendering of *that*. fn renders(&self) -> bool { false } /// TRACES: FR-DEV-2 | FR-DEV-3e /// Which colour this operation is handed. See [`Stage`]. /// /// Default [`Stage::Scene`], which is every operation but white balance. fn stage(&self) -> Stage { Stage::Scene } /// 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>) {} } /// TRACES: FR-DEV-2 | FR-DEV-3e /// Camera RGB to the working space, emitted between the camera-stage /// operations and the scene-stage ones (see [`Stage`]). /// /// Identity for a non-linear source, which is already in sRGB primaries, and /// for the camera-space tap, whose caller fills it so (FR-MRG-2). const CAMERA_MATRIX: &str = " // ==== camera profile: the matrix (FR-DEV-3e) ==== // // Camera RGB -> linear sRGB primaries, unbounded. After white balance, // whose multipliers are defined on the sensor's channels, and before every // other operation, which is handed working-space colour so that a hue or // a luminance weight means the same thing on every body (D19). 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), ); "; /// TRACES: FR-DEV-2 | FR-DEV-3e /// Which colour a point operation is handed (D19, ARCH §5.2). /// /// The composer emits every [`Stage::Camera`] operation, then the camera /// matrix, then every [`Stage::Scene`] one, each group in graph order. Before /// D19 there was no such split: every operation ran in camera RGB and the /// matrix came after them all, so `luminance()`'s Rec.709 weights were /// applied to camera primaries and a band in the colour mixer was a /// different hue on each body. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Stage { /// Camera RGB, balanced as shot, ahead of the matrix. /// /// For white balance alone. Its multipliers scale the sensor's own /// channels, and a matrix that mixes them would make the same numbers a /// different correction once it had run. Camera, /// Working-space colour: linear Rec.709 primaries, scene-referred, /// unbounded. Nothing here clamps above 1.0 or encodes (ARCH §6.14). Scene, /// The view transform (FR-DEV-3j): after every scene operation, and the /// one stage allowed to map the scene to a display range. `view_transform` /// and `film_sim` are its two operations, and one of them runs: the film /// while a stock is loaded (see [`Operation::renders`]), the sigmoid /// otherwise. /// /// Composed **whatever its state**. A neutral operation is otherwise left /// out, but a photograph with no view transform is a scan rather than a /// picture, so a view operation at its defaults still renders — with its /// defaults. "Active" keeps its meaning of "moved from the defaults", /// which is what the sidecar and the panel read. When the chain holds no /// view operation at all the composer supplies a default one. View, } /// 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, no view transform — 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, /// TRACES: FR-DEV-3j /// The view pass, for a shader that stops at [`OutputMode::LinearWorking`]. /// /// The view transform runs after the detail stage (D19): a sharpener or /// a blur must convolve scene-linear values, not a display rendering. So /// when a detail stage follows, this shader stops short of the view /// transform, and this second shader — the same prologue for the /// positions a fragment reads, the colour taken from the detail stage's /// result bound as `sampled`, then the view transform and the output /// transform — writes the display texture. `None` for every other mode. pub view: 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: the matrix, the as-shot /// balance and the sample cache's flags. Framing's block follows. /// /// Eight of them are the source window ([`SOURCE_WINDOW_UNIFORM_FIELDS`]), /// which took the end of the block when D19 retired the base curve there. const BASE_UNIFORM_FIELDS: usize = 16 + SAMPLE_CACHE_UNIFORM_FIELDS + SOURCE_WINDOW_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 [`RESERVED_UNIFORM_FIELDS`] 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-DSP-2 | NFR-RES-2 /// Slots the source window occupies: which part of the photograph the bound /// texture holds, and how large the whole photograph is. /// /// Two `vec4`s. The first is the window as a rectangle in normalised source /// coordinates (origin, extent), `(0, 0, 1, 1)` for a texture that holds the /// whole frame. The second carries the whole frame's size in pixels in /// `.xy`, zero when the texture *is* the whole frame at full resolution, in /// which case the shader measures the texture as it always did. /// /// A photograph larger than one texture is developed from windows cut out of /// it, or from a reduced copy of all of it. Either way everything above the /// sampler — framing, the lens warps, grain seeded from `source_px` — has to /// go on seeing the frame it was authored against, not the texture, or a /// crop drawn on the reduced copy would land somewhere else in the export. pub const SOURCE_WINDOW_UNIFORM_FIELDS: usize = 8; /// Where the source window's slots begin in the generated uniform block. /// /// Exported so `dr-gpu` writes them by index, as it does the sample cache. pub const SOURCE_WINDOW_UNIFORM_OFFSET: usize = SAMPLE_CACHE_UNIFORM_OFFSET + SAMPLE_CACHE_UNIFORM_FIELDS; /// The source window a texture holding the whole frame at full resolution /// has: every block the composer hands out starts with it, so a caller that /// never heard of windows samples exactly as it always did. pub const WHOLE_SOURCE_WINDOW: [f32; SOURCE_WINDOW_UNIFORM_FIELDS] = [0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]; /// 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. /// /// A mask layer's settings are **offsets to the global ones**, applied at each /// operation's own place in the chain: global contrast −30 and a face at −20 /// is contrast −50 on the face, run where contrast runs. They once ran as a /// second chain after every global operation, which compounded the two edits /// in ways neither slider showed — see `mask::offset_onto`. /// /// 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, 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, false, ) } #[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, view_pass: 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. // // TRACES: FR-DEV-3j // The view pass is the other exception. It follows the detail stage and // writes the display texture, so it is `Encoded` whatever the chain holds: // it exists only because a detail stage does. let output_mode = if view_pass { OutputMode::Encoded } else { forced.unwrap_or( if ops.iter().any(|o| o.is_active() && o.detail().is_some()) || !spots.is_neutral() { OutputMode::LinearWorking } else { OutputMode::Encoded }, ) }; 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 source window (see `SOURCE_WINDOW_UNIFORM_FIELDS`): the\n\ \x20 // part of the frame the texture holds, as origin and extent in\n\ \x20 // normalised source coordinates, then the whole frame's size in\n\ \x20 // pixels, zero when the texture is the whole frame.\n\ \x20 source_window: vec4,\n\ \x20 source_full: vec4,\n", ); uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0); uniform_values[SOURCE_WINDOW_UNIFORM_OFFSET..BASE_UNIFORM_FIELDS] .copy_from_slice(&WHOLE_SOURCE_WINDOW); // TRACES: FR-DEV-3j // Whether the composer emits a view transform — which is every render but // the camera-space tap, whose job is the sensor's own numbers. let views = output_mode != OutputMode::CameraLinear; // And whether *this* shader is where it goes. Not the fused pass when a // detail stage follows: the view transform maps into a display range, and // a sharpener handed a display range is what D19 set out to stop. The // view pass after the detail stage carries it instead. let views_here = views && output_mode == OutputMode::Encoded; // 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); } } // TRACES: FR-DEV-3 // The local adjustments. Composed first because they are threaded through // the loop below rather than appended after it: a layer's settings are // offsets to the global ones, applied at each operation's own place in // the chain (see `mask::offset_onto` for why). The weights are sampled // here, once per pixel, ahead of every operation that reads them. let layers = crate::mask::compose_layers_revealing(masks, reveal, ops); body.push_str(&layers.weights); // Every point operation that is active globally *or* in some layer. One // that only a layer moved still runs here, at its place in the chain, // with nothing on the global side of its blend. // // Camera-stage operations first, then the camera matrix, then the rest — // each group in graph order (D19). Graph order still decides everything // within a group; the stage only decides which colour the group is given. let point = |stage: Stage| { ops.iter() .map(|o| o.as_ref()) .filter(move |o| o.detail().is_none() && o.stage() == stage) }; // The view operation: an active rendering — a loaded film stock — if // there is one, otherwise the chain's view transform, or a default one // when the caller's chain holds none (`compose(&[])` in a test, or a // probe). 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. Absent altogether where nothing is to be // rendered: see `views`. let default_view = crate::ops::ViewTransform::new(); let view: Option<&dyn Operation> = views_here.then(|| { point(Stage::View) .find(|o| o.is_active() && o.renders()) .or_else(|| point(Stage::View).find(|o| !o.renders())) .unwrap_or(&default_view as &dyn Operation) }); // What is emitted, in order: camera RGB, the matrix out of it, the scene, // any layer-only operations, and the view transform last (D19). enum Step<'a> { Op(&'a dyn Operation), Matrix, Orphans, } // // The view pass holds the view transform and nothing else: everything // before it has already run, in the fused pass and the detail stage. let mut steps: Vec = Vec::new(); if !view_pass { steps.extend(point(Stage::Camera).map(Step::Op)); steps.push(Step::Matrix); steps.extend(point(Stage::Scene).map(Step::Op)); steps.push(Step::Orphans); } steps.extend(view.map(Step::Op)); for step in steps { let op = match step { Step::Op(op) => op, Step::Matrix => { body.push_str(CAMERA_MATRIX); continue; } Step::Orphans => { // A layer's operation the global chain does not hold at // all. Not a case any editor produces — both chains come from // `ops::chain` — but a layer must not lose an edit because a // caller composed a shorter chain. let mut orphans: Vec<&'static str> = Vec::new(); for l in &layers.ops { if !orphans.contains(&l.op) && !ops.iter().any(|o| o.descriptor().id.0 == l.op) { orphans.push(l.op); } } for id in orphans { let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect(); let _ = writeln!(body, "\n // ---- {id} (local only) ----"); body.push_str(&local_block("", &local)); } continue; } }; let id = op.descriptor().id.0; let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect(); // The global side of the blend. A view operation always has one; see // `Stage::View`. let global = op.is_active() || op.stage() == Stage::View; if !global && local.is_empty() { continue; } let prefix = sanitise(id); let mut fragment = String::new(); let mut op_uniforms = Vec::new(); if global { // Each op's uniforms are prefixed, so two operations may both // declare a field called `amount` without colliding. 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); } } fragment = op.wgsl_body(); } let _ = writeln!(body, "\n // ---- {id} ----"); if op.blends_settings() && !local.is_empty() { // TRACES: FR-DEV-3f body.push_str(&local_settings_block( &fragment, &prefix, &op_uniforms, &local, )); continue; } // Rewrite bare uniform names to their prefixed struct fields, so a // fragment is written without knowing about any other operation. for u in &op_uniforms { fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name)); } body.push_str(&local_block(&fragment, &local)); } // 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. // // And after the detail stage, when there is one: on the linear // intermediate a flat tint would be sharpened and then rendered as some // other colour. So it rides on whichever shader writes the display. let reveal_block = if output_mode == OutputMode::Encoded { layers.reveal.clone() } else { String::new() }; uniform_fields.push_str(&layers.uniform_fields); uniform_values.extend_from_slice(&layers.uniform_values); 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(), ), }; // Where the view transform is not, say why, for whoever reads the // generated source. Where it is, it is the last operation block above. let rendering_tail = if views { String::new() } else { " // No view transform: this is the camera-space tap, which stores the\n // sensor's own numbers.\n" .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; // What the operations are handed. For the fused pass, the source texel // made linear and balanced as shot; for the view pass, the scene as the // detail stage left it, which is already all of that. let head = if view_pass { " // The view pass (FR-DEV-3j): everything up to the view transform ran // in the fused pass and the detail stage, and what they left is in the // texture bound as `sampled`, at this pixel. The prologue above ran only // for the positions it publishes — `source_px` for a film's grain, and // the corners it blacks out — and its colour is discarded. let non_linear = u.as_shot_wb.w > 0.5; c = textureLoad(sampled, vec2(gid.xy), 0).rgb; " .to_string() } else { format!( " // 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); }} " ) }; 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; {WINDOW_HELPER}{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} {head}{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, ); // TRACES: FR-DEV-3j // The fused pass that stops for a detail stage hands its view transform // to a pass of its own, composed here from the same inputs so the two // halves cannot come from different edits. let view = (output_mode == OutputMode::LinearWorking).then(|| { Box::new(compose_inner( ops, framing, output, masks, spots, warps, reveal, None, smooth, true, )) }); ComposedShader { source, uniforms: uniform_values, structure_hash, output_mode, // The view pass reads the intermediate, not the source, so the // sample cache has nothing to say about it. sample_key: if view_pass { None } else { sample_key }, view, } } /// TRACES: FR-DEV-3f /// One operation's block when its layers are blended as *settings*: every /// uniform the weighted average of the global value and each overlapping /// layer's, and then the fragment once. /// /// The weights: each layer's mask weight `w_i`, and the global setting /// whatever the layers leave, `w_g = max(0, 1 − Σ w_i)`. So /// /// ```text /// p = (w_g·p_g + Σ w_i·p_i) / (w_g + Σ w_i) /// ``` /// /// — one layer at weight `w` is `(1 − w)·p_g + w·p_1`, exactly the blend a /// layer has always had, and three layers overlapping at full weight are the /// plain mean of their three settings rather than one piled on another. A /// layer's `p_i` is its combined setting (the global one plus its offset), so /// the average is of what each layer *asks for*. /// /// Only layers that move this operation take part. One that left it alone is /// not voting for the global setting; it is not voting. /// /// `fragment` is the operation's body with bare uniform names, as /// `wgsl_body` wrote it: they are rewritten here to the blended values. fn local_settings_block( fragment: &str, prefix: &str, uniforms: &[Uniform], local: &[&crate::mask::LocalOp], ) -> String { let mut out = String::new(); let _ = writeln!(out, " {{"); let weights: Vec = local.iter().map(|l| format!("mask_w{}", l.slot)).collect(); let _ = writeln!(out, " let set_w = {};", weights.join(" + ")); let _ = writeln!(out, " let set_g = max(1.0 - set_w, 0.0);"); // Never zero: the global weight is one wherever no layer reaches. let _ = writeln!(out, " let set_n = max(set_g + set_w, 1e-6);"); let mut body = fragment.to_string(); for u in uniforms { let mut sum = format!("set_g * u.{prefix}_{}", u.name); for l in local { let _ = write!( sum, " + mask_w{slot} * u.mask{slot}_{prefix}_{name}", slot = l.slot, name = u.name ); } let _ = writeln!(out, " let set_{} = ({sum}) / set_n;", u.name); body = rewrite_uniform(&body, u.name, &format!("set_{}", u.name)); } let _ = writeln!(out, " {{"); for line in body.lines() { let _ = writeln!(out, " {line}"); } let _ = writeln!(out, " }}"); let _ = writeln!(out, " }}"); out } /// One operation's block: its global fragment, and each layer's version of it /// blended in by that layer's weight. /// /// Every version reads the same input — the colour as it arrived at this /// operation — and the pixel moves from the global result by each layer's /// difference from it: `c_g + Σ w_i (c_i − c_g)`. At full weight that is the /// layer's combined setting exactly, at zero it is the global result exactly, /// and two overlapping layers add their changes rather than one repainting /// the other. /// /// With no layer touching the operation this is the block the composer always /// emitted, byte for byte: a photograph with no masks compiles to the shader /// it did before layers were offsets. fn local_block(global: &str, local: &[&crate::mask::LocalOp]) -> String { let mut out = String::new(); let _ = writeln!(out, " {{"); if local.is_empty() { for line in global.lines() { let _ = writeln!(out, " {line}"); } let _ = writeln!(out, " }}"); return out; } let _ = writeln!(out, " let local_in = c;"); let _ = writeln!(out, " {{"); for line in global.lines() { let _ = writeln!(out, " {line}"); } let _ = writeln!(out, " }}"); let _ = writeln!(out, " let local_global = c;"); let _ = writeln!(out, " var local_sum = c;"); for l in local { let w = format!("mask_w{}", l.slot); // Skipping where the mask is empty is most of the point of a local // adjustment: a mask covering a tenth of the frame should cost about a // tenth of the extra work. Safe as non-uniform control flow — nothing // inside samples with derivatives or synchronises. let _ = writeln!(out, " if ({w} > 0.0) {{"); // Fragments write to a `c` they expect to own, so the layer's version // gets one of its own, shadowing the outer one and starting from what // this operation was handed. let _ = writeln!(out, " var c = local_in;"); let _ = writeln!(out, " {{"); for line in l.fragment.lines() { let _ = writeln!(out, " {line}"); } let _ = writeln!(out, " }}"); let _ = writeln!( out, " local_sum = local_sum + {w} * (c - local_global);" ); let _ = writeln!(out, " }}"); } // Two layers pulling the same way can overshoot below zero, and a // negative component poisons every operation after this one. let _ = writeln!(out, " c = max(local_sum, vec3(0.0));"); let _ = writeln!(out, " }}"); out } /// 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(to_window(uv_r), tex_dims).r, sample_bilinear(to_window(uv_src), tex_dims).g, sample_bilinear(to_window(uv_b), tex_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(to_window(uv_src), tex_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. The texel is the texture's, // which is the frame's own only when the texture holds all of it at full // resolution (see `to_window`). let coord = clamp( vec2(to_window(uv_src) * vec2(tex_dims)), vec2(0), vec2(tex_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)); } } " } } /// TRACES: FR-DSP-2 | NFR-RES-2 /// Normalised source coordinates to the bound texture's own. /// /// The identity for a texture that holds the whole frame, which is every /// photograph that fits in one: `(uv - 0) / 1` is `uv` to the bit, so those /// render exactly as they did before windows existed. For a window cut from a /// larger frame, or a reduced copy of all of it, this is the only place that /// knows the texture is not the frame. Emitted on every path, since both the /// exact load and the filtered sample go through it. const WINDOW_HELPER: &str = "fn to_window(uv: vec2) -> vec2 { return (uv - u.source_window.xy) / u.source_window.zw; } "; /// 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. // Measured against an empty chain rather than the preamble alone, // since every render carries the view transform's uniforms too. 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(), compose(&[]).uniforms.len(), "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_view_transform() { // TRACES: FR-DEV-3j | FR-DEV-3f // A film stock's characteristic curve does the view transform's job. // Emitting the default rendering as well would render the scene // 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]; crate::ops::film_sim::FORMAT_COUNT], push_stations: vec![0.0], paper: None, grain_density_max: [3.0; 3], grain_uniformity: 0.97, })); assert!(film.is_active(), "the fixture did not load"); // Listed first, and emitted last anyway: a stock is the view // transform (D19), so graph order does not put it among the scene // operations the way order 25 once did. let source = compose(&[ Box::new(film) as Box, fake(DESC_A.clone(), 2.0, false), ]) .source; let film_at = source .find("---- film_sim ----") .expect("the operation itself must still be emitted"); let scene_op = source.find("---- op_a ----").expect("op present"); assert!( scene_op < film_at, "the film must see every scene operation's result" ); assert!( !source.contains("view_sigmoid"), "the view transform is still being applied on top of the film" ); // D19: the film no longer converts out of camera space itself. The // composer does, once, ahead of it — the film is handed working-space // colour like every other scene-stage operation. assert_eq!( source.matches("dot(u.cam_to_srgb_0.rgb, c)").count(), 1, "camera space is left exactly once" ); let matrix = source.find("camera profile: the matrix").expect("matrix"); assert!( matrix < film_at, "the film must be handed working-space colour" ); } #[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("c = view_sigmoid(")); assert!(source.contains("camera profile: the matrix")); } #[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("c = view_sigmoid(")); assert!(source.contains("camera profile: the matrix")); } #[test] fn the_camera_matrix_follows_white_balance_and_precedes_the_scene() { // TRACES: FR-DEV-2 | FR-DEV-3e // D19. White balance's multipliers are defined on the sensor's own // channels, so it is handed camera RGB. Every other operation is // handed working-space colour: before this, the edits ran in camera // RGB and `luminance()`'s Rec.709 weights were applied to a body's // own primaries. Graph order puts the scene operation first here on // purpose — the stage, not the order, decides which side of the // matrix an operation lands on. let mut wb = crate::ops::WhiteBalance::new(); wb.set_param(crate::ops::white_balance::TEMPERATURE, 30.0); let ops: Vec> = vec![fake(DESC_A.clone(), 2.0, false), Box::new(wb)]; let source = compose(&ops).source; let wb = source.find("---- white_balance ----").expect("wb present"); let matrix = source.find("camera profile: the matrix").expect("matrix"); let op = source.find("---- op_a ----").expect("op present"); assert!(wb < matrix, "white balance must run in camera RGB"); assert!( matrix < op, "a scene operation must be handed working-space colour" ); } #[test] fn an_empty_chain_still_leaves_camera_space() { // TRACES: FR-DEV-3e // The matrix is emitted at the first scene operation, so a chain with // none needs it emitted anyway, or an unedited RAW opens in camera // primaries. let source = compose(&[]).source; assert_eq!(source.matches("dot(u.cam_to_srgb_0.rgb, c)").count(), 1); } #[test] fn the_view_transform_runs_after_the_operations() { // TRACES: FR-DEV-3j // 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. 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 view = source.find("c = view_sigmoid(").expect("view applied"); assert!( op < view, "the view transform must come after the operations" ); } #[test] fn the_view_transform_reaches_a_shader_with_no_operations_at_all() { // TRACES: FR-DEV-3j // An unedited RAW must open looking like a photograph rather than // like a scan of one, and it is skipped only for a source that is // already a rendering. let source = compose(&[]).source; assert!(source.contains("c = view_sigmoid(")); assert!(source.contains("fn view_sigmoid(")); assert!(source.contains("if (!non_linear)")); } #[test] fn the_source_window_owns_the_slots_dr_gpu_writes() { // TRACES: FR-DSP-2 // `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 // window read out of a crop rectangle, which renders as nonsense // rather than as an error. assert_eq!( SOURCE_WINDOW_UNIFORM_OFFSET + SOURCE_WINDOW_UNIFORM_FIELDS, BASE_UNIFORM_FIELDS, "the source window must be the last thing in the base block" ); assert_eq!( compose(&[]).uniforms[SOURCE_WINDOW_UNIFORM_OFFSET..BASE_UNIFORM_FIELDS], WHOLE_SOURCE_WINDOW, "a block the composer hands out must sample the whole source" ); } /// 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 the_camera_space_tap_has_no_view_transform() { // TRACES: FR-MRG-2 // A merge stitches the sensor's own numbers; a rendering in them // would be developed a second time when the composite is opened. let source = compose_camera_probe(&[], &crate::framing::Framing::default()).source; assert!(!source.contains("view_sigmoid")); } #[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_stage_puts_the_view_transform_after_it() { // TRACES: FR-DEV-3j | FR-DEV-2 // D19's second finding: the fused pass stopped at "linear working // values" for a detail stage, but after the base curve, so every // sharpener convolved a display rendering. The fused pass must now // stop *before* the view transform, and the view pass — which reads // the detail stage's result — carries it, the output transform and the // mask reveal, and nothing else. let mut ops = crate::ops::chain(); ops.push(Box::new(crate::detail::probe::BoxBlur::with_radius(0.05))); let mut exposure = crate::ops::Exposure::new(); exposure.set_param(crate::ops::exposure::EXPOSURE, 1.0); ops.push(Box::new(exposure)); let fused = compose(&ops); assert_eq!(fused.output_mode, OutputMode::LinearWorking); assert!(!fused.source.contains("view_sigmoid")); assert!(fused.source.contains("---- exposure ----")); let view = fused.view.as_deref().expect("a view pass"); assert_eq!(view.output_mode, OutputMode::Encoded); assert!(view.view.is_none()); assert!(view .source .contains("c = textureLoad(sampled, vec2(gid.xy), 0).rgb;")); assert!(view.source.contains("c = view_sigmoid(")); assert!(view.source.contains("fn encode_output")); assert_eq!( view.source.matches("---- ").count(), 1, "the view pass must not run an operation the fused pass already ran" ); assert!(!view.source.contains("u.as_shot_wb.rgb")); assert!(!view.source.contains("camera profile: the matrix")); assert!(view.sample_key.is_none()); } #[test] fn an_edit_with_no_detail_stage_has_no_view_pass() { // TRACES: FR-DEV-3j // The common case costs what it always did: one dispatch. let fused = compose(&crate::ops::chain()); assert_eq!(fused.output_mode, OutputMode::Encoded); assert!(fused.view.is_none()); assert!(fused.source.contains("c = view_sigmoid(")); } #[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. // // Asserted on the field names rather than on a count: since D19 the // edit with a detail operation also moves the view transform's // uniforms out of this shader and into its view pass, so the two // blocks differ in size for a reason that is not this one. let mut ops = crate::ops::chain(); ops.push(Box::new(crate::detail::probe::BoxBlur::with_radius(0.05))); let fused = compose(&ops); assert!(!fused.source.contains("detail_probe_"), "{}", fused.source); assert!(fused.uniforms.len() <= compose(&crate::ops::chain()).uniforms.len()); } }