Files
DarkRoom/core/dr-pipeline/src/operation.rs
T
dtourolle affdaecaee Stop describing a base curve the pipeline no longer has
D19 retired the per-body base curve, moved the matrix ahead of the
edits and the film into the view transform's place, but a dozen doc
comments still listed the curve among what a pixel passes through, or
said the film skipped it. The detail stage's module doc still drew the
matrix after the edits and the last detail pass encoding, which the
view pass took over. The film crate's README gave the base curves as
its reason for being data, and the ops README's list of hand-written
nodes had neither the view transform nor three of the five kernels.

FR-MRG-2 gave the base curve as why the merge cuts below the profile;
the view transform is why now. The decision table still said colour
defaults were a per-body curve, and FR-DEV-3j said only the default
view transform skips a JPEG, where the node skips one whatever its
sliders say. frame-budget.md records the view pass as unmeasured.
2026-09-27 19:42:41 -04:00

2748 lines
120 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<dyn Operation>` 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<OpDescriptor>;
/// Set a parameter. Values arrive already clamped to the descriptor.
fn set_param(&mut self, id: ParamId, value: f32);
/// Read a parameter back, for the sidecar and for the UI's initial state.
fn param(&self, id: ParamId) -> f32;
/// Whether this operation currently changes the image.
///
/// An operation at its neutral settings returns `false` and is omitted
/// from the generated shader entirely. This is what makes the common case
/// — a handful of active adjustments out of many available — cost only
/// what is actually used.
fn is_active(&self) -> bool;
/// The WGSL body of this operation's transform.
///
/// Receives `c` (a `vec3<f32>` of linear RGB) and must produce the
/// result in `c`. Uniforms are addressed by the names declared in
/// [`Self::uniforms`], accessed as `u.<prefixed_name>`; 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<Uniform>;
/// 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<Presentation> {
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<f32>(
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<f32>,
/// 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<u64>,
/// 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<Box<ComposedShader>>,
}
/// 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<dyn Operation>]) -> 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<dyn Operation>],
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<dyn Operation>],
framing: &Framing,
output: ColourSpace,
masks: &MaskStack,
spots: &crate::spot::SpotSet,
warps: &[Box<dyn crate::lens::Warp>],
) -> 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<dyn Operation>],
framing: &Framing,
output: ColourSpace,
masks: &MaskStack,
spots: &crate::spot::SpotSet,
warps: &[Box<dyn crate::lens::Warp>],
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<dyn crate::lens::Warp>],
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<dyn crate::lens::Warp>],
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<dyn crate::lens::Warp>],
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<dyn Operation>],
framing: &Framing,
output: ColourSpace,
masks: &MaskStack,
spots: &crate::spot::SpotSet,
warps: &[Box<dyn crate::lens::Warp>],
reveal: Option<&crate::mask::Reveal>,
forced: Option<OutputMode>,
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<f32> = Vec::new();
let mut body = String::new();
let mut helpers: Vec<Helper> = 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<f32>,\n\
\x20 cam_to_srgb_1: vec4<f32>,\n\
\x20 cam_to_srgb_2: vec4<f32>,\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<f32>,\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<f32>,\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<f32>,\n\
\x20 source_full: vec4<f32>,\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<f32>,\n\
\x20 framing_angle: vec4<f32>,\n\
\x20 // The perspective map (FR-DEV-20) by columns, `.w` unused.\n\
\x20 keystone_c0: vec4<f32>,\n\
\x20 keystone_c1: vec4<f32>,\n\
\x20 keystone_c2: vec4<f32>,\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<Step> = 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<f32>(0.0), vec3<f32>(1.0));
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(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<i32>(gid.xy), vec4<f32>(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<i32>(gid.xy), vec4<f32>(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<i32>(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<f32>(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<f32>;
@group(0) @binding(1) var<uniform> 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<f32>;
// 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<f32>;
@group(0) @binding(5) var film_lut_texture: texture_3d<f32>;
// 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<f32>;
@group(0) @binding(7) var sample_out: texture_storage_2d<rgba16float, write>;
{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<f32>) -> vec3<f32> {{
let lo = c / 12.92;
let hi = pow((max(c, vec3<f32>(0.04045)) + 0.055) / 1.055, vec3<f32>(2.4));
return select(hi, lo, c <= vec3<f32>(0.04045));
}}
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
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<String> = 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<f32>(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<f32>(\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<f32>({:.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<f32>(0.0031308)), vec3<f32>(1.0 / 2.4)) - 0.055;
return select(hi, lo, c <= vec3<f32>(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<f32>(1.0 / {g:.8}));"),
Transfer::Prophoto => " let lo = c * 16.0;
let hi = pow(max(c, vec3<f32>(0.001953125)), vec3<f32>(1.0 / 1.8));
return select(hi, lo, c < vec3<f32>(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<f32>) -> vec3<f32> {{
{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<f32>(0.5);
let uv_r = p_r / aspect + vec2<f32>(0.5);
let uv_b = p_b / aspect + vec2<f32>(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<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(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<f32>(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<f32>(
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<f32>(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<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(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<f32>(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<f32>(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<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(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<i32>(to_window(uv_src) * vec2<f32>(tex_dims)),
vec2<i32>(0),
vec2<i32>(tex_dims) - vec2<i32>(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<f32>(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<f32>;
if (u.sample_cache.x > 0.5) {
c = textureLoad(sampled, vec2<i32>(gid.xy), 0).rgb;
} else {
c = textureLoad(source, coord, 0).rgb;
if (u.sample_cache.y > 0.5) {
textureStore(sample_out, vec2<i32>(gid.xy), vec4<f32>(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<f32>) -> vec2<f32> {
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<f32>, dims: vec2<u32>) -> vec3<f32> {
let last = vec2<i32>(dims) - vec2<i32>(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<f32>(dims) - vec2<f32>(0.5);
let base = floor(q);
let f = q - base;
let i0 = clamp(vec2<i32>(base), vec2<i32>(0), last);
let i1 = min(i0 + vec2<i32>(1), last);
let c00 = textureLoad(source, vec2<i32>(i0.x, i0.y), 0).rgb;
let c10 = textureLoad(source, vec2<i32>(i1.x, i0.y), 0).rgb;
let c01 = textureLoad(source, vec2<i32>(i0.x, i1.y), 0).rgb;
let c11 = textureLoad(source, vec2<i32>(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<Arc<OpDescriptor>> = 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<Arc<OpDescriptor>> = 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<OpDescriptor>,
amount: f32,
helper: Option<Helper>,
}
impl Operation for Fake {
fn descriptor(&self) -> Arc<OpDescriptor> {
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<Uniform> {
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>) -> f32 { return c.g; }",
}];
fn fake(desc: Arc<OpDescriptor>, amount: f32, helper: bool) -> Box<dyn Operation> {
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<Box<dyn Operation>> = (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<dyn crate::lens::Warp>,
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<Box<dyn crate::lens::Warp>>| {
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<dyn crate::lens::Warp>],
)
};
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<dyn Operation>,
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<dyn Operation> = 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<Box<dyn Operation>> = 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<dyn Operation>], 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<f32>(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<f32>(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<f32>({:.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<u64> = 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<i32>(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());
}
}