Files
DarkRoom/core/dr-pipeline/src/operation.rs
T
dtourolleandClaude Opus 5 4b2ee0ac50 Count the silver instead of adding noise
An emulsion is a suspension of crystals. Light sensitises some; development
turns a sensitised one opaque, all or nothing. So a patch of film's density
is a *count* of developed grains, and a count of independent yes/no events
has a variance whether or not anyone wanted texture:

    mean     = D
    variance = D * (Dmax - u * D) / N

That expression is the whole feature. It peaks in the middle of the density
range and vanishes at both ends -- clear film has nothing developed to vary,
black film has nothing left to develop -- so grain lives in the midtones as a
consequence rather than as a "midtone bias" slider.

I was wrong earlier that this needs the detail stage. Nothing in it reads a
neighbouring pixel; the only reason to move it was that grain must be fixed in
film space rather than screen space, and that solves itself: N is grains *per
pixel*, so it scales with the film a pixel covers. Zoom out, each pixel
averages more grains, less variance -- correct, with nothing super-sampled and
nothing filtered. It stays in the fused pass.

Grain goes on the density and *before* the dye, which is the physical order
and not cosmetic. Perturbing the finished colour -- what an effect does --
tints highlights wrong, because that noise never passes through the dye.

Crystal habit lives in `rms_granularity`, the number every datasheet
publishes, now a profile field. It measures exactly what differs between a
cubic emulsion and a tabular one: at equal speed, tabular crystals present
more area per unit silver, so the film reads finer. Delta 100 is quoted near 9
where HP5 is near 12, and that gap *is* the habit. Adding a stock whose grain
is its whole reputation is therefore editing one line, not writing a model.

Three things this cost, all of them worth writing down:

  - The default granularity is a colour negative's, blue coarsest. Applied to
    Tri-X it put *colour* speckle on a black and white photograph. Monochrome
    stocks collapse it at parse, where every other per-layer table is already
    replicated from the one measured channel.
  - Helpers cannot read uniforms. The composer prefixes a uniform with its
    operation's id and rewrites references inside a fragment body only;
    helpers are shared and deduplicated, so a bare `gn0` names nothing.
    `film_lut` already took its size as an argument for this reason, and now
    says so.
  - The end-to-end test compares the shader against the CPU model, and grain
    is stochastic, so that comparison now runs with grain off. Which means a
    grain that never left the CPU would look exactly like a passing suite --
    hence a second test that grain off is bit-identical, one grain per pixel
    moves it, and ten thousand move it less.

Not here, deliberately: no grain slider. The parameters are physical and
`rms_granularity` is the honest place to scale one from, but its range wants
choosing rather than guessing. Nor a film format -- 35 mm is assumed, and
medium format at the same stock is far less grainy per unit of picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:08:51 +02:00

1704 lines
74 KiB
Rust

//! The `Operation` trait and WGSL fragment composition.
//!
//! # Composable shaders
//!
//! Each operation contributes a **WGSL fragment**: a function taking a linear
//! RGB colour and returning one. The pipeline concatenates the fragments of
//! the enabled operations into a single generated shader, run as one compute
//! dispatch. This buys the performance of a fused pass without the coupling:
//!
//! - **One texture read and one write per frame**, not one pair per operation.
//! At 24 MP the difference is the whole frame budget.
//! - **Operations stay independent.** Adding one is a new file implementing
//! this trait; no central shader to edit and no ordering table to update.
//! - **A disabled operation vanishes from the source** rather than costing a
//! branch, so an image with two active adjustments compiles to a shader
//! doing exactly two things.
//! - **Each distinct op-set compiles once** and is cached by the hash of its
//! generated source (ARCH §5.6).
//!
//! The cost is that WGSL compile errors point at generated source, so the
//! generator emits readable, commented output — see [`compose`].
use std::fmt::Write as _;
use dr_types::{ColourSpace, Transfer};
use crate::descriptor::{OpDescriptor, ParamId, Presentation};
use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS};
use crate::mask::MaskStack;
/// TRACES: FR-DEV-3d
/// What an operation's parameters affect, for cache invalidation scoping.
///
/// Adjusting exposure must not invalidate the demosaic result; this is what
/// lets the tile cache reuse everything up to the first changed stage
/// (ARCH §5.3).
///
/// **The ordering is the pipeline order**, which is why this derives `Ord`
/// rather than merely `Eq`: geometry decides which source pixel a colour comes
/// from, the fused colour pass transforms it, and the detail stage reads the
/// neighbourhood the colour pass produced. A change at one stage invalidates
/// that stage and every later one, and nothing earlier — see [`Invalidation`],
/// which is where that rule is actually written down and tested.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Affects {
/// Pixel positions — crop, rotate, straighten. The framing prologue, which
/// also decides the resolution everything downstream runs at.
Geometry,
/// Per-pixel colour. Every operation fused into the single adjust
/// dispatch, and every mask layer's chain.
Colour,
/// TRACES: FR-DEV-3d
/// A pixel's *neighbourhood* — sharpening, noise reduction, clarity,
/// texture, dehaze, spot removal.
///
/// The seam `docs/requirements.md` §3.3 designed and nothing cut until
/// [`crate::detail`] existed. It is a separate variant rather than a flavour
/// of `Colour` because it is a separate *dispatch*: a fragment in the fused
/// pass is handed a colour and has no way back to a coordinate, so a
/// kernel cannot be expressed there at any price.
///
/// What the distinction buys, concretely: the fused pass's result is held
/// in a linear intermediate, so dragging a sharpening slider re-runs the
/// detail dispatches and **not** the colour pass — which is exactly the
/// reuse FR-DEV-3d asks for, and it is asserted in `dr-gpu`'s
/// `detail_stage` tests rather than merely hoped for.
Detail,
}
/// TRACES: FR-DEV-3d
/// One cache key per pipeline stage, derived from the edit.
///
/// # The rule
///
/// A cached result for stage *S* stays valid while *S*'s own key and the keys
/// of every stage **before** it are unchanged. [`Self::of`] is the first half;
/// [`Self::through`] folds in the second and is what a cache should actually
/// store.
///
/// That reads as pedantry until it is applied, at which point it settles the
/// two questions FR-DEV-3d asks:
///
/// - **Changing a detail parameter must not re-run demosaic**, or the framing,
/// or the fused colour pass. It does not: `of(Detail)` moves and
/// `through(Colour)` does not, so the linear intermediate the colour pass
/// wrote is still good and only the detail dispatches run again.
///
/// - **Changing exposure must not re-run anything upstream of colour.** It
/// does not: `through(Geometry)` is untouched, so a tile cache keyed on it
/// survives, and the demosaiced texture — which no key here mentions at all
/// — is never in question.
///
/// It also settles what is *not* true, and the temptation is real: changing
/// exposure **does** re-run the detail passes, because the detail stage reads
/// what the colour pass wrote and that changed. There is no arrangement of
/// keys that avoids it while keeping sharpening after the tone curve, and
/// sharpening after the tone curve is the correct place (see
/// [`crate::detail`]). Anyone who wants exposure to leave the detail stage
/// alone is asking for detail to run *before* tone, which is a different
/// pipeline and a worse picture.
///
/// # Why the demosaic is not in here
///
/// Because no parameter in this graph can change it. The demosaiced texture is
/// a function of the file and the decode settings, both of which live outside
/// the edit graph; a caller keying a cache on it mixes in whatever names the
/// photograph — a `VersionId` — and these keys ride on top.
///
/// # Integer state only
///
/// Every value folded in here is a parameter: a slider position or a number
/// from a sidecar, never a float that came back from the GPU. That is what
/// ARCH §6.13 requires of a cache key, and it is why hashing the raw bit
/// patterns is sound rather than reckless. Negative zero is canonicalised on
/// the way in, because `-0.0 == 0.0` while their bit patterns differ, and a
/// slider that arrived at zero from below would otherwise invalidate a cache
/// that is perfectly valid.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Invalidation {
geometry: u64,
colour: u64,
detail: u64,
}
impl Invalidation {
/// Build from the three per-stage hashes. [`crate::EditGraph::invalidation`]
/// is what computes them; this is public so a caller with its own notion
/// of a stage can construct one.
pub fn new(geometry: u64, colour: u64, detail: u64) -> Self {
Self {
geometry,
colour,
detail,
}
}
/// The key for `stage`'s own parameters, ignoring everything upstream.
///
/// Useful for asserting that a change was correctly *scoped* — that moving
/// a detail slider left the colour stage's parameters alone. Not a cache
/// key: a stage whose own parameters are unchanged still has to re-run if
/// its input changed, which is what [`Self::through`] is for.
pub fn of(&self, stage: Affects) -> u64 {
match stage {
Affects::Geometry => self.geometry,
Affects::Colour => self.colour,
Affects::Detail => self.detail,
}
}
/// The key for the **output** of `stage` — this stage and everything
/// upstream of it. What a cached texture should be keyed on.
pub fn through(&self, stage: Affects) -> u64 {
let mut h = FNV_OFFSET;
h = mix(h, self.geometry);
if stage >= Affects::Colour {
h = mix(h, self.colour);
}
if stage >= Affects::Detail {
h = mix(h, self.detail);
}
h
}
}
/// Fold one operation's identity and settings into a running hash.
///
/// Shared by the stage keys so that two stages cannot come to disagree about
/// what "this operation's state" means — which would show as a cache that is
/// occasionally, unreproducibly stale.
pub(crate) fn hash_op(h: u64, op: &dyn Operation) -> u64 {
let desc = op.descriptor();
let mut h = hash_bytes(h, desc.id.0.as_bytes());
for p in desc.params {
h = hash_bytes(h, p.id.0.as_bytes());
h = mix(h, u64::from(canonical_bits(op.param(p.id))));
}
h
}
/// A parameter's bits, with negative zero folded onto zero.
///
/// `-0.0 == 0.0` as far as every operation is concerned — a slider that
/// reached zero from below produces the same shader and the same picture — but
/// the two have different bit patterns. Hashing them apart would invalidate a
/// cache for a change that is not one.
pub(crate) fn canonical_bits(v: f32) -> u32 {
if v == 0.0 {
0
} else {
v.to_bits()
}
}
pub(crate) fn hash_bytes(mut h: u64, bytes: &[u8]) -> u64 {
for byte in bytes {
h ^= u64::from(*byte);
h = h.wrapping_mul(0x100_0000_01b3);
}
h
}
/// FNV-1a's offset basis. No dependency, and stable across runs and platforms,
/// which a cache key requires.
pub(crate) const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
/// A single scalar a fragment reads from the generated uniform block.
///
/// Operations declare uniforms by name and value; the composer assigns them
/// slots and emits the struct. An operation never knows its own offset, which
/// is what allows fragments to be reordered or omitted freely.
#[derive(Debug, Clone, PartialEq)]
pub struct Uniform {
/// Field name as it appears in WGSL. Prefixed with the op id by the
/// composer, so two operations may both declare `amount`.
pub name: &'static str,
pub value: f32,
}
/// A develop operation.
///
/// Object-safe: the pipeline holds `Box<dyn Operation>` in graph order, so
/// order is data rather than code (ARCH §3.4).
pub trait Operation: Send + Sync {
/// Static description, driving UI generation (FR-DEV-3a).
fn descriptor(&self) -> &'static OpDescriptor;
/// Set a parameter. Values arrive already clamped to the descriptor.
fn set_param(&mut self, id: ParamId, value: f32);
/// Read a parameter back, for the sidecar and for the UI's initial state.
fn param(&self, id: ParamId) -> f32;
/// Whether this operation currently changes the image.
///
/// An operation at its neutral settings returns `false` and is omitted
/// from the generated shader entirely. This is what makes the common case
/// — a handful of active adjustments out of many available — cost only
/// what is actually used.
fn is_active(&self) -> bool;
/// The WGSL body of this operation's transform.
///
/// Receives `c` (a `vec3<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-3e | FR-DEV-3f
/// Whether this operation *is* the rendering, rather than an adjustment to
/// one.
///
/// Almost everything returns `false`. An operation that returns `true`
/// takes camera RGB and hands back linear sRGB, and in exchange the
/// composer emits neither the camera profile's base curve nor the
/// conversion out of camera space — because this operation has done both.
///
/// The reason it is a trait method and not a flag the caller sets is the
/// one [`compose_full`] gives for deciding the output mode the same way: a
/// caller that got it wrong would produce a shader that compiles, runs, and
/// renders the picture twice. `film_sim` is the operation this exists for —
/// a stock's characteristic curve does the base curve's job, from
/// measurements, and running both is the camera's rendering of the scene
/// followed by a film's rendering of *that*.
fn renders(&self) -> bool {
false
}
/// TRACES: FR-DEV-3 | FR-DEV-8
/// This operation's neighbourhood stage, if it has one.
///
/// `None` — the default, and true of every operation that is a function of
/// one colour — means the operation is fused into the single adjust
/// dispatch in the ordinary way.
///
/// `Some` means the opposite: the operation reads pixels it is not
/// writing, cannot be a fragment in a fused shader, and runs as its own
/// dispatch or dispatches after the colour pass. See [`crate::detail`] for
/// where that sits and why, and for what a sharpening operation has to
/// write. An operation returning `Some` must also return
/// [`Affects::Detail`] from [`Self::affects`], which
/// `detail_operations_agree_with_themselves` checks — the two saying
/// different things would leave the operation in neither stage, silently
/// doing nothing.
fn detail(&self) -> Option<&dyn crate::detail::DetailStage> {
None
}
/// Any WGSL helper functions the fragment calls.
///
/// Emitted once per *distinct* function name even if several operations
/// request it, so shared helpers (luminance, soft clipping) are declared
/// exactly once.
fn helpers(&self) -> &'static [Helper] {
&[]
}
/// TRACES: FR-DEV-3a | FR-DEV-3b
/// How this operation would like its parameters presented.
///
/// `None` — the default, and the right answer for nearly every operation
/// — means one control per parameter, chosen from its
/// [`crate::ParamKind`]. Returning a [`Presentation`] says that several
/// parameters form a single conceptual control and names the widget that
/// draws it.
///
/// Purely a hint. The parameters remain individually addressable
/// scalars, so a UI that does not implement the named widget falls back
/// to sliders and stays fully functional.
fn presentation(&self) -> Option<Presentation> {
None
}
}
/// A named WGSL helper function, deduplicated across operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Helper {
pub name: &'static str,
pub source: &'static str,
}
/// TRACES: FR-DEV-2 | FR-DEV-3d
/// What the fused pass writes, and therefore what has to be bound to it.
///
/// The fused shader ends one of two ways, and the difference is not cosmetic —
/// it decides the storage texture's format, so a shader composed for one and
/// dispatched against the other is a validation failure rather than a wrong
/// picture.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
/// `rgba8unorm`, display-encoded in the composed output space. What the
/// pass has always written, and still writes for the overwhelmingly common
/// edit that has no detail stage: one dispatch, one read, one write.
Encoded,
/// `rgba16float`, linear sRGB, **unclipped**, scene-referred.
///
/// Emitted when the edit has an active neighbourhood operation. The detail
/// passes read this, and the last of them performs the output transform,
/// so the pipeline still quantises exactly once (FR-DEV-2) — it simply
/// happens two dispatches later.
///
/// Unclipped matters: a recovered highlight is above 1.0 here, and
/// clamping before a sharpener sees it would draw a hard edge at precisely
/// the luminance a sharpener is most visible at.
LinearWorking,
}
/// The result of composing a set of operations into one shader.
#[derive(Debug, Clone, PartialEq)]
pub struct ComposedShader {
/// Complete, compilable WGSL.
pub source: String,
/// Uniform values in the order the generated struct declares them.
pub uniforms: Vec<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,
}
/// Fields the generated uniform struct always carries, before op uniforms.
///
/// WGSL requires a uniform struct to be non-empty and 16-byte aligned; these
/// are needed by every generated shader in any case.
///
/// Twelve of the twenty-eight are the camera profile's base curve
/// ([`BASE_CURVE_UNIFORM_FIELDS`]); the rest are the matrix, the as-shot
/// balance and framing's own block.
const BASE_UNIFORM_FIELDS: usize = 16 + BASE_CURVE_UNIFORM_FIELDS;
/// TRACES: FR-DEV-3e
/// Slots the base curve occupies: five `(x, y)` points and an active flag.
///
/// Twelve rather than eleven so the block stays a whole number of `vec4`s,
/// which is what std140 requires of a uniform struct's members. The spare
/// float is left zero rather than repurposed — a uniform slot that means one
/// thing today and two things next year is how a shader comes to read a
/// highlight rolloff out of a crop rectangle.
const BASE_CURVE_UNIFORM_FIELDS: usize = 12;
/// TRACES: FR-DEV-3e
/// Where the base curve's slots begin in the generated uniform block.
///
/// Exported for the same reason [`RESERVED_UNIFORM_FIELDS`] is: `dr-gpu`
/// writes these by index, and an offset computed independently at both ends is
/// an offset that will eventually disagree with itself.
pub const BASE_CURVE_UNIFORM_OFFSET: usize = 16;
/// How many control points a base curve carries.
///
/// The same five the tone curve widget has, deliberately — see the helper
/// selection in [`compose_full`].
pub const BASE_CURVE_POINTS: usize = 5;
/// Where an operation's own uniforms begin in the generated block.
///
/// The base fields, then framing's. Exported because `dr-gpu` writes the
/// camera matrix into the leading slots by index and would otherwise carry
/// its own copy of this arithmetic — a duplicate that silently corrupts every
/// operation's uniforms the moment either block changes size.
pub const RESERVED_UNIFORM_FIELDS: usize = BASE_UNIFORM_FIELDS + FRAMING_UNIFORM_FIELDS;
/// Compose enabled operations into a single compute shader, for the display.
///
/// Inactive operations are skipped entirely — they contribute no code, no
/// uniforms, and nothing to the structure hash.
///
/// Equivalent to [`compose_with_framing`] with neutral framing and an sRGB
/// output.
pub fn compose(ops: &[Box<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())
}
/// TRACES: FR-DEV-3
/// Compose the global chain, the framing, and the local adjustments.
///
/// Mask layers are emitted **after** every global operation and before the
/// conversion out of camera space, so a local exposure acts on the tones the
/// global chain settled on — which is what a photographer means by "and then
/// lift the shadows on her face".
///
/// The fused-dispatch property survives: three global adjustments and two
/// masked ones are still one shader, one read and one write. The masks
/// themselves arrive as a pre-rasterised texture array (ARCH §5.4), so a
/// slider drag over a mask recompiles a shader but re-rasterises nothing.
pub fn compose_full(
ops: &[Box<dyn Operation>],
framing: &Framing,
output: ColourSpace,
masks: &MaskStack,
) -> ComposedShader {
// Active *point* operations. A neighbourhood operation is filtered out
// here rather than asked for a fragment it cannot write: it reads pixels
// it is not writing, so it belongs to the detail stage that runs after
// this one (see `crate::detail`). Filtering on the declared stage rather
// than on `affects()` means the shader and the stage agree by
// construction — there is one place an operation says which it is.
let active: Vec<&dyn Operation> = ops
.iter()
.map(|o| o.as_ref())
.filter(|o| o.is_active() && o.detail().is_none())
.collect();
// Whether a detail stage follows. If one does, this pass stops short of
// the output transform and hands on a linear intermediate; the last detail
// pass finishes the job. Decided from the operations themselves rather
// than from a flag the caller sets, because a caller that got the flag
// wrong would produce a shader whose storage format does not match the
// texture bound to it.
let output_mode = if ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
OutputMode::LinearWorking
} else {
OutputMode::Encoded
};
// Whether an operation has taken over the rendering. Decided from the
// operations for the same reason `output_mode` is: a caller that got it
// wrong would produce a shader that compiles and renders the picture twice.
let op_renders = ops.iter().any(|o| o.is_active() && o.renders());
let mut uniform_fields = String::new();
let mut uniform_values: Vec<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 camera profile's base curve (FR-DEV-3e): five points on a\n\
\x20 // monotone spline, packed as x0..x3, y0..y3, then (x4, y4, on).\n\
\x20 // `.z` of the last is the flag, not padding — it is 0 for a\n\
\x20 // body with no profile and for an already-rendered source.\n\
\x20 base_curve_x: vec4<f32>,\n\
\x20 base_curve_y: vec4<f32>,\n\
\x20 base_curve_last: vec4<f32>,\n",
);
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
// TRACES: FR-DEV-3e
// The spline the base curve is evaluated on is the *tone curve's* spline,
// reached through the trait rather than reimplemented here.
//
// Two reasons, and the second is the one that matters. The obvious one is
// that a shader carrying two `curve_eval`s would not compile, and the
// composer's helper de-duplication is what makes both stages able to ask
// for it. The real one is that a profile author placing a control point
// and a photographer dragging one must mean the same thing by it — down to
// the Fritsch-Carlson tangent limiting, which is what decides how a
// shoulder actually rolls off. Two implementations that agreed today would
// be two that could disagree later, and the disagreement would show up as
// a body whose profile renders subtly differently from the curve someone
// drew to match it.
//
// Emitted unconditionally, unlike an operation's helpers. The base curve
// is active for every RAW frame — an unprofiled body still gets the
// database's default rendering — so making the shader's shape depend on it
// would split the pipeline cache in two for no benefit. The uniform flag
// above turns it off for the cases that are genuinely already rendered,
// and a branch on a uniform is coherent across the whole dispatch.
for h in crate::ops::ToneCurve::new().helpers() {
if matches!(h.name, "curve_span" | "curve_eval") {
helpers.push(*h);
}
}
// Framing's block follows the base one at a fixed offset, for the same
// reason: the prologue is emitted whether or not any operation is active,
// so these slots cannot be positioned by the op loop below.
uniform_fields.push_str(
" // Framing: the crop rect (origin, extent) and the straightening\n\
\x20 // angle as sin/cos — a trig call per pixel would recompute a\n\
\x20 // value that is constant across the dispatch.\n\
\x20 crop_rect: vec4<f32>,\n\
\x20 framing_angle: vec4<f32>,\n",
);
uniform_values.extend_from_slice(&framing.uniforms());
for op in &active {
let id = op.descriptor().id.0;
let prefix = sanitise(id);
// Each op's uniforms are prefixed, so two operations may both declare
// a field called `amount` without colliding.
let op_uniforms = op.uniforms();
if !op_uniforms.is_empty() {
let _ = writeln!(uniform_fields, " // {id}");
}
for u in &op_uniforms {
let _ = writeln!(uniform_fields, " {prefix}_{}: f32,", u.name);
uniform_values.push(u.value);
}
for h in op.helpers() {
if !helpers.iter().any(|existing| existing.name == h.name) {
helpers.push(*h);
}
}
// Rewrite bare uniform names to their prefixed struct fields, so a
// fragment is written without knowing about any other operation.
let mut fragment = op.wgsl_body();
for u in &op_uniforms {
fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name));
}
let _ = writeln!(body, "\n // ---- {id} ----");
let _ = writeln!(body, " {{");
for line in fragment.lines() {
let _ = writeln!(body, " {line}");
}
let _ = writeln!(body, " }}");
}
// The local adjustments, after every global one: a masked exposure should
// act on the tones the global chain arrived at, not on the ones it started
// from. Their uniforms follow the global ops' in the block for the same
// reason those follow framing's — slot order is emission order, and
// nothing addresses a slot by number.
let layers = crate::mask::compose_layers(masks);
uniform_fields.push_str(&layers.uniform_fields);
uniform_values.extend_from_slice(&layers.uniform_values);
body.push_str(&layers.body);
for h in &layers.helpers {
if !helpers.iter().any(|existing| existing.name == h.name) {
helpers.push(*h);
}
}
// Pad the uniform block to a 16-byte boundary. A struct whose size is not
// a multiple of 16 is rejected by the WGSL uniform address space rules.
let pad = (4 - (uniform_values.len() % 4)) % 4;
for i in 0..pad {
let _ = writeln!(uniform_fields, " _pad{i}: f32,");
uniform_values.push(0.0);
}
let mut helper_src = String::new();
for h in &helpers {
let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
}
// The coordinate stage: output pixel -> source position -> colour. Emitted
// ahead of the operation fragments, which receive the sampled `c`.
let prologue = format!(
"{}\n{}",
framing.wgsl_prologue(),
sample_source(framing.needs_interpolation())
);
let sampler_helper = if framing.needs_interpolation() {
BILINEAR_HELPER
} else {
""
};
// The tail, and it is the whole of the difference between the two output
// modes. Everything above — the prologue, the fragments, the mask layers,
// the camera matrix — is emitted identically either way, so an operation
// cannot tell whether a detail stage follows it and does not have to.
let (store_format, to_output, encode_output, store) = match output_mode {
OutputMode::Encoded => (
"rgba8unorm",
primaries_conversion(output),
encode_output_fn(output),
" // Clip to the output gamut and encode. The clip is last for the reason the
// matrix above is: a colour outside sRGB is still inside a wider space, and
// clipping before the conversion would throw it away for no one's benefit.
c = clamp(c, vec3<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(),
),
};
// The camera profile's rendering, which an operation may have taken over.
//
// Emitted as a unit because the two halves belong together: the base curve
// is defined in camera RGB and the matrix is what leaves it, so an
// operation that replaces one has necessarily replaced the other. Keeping
// them as one string is what makes that impossible to get half right.
let rendering_tail = if op_renders {
" // The camera profile's base curve and the conversion out of camera\n // space are both absent: an operation declaring `Operation::renders`\n // has done both, and doing them again would render the picture twice.\n"
.to_string()
} else {
" // ==== camera profile: the base curve (FR-DEV-3e) ====
//
// Marked with `====` and not the `----` an operation block carries: this
// is not one, and the difference is what several tests count on to tell
// an edit apart from the reading of a file.
//
// The stage between demosaic and the working space that turns a correct
// exposure into a photograph. Sensor data is scene-referred and nearly
// linear; nothing anybody looks at is. Rendering it straight out is the
// dcraw default, and it is flat, dark through the midtones and clips its
// highlights instead of rolling them off.
//
// **In camera RGB, and after the adjustments**, which is a deliberate pair
// of choices:
//
// - Before the matrix, because that is where a base curve is defined and
// where every other converter applies one. The curve was tuned against
// this body's own primaries; moving it after the conversion would apply
// a Canon rendering to sRGB values and change what it does.
// - After exposure and the tonal operations, because those are corrections
// to *capture* and are only meaningful on linear values. A stop is a
// doubling; run exposure after a curve and it stops being one.
//
// Per channel rather than on luminance. It desaturates the extremes
// slightly, and that is the point — it is what makes a blown sky roll
// toward white rather than toward a saturated corner of the gamut, and it
// is what the camera's own JPEG does.
//
// The branch is on a uniform, so the whole dispatch takes the same path.
// It is off for a JPEG and any other already-rendered source, which must
// not be rendered twice, and for a body the profile database declines to
// offer any curve for at all.
if (u.base_curve_last.z > 0.5) {
c = vec3<f32>(
curve_eval(
u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y,
u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w,
u.base_curve_last.x, u.base_curve_last.y, c.r,
),
curve_eval(
u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y,
u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w,
u.base_curve_last.x, u.base_curve_last.y, c.g,
),
curve_eval(
u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y,
u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w,
u.base_curve_last.x, u.base_curve_last.y, c.b,
),
);
}
// Camera space -> linear sRGB. Applied after the adjustments so white
// balance and exposure act on sensor-native values, which is where they
// are physically meaningful.
//
// Identity for a non-linear source, which is already in sRGB primaries.
c = vec3<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),
);
"
.to_string()
};
let source = format!(
"// GENERATED — do not edit.
//
// Composed by dr-pipeline from {} active operation(s). Each block below is
// one operation's fragment, run in graph order over a linear scene-referred
// colour. Operations at neutral settings are omitted rather than branched
// over, so this shader does exactly the work the current edit requires.
struct Params {{
{uniform_fields}}}
@group(0) @binding(0) var source: texture_2d<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>;
{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}
// A non-linear source is already display-encoded; undo that so the
// operations below see linear colour whatever the source was.
let non_linear = u.as_shot_wb.w > 0.5;
if (non_linear) {{
c = decode_srgb(c);
}}
// As-shot white balance. Applied unconditionally, before any operation,
// because it is part of *interpreting* the sensor rather than an edit: a
// Bayer sensor's green photosites collect far more signal than its red
// and blue, so raw camera-space values are strongly green and no amount
// of later correction recovers a neutral image from them. The white
// balance operation, when active, applies its own offset on top of this.
//
// A non-linear source has already had this applied in-camera; the uniform
// is neutral there, so this is a multiply by one rather than a branch.
// How close this pixel was to saturation before any balance was applied.
// A photosite at its white level carries no colour information — every
// channel simply stopped counting — so the balance below must not be
// allowed to tint it.
let clipped = smoothstep(0.985, 1.0, max(c.r, max(c.g, c.b)));
c = c * u.as_shot_wb.rgb;
// **Highlight desaturation, and without it every blown sky is magenta.**
//
// A fully clipped pixel arrives as (1, 1, 1). The as-shot multipliers are
// not neutral — on a Canon 6D they are (1.93, 1.00, 1.68) — so balancing
// sends it to exactly that, and the camera matrix then produces R 2.88,
// G 0.51, B 2.03. Red and blue clip at one and green does not, which is
// magenta. The balance is correct; the input was not a colour.
//
// So a saturated pixel is pulled back toward the neutral its raw values
// actually represent, fading in over the last 1.5% of range. Smoothly,
// because a hard switch puts a visible edge around every highlight where
// the two treatments meet — a rim light on skin is the worst case, and it
// is the one people notice.
//
// The neutral chosen is the balanced grey of the same brightness, so the
// highlight keeps its luminance and loses only the cast.
if (clipped > 0.0) {{
let neutral = vec3<f32>(max(c.r, max(c.g, c.b)));
c = mix(c, neutral, clipped);
}}
{body}
{rendering_tail}{to_output}
{store}
}}
",
active.len()
);
// Taken over the generated source, because the source *is* the structure:
// it is what gets compiled, and two compositions that produce different
// WGSL are two pipelines however alike their op-sets look.
//
// Hashing the list of active operation ids instead — which is what this
// did — assumed every operation emits the same code whatever its
// parameters say. The colour mixer does not: it emits a block and a
// uniform only for the bands that are set, so a red adjustment and a blue
// one are the same op-set and different shaders. They shared a cache
// entry, so the second was rendered with the first's compiled pipeline
// while its uniforms were uploaded in an order that pipeline never agreed
// to — whichever band was adjusted first kept acting, and every other
// band appeared dead.
//
// Values still do not enter it, since no operation writes a parameter
// value into its source; they arrive as uniforms, and dragging a slider
// regenerates identical text. One that did inline a value would have to
// recompile to be correct, and hashing the source says so rather than
// silently reusing the wrong pipeline.
//
// Framing and the output space are mixed in as well, though both already
// shape the source: the prologue's branches and the encode function are
// written into it. Belt and braces on the two inputs whose contribution to
// the source is indirect.
let structure_hash = mix(
mix(hash_source(&source), framing.structure_key()),
output as u64,
);
ComposedShader {
source,
uniforms: uniform_values,
structure_hash,
output_mode,
}
}
/// The WGSL converting linear sRGB into the output space's primaries.
///
/// A constant matrix rather than a uniform: the space is chosen when the
/// shader is composed, so the numbers are known at generation time and the
/// driver can fold them into the surrounding arithmetic.
///
/// Empty for sRGB, which is the space the pipeline already works in — the
/// camera matrix converts into it, which is what `cam_to_srgb` is named for.
/// Emitting an identity there would put nine constants and three dot products
/// into the display path's shader, the one compiled most often, to compute the
/// value it already had. The identity is detected rather than special-cased by
/// name, so a space that happens to share sRGB's primaries would be spared
/// too.
pub(crate) fn primaries_conversion(output: ColourSpace) -> String {
let m = output.from_linear_srgb();
const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
// A tolerance rather than equality: the matrix is an inverse multiplied by
// a product, so sRGB's own comes back a few ULP off the identity. A
// millionth of a channel is four decimal places below an 8-bit step.
if m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < 1e-6) {
return String::new();
}
let mut out = format!(
"\n // Linear sRGB -> linear {}. The last colour transform before the\n\
\x20 // encode, and the reason a colour sRGB could not hold survives\n\
\x20 // this far: it is still inside this gamut.\n\
\x20 c = vec3<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) -> &'static str {
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, 1.0));
return;
}
// TRACES: FR-DEV-3f
// Where this pixel sits on the *source*, in source pixels. Published for
// fragments that need a position and not only a colour.
//
// The source and not the output, and that is the whole point: a pattern
// seeded from the render swims as the photograph is zoomed, and grain is a
// property of the film rather than of the view. Seeded from here it stays
// put, and its *amount* is handled separately by how much film a pixel
// covers -- see `dr_film::Grain`.
let source_px = uv_src * vec2<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.
var c = sample_bilinear(uv_src, src_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, 1.0));
return;
}
// Every output pixel lands on a source pixel, so load it directly: exact,
// and with no interpolation to soften detail.
let coord = min(vec2<i32>(uv_src * vec2<f32>(src_dims)), vec2<i32>(src_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);
var c = textureLoad(source, coord, 0).rgb;
"
}
}
/// Bilinear sampling against an unfiltered `texture_2d`.
///
/// Hand-rolled rather than done with a sampler: the source is bound as a plain
/// texture, and adding a sampler for the straightening case alone would change
/// a bind group layout that every pass shares.
const BILINEAR_HELPER: &str = "fn sample_bilinear(uv: vec2<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 crate::descriptor::Attribute;
use crate::descriptor::{LocalizedKey, OpId, ParamDescriptor};
static DESC_A: OpDescriptor = OpDescriptor {
id: OpId("op_a"),
label: LocalizedKey("a"),
params: &[ParamDescriptor::amount("amount", "a.amount")],
attributes: &[Attribute::Tone],
};
static DESC_B: OpDescriptor = OpDescriptor {
id: OpId("op_b"),
label: LocalizedKey("b"),
params: &[ParamDescriptor::amount("amount", "b.amount")],
attributes: &[Attribute::Tone],
};
struct Fake {
desc: &'static OpDescriptor,
amount: f32,
helper: Option<Helper>,
}
impl Operation for Fake {
fn descriptor(&self) -> &'static OpDescriptor {
self.desc
}
fn set_param(&mut self, _id: ParamId, value: f32) {
self.amount = value;
}
fn param(&self, _id: ParamId) -> f32 {
self.amount
}
fn is_active(&self) -> bool {
self.amount != 0.0
}
fn wgsl_body(&self) -> String {
"c = c * amount;".into()
}
fn uniforms(&self) -> Vec<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: &'static 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.
let ops = vec![fake(&DESC_A, 0.0, false)];
let shader = compose(&ops);
assert!(
!shader.source.contains("op_a"),
"a neutral operation must not reach the generated shader"
);
assert_eq!(
shader.uniforms.len(),
PREAMBLE_FIELDS,
"it must contribute no uniforms either"
);
}
/// Uniform slots reserved before any operation's own: the camera matrix
/// and as-shot white balance, plus framing. The same constant `dr-gpu`
/// writes against, so these offsets cannot agree with each other while
/// disagreeing with the shader.
const PREAMBLE_FIELDS: usize = RESERVED_UNIFORM_FIELDS;
#[test]
fn an_active_operation_appears_once() {
let ops = vec![fake(&DESC_A, 2.0, false)];
let shader = compose(&ops);
assert!(shader.source.contains("---- op_a ----"));
assert!(shader.source.contains("u.op_a_amount"));
}
#[test]
fn uniforms_are_prefixed_so_operations_cannot_collide() {
// Both fakes declare a uniform called `amount`. Without prefixing,
// the generated struct would have a duplicate field and fail to
// compile — the failure mode that makes naive concatenation fragile.
let ops = vec![fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)];
let shader = compose(&ops);
assert!(shader.source.contains("op_a_amount: f32"));
assert!(shader.source.contains("op_b_amount: f32"));
assert!(shader.source.contains("c = c * u.op_a_amount;"));
assert!(shader.source.contains("c = c * u.op_b_amount;"));
}
#[test]
fn uniform_values_follow_declaration_order() {
let ops = vec![fake(&DESC_A, 1.5, false), fake(&DESC_B, 2.5, false)];
let shader = compose(&ops);
assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5);
assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5);
}
#[test]
fn a_shared_helper_is_emitted_once() {
// Two operations wanting the same helper must not produce a
// duplicate function definition.
let ops = vec![fake(&DESC_A, 1.0, true), fake(&DESC_B, 1.0, true)];
let shader = compose(&ops);
assert_eq!(
shader.source.matches("fn luma(").count(),
1,
"a helper requested twice must be declared once"
);
}
#[test]
fn the_uniform_block_is_16_byte_aligned() {
// WGSL rejects a uniform struct whose size is not a multiple of 16.
for n in 0..6 {
let ops: Vec<Box<dyn Operation>> = (0..n)
.map(|i| fake(if i % 2 == 0 { &DESC_A } else { &DESC_B }, 1.0, false))
.collect();
let shader = compose(&ops);
assert_eq!(
shader.uniforms.len() % 4,
0,
"{n} operations produced {} floats, not a multiple of 4",
shader.uniforms.len()
);
}
}
#[test]
fn structure_hash_ignores_values_but_tracks_the_op_set() {
// The property the shader cache depends on: moving a slider must not
// trigger a recompile, but enabling an operation must.
let a1 = compose(&[fake(&DESC_A, 1.0, false)]).structure_hash;
let a2 = compose(&[fake(&DESC_A, 9.0, false)]).structure_hash;
assert_eq!(a1, a2, "a value change must reuse the compiled pipeline");
let both = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
assert_ne!(a1, both.structure_hash, "a different op-set must recompile");
}
#[test]
fn structure_hash_is_order_sensitive() {
// Operation order is data (ARCH §3.4); two orders are different
// shaders and must not share a cache entry.
let ab = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
let ba = compose(&[fake(&DESC_B, 1.0, false), fake(&DESC_A, 1.0, false)]);
assert_ne!(ab.structure_hash, ba.structure_hash);
}
#[test]
fn rewriting_respects_word_boundaries() {
// `amount` must not corrupt `amount_hi` — the bug a naive
// string replace would introduce.
let got = rewrite_uniform("x = amount + amount_hi;", "amount", "u.p_amount");
assert_eq!(got, "x = u.p_amount + amount_hi;");
}
#[test]
fn rewriting_leaves_comments_alone() {
// Found in generated source: a comment reading "A factor of 0 is
// monochrome" came out as "A u.saturation_factor of 0 is monochrome".
// The generated source is what gets read when a shader fails to
// compile, so mangling it works against the one time it matters.
let got = rewrite_uniform(
"// A factor of 0 is monochrome\nc = c * factor;",
"factor",
"u.op_factor",
);
assert_eq!(got, "// A factor of 0 is monochrome\nc = c * u.op_factor;");
}
#[test]
fn rewriting_resumes_after_a_comment_ends() {
let got = rewrite_uniform(
"// factor here is prose\nlet x = factor;\n// factor again\n",
"factor",
"u.p",
);
assert_eq!(
got,
"// factor here is prose\nlet x = u.p;\n// factor again\n"
);
}
#[test]
fn rewriting_leaves_substrings_alone() {
let got = rewrite_uniform("total_amount = 1.0;", "amount", "u.a");
assert_eq!(got, "total_amount = 1.0;");
}
#[test]
fn as_shot_white_balance_is_applied_even_with_no_operations() {
// The bug this catches, seen on a real CR2: a Bayer sensor's green
// photosites collect roughly twice the signal of its red and blue,
// so an image rendered without the as-shot multipliers comes out
// violently green. It must not depend on the white balance operation
// being active — that one carries only the user's offset.
let shader = compose(&[]);
assert!(
shader.source.contains("u.as_shot_wb"),
"a neutral edit must still apply as-shot white balance"
);
}
#[test]
fn white_balance_is_applied_before_the_operations() {
// Exposure and the tonal controls act on white-balanced values; if
// the multiply came afterwards, every operation would be reasoning
// about a green-cast image.
let ops = vec![fake(&DESC_A, 2.0, false)];
let source = compose(&ops).source;
let wb = source.find("u.as_shot_wb").expect("wb applied");
let op = source.find("---- op_a ----").expect("op present");
assert!(wb < op, "as-shot white balance must precede the operations");
}
#[test]
fn a_rendering_operation_takes_over_the_base_curve_and_the_camera_matrix() {
// TRACES: FR-DEV-3e | FR-DEV-3f
// A film stock's characteristic curve does the base curve's job, and
// the film node converts out of camera space itself. Emitting the
// profile's rendering as well would render the scene twice and convert
// it twice — a picture that comes out looking like neither the camera's
// rendering nor the film's, with a colour-management bug's signature
// and no colour-management bug to find.
let mut film = crate::ops::FilmSim::new();
film.set_film_tables(Some(&crate::ops::FilmTables {
exposure_matrix: [[5.0, 0.5, 0.2], [0.1, 5.0, 0.3], [0.2, 0.5, 4.0]],
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
curve_log_min: -3.0,
curve_log_max: 4.0,
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0,
lut_size: 32,
grain_particles: [0.0; 3],
grain_density_max: [3.0; 3],
grain_uniformity: 0.97,
}));
assert!(film.is_active(), "the fixture did not load");
let source = compose(&[Box::new(film) as Box<dyn Operation>]).source;
assert!(
source.contains("---- film_sim ----"),
"the operation itself must still be emitted"
);
assert!(
!source.contains("base_curve_last.z > 0.5"),
"the base curve is still being applied on top of the film"
);
// Asserted on the composer's own comment, not on the conversion
// itself: the film fragment performs exactly the same three dot
// products, so a substring search cannot tell the composer's copy from
// the operation's. What must be gone is the *second* one.
assert!(
!source.contains("Camera space -> linear sRGB"),
"the composer converted out of camera space after the film already had"
);
assert_eq!(
source.matches("dot(u.cam_to_srgb_0.rgb, c)").count(),
1,
"camera space is left exactly once, and it is the film that does it"
);
}
#[test]
fn an_operation_that_does_not_render_leaves_the_profile_alone() {
// The other half, and the one that would fail silently: a bug that
// suppressed the tail unconditionally renders every ordinary edit
// flat and uncorrected, which reads as a broken camera profile.
let source = compose(&[fake(&DESC_A, 2.0, false)]).source;
assert!(source.contains("base_curve_last.z > 0.5"));
assert!(source.contains("Camera space -> linear sRGB"));
}
#[test]
fn an_inactive_film_node_leaves_the_profile_alone() {
// `renders()` is a property of the type, but the suppression must key
// off whether it is *active*. A film node sitting in the chain with no
// stock loaded is the default state of every photograph in the
// catalogue, and it must not disturb the camera's own rendering.
let film: Box<dyn Operation> = Box::new(crate::ops::FilmSim::new());
let source = compose(&[film, fake(&DESC_A, 2.0, false)]).source;
assert!(source.contains("base_curve_last.z > 0.5"));
assert!(source.contains("Camera space -> linear sRGB"));
}
#[test]
fn the_camera_matrix_is_applied_after_the_operations() {
// Adjustments are meaningful in sensor-native space, where highlight
// headroom still exists; converting first would clip it away.
let ops = vec![fake(&DESC_A, 2.0, false)];
let source = compose(&ops).source;
let op = source.find("---- op_a ----").expect("op present");
let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied");
assert!(op < matrix, "the camera matrix must come after operations");
}
#[test]
fn the_base_curve_runs_after_the_operations_and_before_the_camera_matrix() {
// TRACES: FR-DEV-3e
// Both halves matter and for different reasons.
//
// After the operations: exposure and the tonal controls are
// corrections to capture, and they are only meaningful on linear
// values. A stop is a doubling; run exposure after a curve and it is
// not one any more, and every slider in the panel starts lying about
// what it does.
//
// Before the matrix: the curve was tuned against this body's own
// primaries. Applied after the conversion it would be a Canon
// rendering acting on sRGB values, which is a different curve.
let ops = vec![fake(&DESC_A, 2.0, false)];
let source = compose(&ops).source;
let op = source.find("---- op_a ----").expect("op present");
let curve = source
.find("if (u.base_curve_last.z > 0.5)")
.expect("base curve applied");
let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied");
assert!(op < curve, "the base curve must come after the operations");
assert!(curve < matrix, "and before the camera matrix");
}
#[test]
fn the_base_curve_reaches_a_shader_with_no_operations_at_all() {
// TRACES: FR-DEV-3e
// The same property as as-shot white balance, and for the same reason:
// it is part of interpreting the file, not part of the edit. An
// unedited RAW must open looking like a photograph rather than like a
// scan of one.
let shader = compose(&[]);
assert!(shader.source.contains("u.base_curve_x"));
assert!(
shader.source.contains("fn curve_eval("),
"the spline it is evaluated on must be emitted too"
);
}
#[test]
fn the_base_curve_and_the_tone_curve_share_one_spline() {
// TRACES: FR-DEV-3e
// Two `curve_eval`s in one shader would not compile — but the reason
// the helper is *shared* rather than merely renamed is that a profile
// author placing a control point and a photographer dragging one must
// mean the same thing by it, down to the tangent limiting that decides
// how a shoulder rolls off.
let mut curve = crate::ops::ToneCurve::new();
curve.set_param(crate::ops::curve::P2_Y, 0.7);
assert!(
curve.is_active(),
"the fixture must actually reach the shader"
);
let source = compose(&[Box::new(curve)]).source;
assert_eq!(
source.matches("fn curve_eval(").count(),
1,
"the spline must be declared exactly once"
);
assert_eq!(source.matches("fn curve_span(").count(), 1);
}
#[test]
fn the_base_curve_owns_the_slots_dr_gpu_writes() {
// TRACES: FR-DEV-3e
// `dr-gpu` fills these by index. The offset is exported rather than
// recomputed there, and this asserts the exported number still points
// at the block the shader declares — the failure otherwise is a
// highlight rolloff read out of a crop rectangle, which renders as
// nonsense rather than as an error.
assert_eq!(
BASE_CURVE_UNIFORM_OFFSET + BASE_CURVE_UNIFORM_FIELDS,
BASE_UNIFORM_FIELDS,
"the base curve must be the last thing in the base block"
);
assert_eq!(BASE_CURVE_POINTS * 2 + 1, BASE_CURVE_UNIFORM_FIELDS - 1);
assert!(compose(&[]).uniforms.len() >= BASE_UNIFORM_FIELDS);
}
/// Compose with neutral framing into a chosen output space.
fn compose_to(ops: &[Box<dyn Operation>], output: ColourSpace) -> ComposedShader {
compose_with_framing(ops, &Framing::new(), output)
}
#[test]
fn an_srgb_render_is_byte_for_byte_what_it_was_before_output_spaces_existed() {
// The display path is the shader compiled on nearly every frame, and
// it must not pick up an identity matrix multiply for the sake of
// generality. Asserted against the source rather than against timing,
// which would not fail reliably.
let ops = vec![fake(&DESC_A, 1.0, false)];
let srgb = compose_to(&ops, ColourSpace::Srgb).source;
assert!(
!srgb.contains("Linear sRGB -> linear sRGB"),
"sRGB in, sRGB out must emit no conversion:\n{srgb}"
);
assert_eq!(srgb, compose(&ops).source);
}
#[test]
fn a_wide_output_space_converts_after_the_camera_matrix_and_before_the_clip() {
// The whole point of the ordering. The camera matrix lands the colour
// in linear sRGB, the primaries conversion carries it into the wider
// space, and only then is it clipped — clipping first would discard
// exactly the colours the wider space was chosen to keep.
let source = compose_to(&[fake(&DESC_A, 1.0, false)], ColourSpace::DisplayP3).source;
let camera = source.find("u.cam_to_srgb_0").expect("camera matrix");
let convert = source
.find("Linear sRGB -> linear Display P3")
.expect("primaries conversion");
let clip = source.find("c = clamp(c,").expect("clip");
assert!(camera < convert, "the camera matrix must come first");
assert!(convert < clip, "the clip must come after the conversion");
}
#[test]
fn the_generated_matrix_is_the_one_the_profile_writer_will_use() {
// The shader encodes the pixels and `dr-export` describes them, from
// the same table in `dr-types`. If the composer ever grew its own copy
// of these numbers the file would be labelled with primaries it does
// not contain, which is the failure the whole feature exists to avoid.
let m = ColourSpace::DisplayP3.from_linear_srgb();
let first_row = format!("dot(vec3<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, 1.0, false)], space).structure_hash;
assert!(!seen.contains(&h), "{space:?} collides with another space");
seen.push(h);
}
}
#[test]
fn generated_source_carries_a_do_not_edit_banner() {
// Someone will eventually find this in a debugger and try to fix it
// in place.
let shader = compose(&[fake(&DESC_A, 1.0, false)]);
assert!(shader.source.starts_with("// GENERATED"));
}
#[test]
fn detail_operations_agree_with_themselves() {
// An operation says which stage it belongs to in two places — through
// `affects()` and through `detail()` — and the two must say the same
// thing. Disagreement is the worst possible failure mode here, because
// it is silent: an operation claiming `Affects::Detail` while
// returning `None` from `detail()` is fused as a point op and asked
// for a fragment it does not have, and one returning `Some` while
// claiming `Affects::Colour` is filtered out of the fused pass and put
// in the wrong invalidation bucket. Either way the slider moves and
// nothing happens.
//
// Checked over the real chain, plus the test consumer, so that a
// sharpening operation added later is covered by this without anyone
// remembering to extend it.
let mut ops = crate::ops::chain();
ops.push(Box::new(crate::detail::probe::BoxBlur::new()));
for op in &ops {
let id = op.descriptor().id;
assert_eq!(
op.detail().is_some(),
op.affects() == Affects::Detail,
"{id} disagrees with itself about whether it is a \
neighbourhood operation"
);
}
}
#[test]
fn a_detail_operation_never_contributes_a_fused_uniform() {
// Slot order in the generated block is emission order, and nothing
// addresses a slot by number — so an operation that contributed a
// uniform without contributing the fragment that reads it would shift
// every later operation's uniforms out from under its shader. The
// filter in `compose_full` prevents it; this is the assertion that the
// filter is on the right side of the loop.
let mut ops = crate::ops::chain();
ops.push(Box::new(crate::detail::probe::BoxBlur::with_radius(0.05)));
let before = compose(&crate::ops::chain()).uniforms.len();
assert_eq!(compose(&ops).uniforms.len(), before);
}
}