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>
1704 lines
74 KiB
Rust
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);
|
|
}
|
|
}
|