`Operation::descriptor()` returned `&'static OpDescriptor`, and that lifetime
is the whole reason a build-time node is free and a run-time node is
impossible: only a compile-time literal can satisfy it, so no amount of
reading `ops/*.yaml` at startup could ever produce a descriptor the rest of
the application would accept. FR-PLG-2 says a bundled operation and a
third-party plugin are the same kind of thing, differing only in where the
file was found — and a lifetime outsiders cannot meet is exactly the second,
weaker format that requirement forbids.
So a descriptor is now owned and handed out as `Arc<OpDescriptor>`, with `Vec`
where it held `&'static` slices. `Arc` rather than a `&self`-borrowed
reference because the callers want to *keep* it: the develop panel collects
descriptors and then mutates the graph, and a borrow would tie the
descriptor's lifetime to a borrow of the operation it came from, which is the
one thing `&'static` was doing right.
The identifier newtypes deliberately did not follow. `ParamId` is `Copy`, is
compared in `match` arms against generated constants, is a map key in the
sidecar and history, and reaches Slint model rows; an `Arc<str>` there would
cost a refcount on every one of those and would take `match id { EXPOSURE =>
.. }` away from the generated code. They gain an interner instead, which is
honest about its lifetime rather than pretending to one — the set of ids is
bounded by deduplication and is process-lifetime by construction, because the
sidecar on disk names its parameters and an id has to stay resolvable for as
long as any edit naming it can be opened.
No behaviour changes. Every descriptor that was a `static` is a `LazyLock`
initialiser now, `Operation::helpers` borrows from `self` instead of being
`'static` so a future run-time node can own its list, and `Warp` and `Framing`
follow `Operation` so there is one shape rather than two.
The one place a descriptor is read per frame is `compose_full`, which takes
`descriptor().id` to prefix each active operation's uniforms, and `dr-ui`
composes on every frame it draws. That is a dozen atomic increments beside a
composition that is already building several kilobytes of WGSL on the same
call; it is noted at the trait method rather than left for a profiler to find.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1789 lines
77 KiB
Rust
1789 lines
77 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 std::sync::Arc;
|
|
|
|
use dr_types::{ColourSpace, Transfer};
|
|
|
|
use crate::descriptor::{OpDescriptor, ParamId, Presentation};
|
|
use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS};
|
|
use crate::mask::MaskStack;
|
|
|
|
/// TRACES: FR-DEV-3d
|
|
/// What an operation's parameters affect, for cache invalidation scoping.
|
|
///
|
|
/// Adjusting exposure must not invalidate the demosaic result; this is what
|
|
/// lets the tile cache reuse everything up to the first changed stage
|
|
/// (ARCH §5.3).
|
|
///
|
|
/// **The ordering is the pipeline order**, which is why this derives `Ord`
|
|
/// rather than merely `Eq`: geometry decides which source pixel a colour comes
|
|
/// from, the fused colour pass transforms it, and the detail stage reads the
|
|
/// neighbourhood the colour pass produced. A change at one stage invalidates
|
|
/// that stage and every later one, and nothing earlier — see [`Invalidation`],
|
|
/// which is where that rule is actually written down and tested.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum Affects {
|
|
/// Pixel positions — crop, rotate, straighten. The framing prologue, which
|
|
/// also decides the resolution everything downstream runs at.
|
|
Geometry,
|
|
/// Per-pixel colour. Every operation fused into the single adjust
|
|
/// dispatch, and every mask layer's chain.
|
|
Colour,
|
|
/// TRACES: FR-DEV-3d
|
|
/// A pixel's *neighbourhood* — sharpening, noise reduction, clarity,
|
|
/// texture, dehaze, spot removal.
|
|
///
|
|
/// The seam `docs/requirements.md` §3.3 designed and nothing cut until
|
|
/// [`crate::detail`] existed. It is a separate variant rather than a flavour
|
|
/// of `Colour` because it is a separate *dispatch*: a fragment in the fused
|
|
/// pass is handed a colour and has no way back to a coordinate, so a
|
|
/// kernel cannot be expressed there at any price.
|
|
///
|
|
/// What the distinction buys, concretely: the fused pass's result is held
|
|
/// in a linear intermediate, so dragging a sharpening slider re-runs the
|
|
/// detail dispatches and **not** the colour pass — which is exactly the
|
|
/// reuse FR-DEV-3d asks for, and it is asserted in `dr-gpu`'s
|
|
/// `detail_stage` tests rather than merely hoped for.
|
|
Detail,
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3d
|
|
/// One cache key per pipeline stage, derived from the edit.
|
|
///
|
|
/// # The rule
|
|
///
|
|
/// A cached result for stage *S* stays valid while *S*'s own key and the keys
|
|
/// of every stage **before** it are unchanged. [`Self::of`] is the first half;
|
|
/// [`Self::through`] folds in the second and is what a cache should actually
|
|
/// store.
|
|
///
|
|
/// That reads as pedantry until it is applied, at which point it settles the
|
|
/// two questions FR-DEV-3d asks:
|
|
///
|
|
/// - **Changing a detail parameter must not re-run demosaic**, or the framing,
|
|
/// or the fused colour pass. It does not: `of(Detail)` moves and
|
|
/// `through(Colour)` does not, so the linear intermediate the colour pass
|
|
/// wrote is still good and only the detail dispatches run again.
|
|
///
|
|
/// - **Changing exposure must not re-run anything upstream of colour.** It
|
|
/// does not: `through(Geometry)` is untouched, so a tile cache keyed on it
|
|
/// survives, and the demosaiced texture — which no key here mentions at all
|
|
/// — is never in question.
|
|
///
|
|
/// It also settles what is *not* true, and the temptation is real: changing
|
|
/// exposure **does** re-run the detail passes, because the detail stage reads
|
|
/// what the colour pass wrote and that changed. There is no arrangement of
|
|
/// keys that avoids it while keeping sharpening after the tone curve, and
|
|
/// sharpening after the tone curve is the correct place (see
|
|
/// [`crate::detail`]). Anyone who wants exposure to leave the detail stage
|
|
/// alone is asking for detail to run *before* tone, which is a different
|
|
/// pipeline and a worse picture.
|
|
///
|
|
/// # Why the demosaic is not in here
|
|
///
|
|
/// Because no parameter in this graph can change it. The demosaiced texture is
|
|
/// a function of the file and the decode settings, both of which live outside
|
|
/// the edit graph; a caller keying a cache on it mixes in whatever names the
|
|
/// photograph — a `VersionId` — and these keys ride on top.
|
|
///
|
|
/// # Integer state only
|
|
///
|
|
/// Every value folded in here is a parameter: a slider position or a number
|
|
/// from a sidecar, never a float that came back from the GPU. That is what
|
|
/// ARCH §6.13 requires of a cache key, and it is why hashing the raw bit
|
|
/// patterns is sound rather than reckless. Negative zero is canonicalised on
|
|
/// the way in, because `-0.0 == 0.0` while their bit patterns differ, and a
|
|
/// slider that arrived at zero from below would otherwise invalidate a cache
|
|
/// that is perfectly valid.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Invalidation {
|
|
geometry: u64,
|
|
colour: u64,
|
|
detail: u64,
|
|
}
|
|
|
|
impl Invalidation {
|
|
/// Build from the three per-stage hashes. [`crate::EditGraph::invalidation`]
|
|
/// is what computes them; this is public so a caller with its own notion
|
|
/// of a stage can construct one.
|
|
pub fn new(geometry: u64, colour: u64, detail: u64) -> Self {
|
|
Self {
|
|
geometry,
|
|
colour,
|
|
detail,
|
|
}
|
|
}
|
|
|
|
/// The key for `stage`'s own parameters, ignoring everything upstream.
|
|
///
|
|
/// Useful for asserting that a change was correctly *scoped* — that moving
|
|
/// a detail slider left the colour stage's parameters alone. Not a cache
|
|
/// key: a stage whose own parameters are unchanged still has to re-run if
|
|
/// its input changed, which is what [`Self::through`] is for.
|
|
pub fn of(&self, stage: Affects) -> u64 {
|
|
match stage {
|
|
Affects::Geometry => self.geometry,
|
|
Affects::Colour => self.colour,
|
|
Affects::Detail => self.detail,
|
|
}
|
|
}
|
|
|
|
/// The key for the **output** of `stage` — this stage and everything
|
|
/// upstream of it. What a cached texture should be keyed on.
|
|
pub fn through(&self, stage: Affects) -> u64 {
|
|
let mut h = FNV_OFFSET;
|
|
h = mix(h, self.geometry);
|
|
if stage >= Affects::Colour {
|
|
h = mix(h, self.colour);
|
|
}
|
|
if stage >= Affects::Detail {
|
|
h = mix(h, self.detail);
|
|
}
|
|
h
|
|
}
|
|
}
|
|
|
|
/// Fold one operation's identity and settings into a running hash.
|
|
///
|
|
/// Shared by the stage keys so that two stages cannot come to disagree about
|
|
/// what "this operation's state" means — which would show as a cache that is
|
|
/// occasionally, unreproducibly stale.
|
|
pub(crate) fn hash_op(h: u64, op: &dyn Operation) -> u64 {
|
|
let desc = op.descriptor();
|
|
let mut h = hash_bytes(h, desc.id.0.as_bytes());
|
|
for p in &desc.params {
|
|
h = hash_bytes(h, p.id.0.as_bytes());
|
|
h = mix(h, u64::from(canonical_bits(op.param(p.id))));
|
|
}
|
|
h
|
|
}
|
|
|
|
/// A parameter's bits, with negative zero folded onto zero.
|
|
///
|
|
/// `-0.0 == 0.0` as far as every operation is concerned — a slider that
|
|
/// reached zero from below produces the same shader and the same picture — but
|
|
/// the two have different bit patterns. Hashing them apart would invalidate a
|
|
/// cache for a change that is not one.
|
|
pub(crate) fn canonical_bits(v: f32) -> u32 {
|
|
if v == 0.0 {
|
|
0
|
|
} else {
|
|
v.to_bits()
|
|
}
|
|
}
|
|
|
|
pub(crate) fn hash_bytes(mut h: u64, bytes: &[u8]) -> u64 {
|
|
for byte in bytes {
|
|
h ^= u64::from(*byte);
|
|
h = h.wrapping_mul(0x100_0000_01b3);
|
|
}
|
|
h
|
|
}
|
|
|
|
/// FNV-1a's offset basis. No dependency, and stable across runs and platforms,
|
|
/// which a cache key requires.
|
|
pub(crate) const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
|
|
|
/// A single scalar a fragment reads from the generated uniform block.
|
|
///
|
|
/// Operations declare uniforms by name and value; the composer assigns them
|
|
/// slots and emits the struct. An operation never knows its own offset, which
|
|
/// is what allows fragments to be reordered or omitted freely.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Uniform {
|
|
/// Field name as it appears in WGSL. Prefixed with the op id by the
|
|
/// composer, so two operations may both declare `amount`.
|
|
///
|
|
/// `&'static str` for the reason [`crate::descriptor::intern`] gives about
|
|
/// ids: a uniform name is a small, deduplicated, process-lifetime piece of
|
|
/// vocabulary, and a declared operation interns its names once when it is
|
|
/// parsed rather than allocating them on every `uniforms()` call — which
|
|
/// happens per composition, and composition happens per frame.
|
|
pub name: &'static str,
|
|
pub value: f32,
|
|
}
|
|
|
|
/// A develop operation.
|
|
///
|
|
/// Object-safe: the pipeline holds `Box<dyn Operation>` in graph order, so
|
|
/// order is data rather than code (ARCH §3.4).
|
|
pub trait Operation: Send + Sync {
|
|
/// TRACES: FR-DEV-3a | FR-PLG-2
|
|
/// This operation's description, driving UI generation (FR-DEV-3a).
|
|
///
|
|
/// **Shared and owned rather than `&'static`.** See [`OpDescriptor`] for
|
|
/// why — in short, a `&'static` descriptor is one a compile-time literal
|
|
/// can produce and a load-time declaration cannot, which would make a
|
|
/// plugin a second-class kind of operation for a reason that is purely an
|
|
/// artefact of how the built-ins happen to be written.
|
|
///
|
|
/// # What this costs, and where
|
|
///
|
|
/// One `Arc` clone and drop per call. Descriptors are read when a panel is
|
|
/// built (`EditGraph::capabilities`), when a sidecar is written or read,
|
|
/// and when the history names what changed — none of which is a per-frame
|
|
/// path.
|
|
///
|
|
/// There is **one** exception, and it is worth stating plainly rather than
|
|
/// letting somebody discover it with a profiler: [`compose_full`] reads
|
|
/// `descriptor().id` once per *active* operation to prefix its uniforms,
|
|
/// and `dr-ui` composes on every frame it draws. That is a handful of
|
|
/// atomic increments — a dozen or so, against a composition that is
|
|
/// already building several kilobytes of WGSL text from scratch on the
|
|
/// same call. If composition ever stops being a per-frame operation, this
|
|
/// stops being a question at all; while it is one, the refcount is not
|
|
/// what makes it expensive.
|
|
fn descriptor(&self) -> Arc<OpDescriptor>;
|
|
|
|
/// Set a parameter. Values arrive already clamped to the descriptor.
|
|
fn set_param(&mut self, id: ParamId, value: f32);
|
|
|
|
/// Read a parameter back, for the sidecar and for the UI's initial state.
|
|
fn param(&self, id: ParamId) -> f32;
|
|
|
|
/// Whether this operation currently changes the image.
|
|
///
|
|
/// An operation at its neutral settings returns `false` and is omitted
|
|
/// from the generated shader entirely. This is what makes the common case
|
|
/// — a handful of active adjustments out of many available — cost only
|
|
/// what is actually used.
|
|
fn is_active(&self) -> bool;
|
|
|
|
/// The WGSL body of this operation's transform.
|
|
///
|
|
/// Receives `c` (a `vec3<f32>` of linear RGB) and must produce the
|
|
/// result in `c`. Uniforms are addressed by the names declared in
|
|
/// [`Self::uniforms`], accessed as `u.<prefixed_name>`; the composer
|
|
/// rewrites them, so a fragment writes the bare name.
|
|
///
|
|
/// The fragment runs inside its own block, so locals need no unique
|
|
/// names.
|
|
///
|
|
/// Never called on an operation that declares a [`Self::detail`] stage —
|
|
/// a neighbourhood operation is a dispatch of its own and contributes
|
|
/// nothing to the fused shader, so it returns an empty string.
|
|
fn wgsl_body(&self) -> String;
|
|
|
|
/// Uniform values this operation's fragment reads.
|
|
fn uniforms(&self) -> Vec<Uniform>;
|
|
|
|
/// What this operation's parameters affect.
|
|
fn affects(&self) -> Affects {
|
|
Affects::Colour
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3f
|
|
/// Hand this operation a stock's measured tables, if it wants them.
|
|
///
|
|
/// Default: ignore them, which is right for every operation that is a
|
|
/// function of its parameters alone.
|
|
///
|
|
/// A named method rather than a downcast or a bag of profiles, because
|
|
/// there is one caller and inventing a general mechanism for it would be
|
|
/// guessing at the shape of the next one. `vignetting`, `distortion` and
|
|
/// `aberration` already carry lens measurements through `set_profile` and
|
|
/// are not yet reached from the graph at all; when they are, this is the
|
|
/// shape it should take.
|
|
fn set_film_tables(&mut self, _tables: Option<&crate::ops::film_sim::FilmTables>) {}
|
|
|
|
/// TRACES: FR-DEV-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.
|
|
///
|
|
/// Borrowed from `self` rather than `'static`, for the reason
|
|
/// [`Self::descriptor`] is owned: a generated operation returns a
|
|
/// `&'static [Helper]` and coerces, while an operation built from a
|
|
/// declaration at load time owns its list. The [`Helper`] *strings*
|
|
/// themselves stay `&'static` — they are interned, like the ids.
|
|
fn helpers(&self) -> &[Helper] {
|
|
&[]
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3a | FR-DEV-3b
|
|
/// How this operation would like its parameters presented.
|
|
///
|
|
/// `None` — the default, and the right answer for nearly every operation
|
|
/// — means one control per parameter, chosen from its
|
|
/// [`crate::ParamKind`]. Returning a [`Presentation`] says that several
|
|
/// parameters form a single conceptual control and names the widget that
|
|
/// draws it.
|
|
///
|
|
/// Purely a hint. The parameters remain individually addressable
|
|
/// scalars, so a UI that does not implement the named widget falls back
|
|
/// to sliders and stays fully functional.
|
|
fn presentation(&self) -> Option<Presentation> {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// 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(),
|
|
&crate::spot::SpotSet::new(),
|
|
)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// Compose the global chain, the framing, and the local adjustments.
|
|
///
|
|
/// Mask layers are emitted **after** every global operation and before the
|
|
/// conversion out of camera space, so a local exposure acts on the tones the
|
|
/// global chain settled on — which is what a photographer means by "and then
|
|
/// lift the shadows on her face".
|
|
///
|
|
/// The fused-dispatch property survives: three global adjustments and two
|
|
/// masked ones are still one shader, one read and one write. The masks
|
|
/// themselves arrive as a pre-rasterised texture array (ARCH §5.4), so a
|
|
/// slider drag over a mask recompiles a shader but re-rasterises nothing.
|
|
pub fn compose_full(
|
|
ops: &[Box<dyn Operation>],
|
|
framing: &Framing,
|
|
output: ColourSpace,
|
|
masks: &MaskStack,
|
|
spots: &crate::spot::SpotSet,
|
|
) -> ComposedShader {
|
|
// Active *point* operations. A neighbourhood operation is filtered out
|
|
// here rather than asked for a fragment it cannot write: it reads pixels
|
|
// it is not writing, so it belongs to the detail stage that runs after
|
|
// this one (see `crate::detail`). Filtering on the declared stage rather
|
|
// than on `affects()` means the shader and the stage agree by
|
|
// construction — there is one place an operation says which it is.
|
|
let active: Vec<&dyn Operation> = ops
|
|
.iter()
|
|
.map(|o| o.as_ref())
|
|
.filter(|o| o.is_active() && o.detail().is_none())
|
|
.collect();
|
|
|
|
// Whether a detail stage follows. If one does, this pass stops short of
|
|
// the output transform and hands on a linear intermediate; the last detail
|
|
// pass finishes the job. Decided from the operations themselves rather
|
|
// than from a flag the caller sets, because a caller that got the flag
|
|
// wrong would produce a shader whose storage format does not match the
|
|
// texture bound to it.
|
|
//
|
|
// TRACES: FR-DEV-8
|
|
// The repairs count too, and they are the reason this takes a spot set at
|
|
// all: a photograph with a spot on it and no sharpening still has a detail
|
|
// stage, and a fused pass that encoded its own output there would quantise
|
|
// twice and be bound to a texture of the wrong format.
|
|
let output_mode =
|
|
if ops.iter().any(|o| o.is_active() && o.detail().is_some()) || !spots.is_neutral() {
|
|
OutputMode::LinearWorking
|
|
} else {
|
|
OutputMode::Encoded
|
|
};
|
|
|
|
// Whether an operation has taken over the rendering. Decided from the
|
|
// operations for the same reason `output_mode` is: a caller that got it
|
|
// wrong would produce a shader that compiles and renders the picture twice.
|
|
let op_renders = ops.iter().any(|o| o.is_active() && o.renders());
|
|
|
|
let mut uniform_fields = String::new();
|
|
let mut uniform_values: Vec<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 std::sync::LazyLock;
|
|
|
|
use crate::descriptor::Attribute;
|
|
use crate::descriptor::{LocalizedKey, OpId, ParamDescriptor};
|
|
|
|
static DESC_A: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
|
Arc::new(OpDescriptor {
|
|
id: OpId("op_a"),
|
|
label: LocalizedKey("a"),
|
|
params: vec![ParamDescriptor::amount("amount", "a.amount")],
|
|
attributes: vec![Attribute::Tone],
|
|
})
|
|
});
|
|
static DESC_B: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
|
Arc::new(OpDescriptor {
|
|
id: OpId("op_b"),
|
|
label: LocalizedKey("b"),
|
|
params: vec![ParamDescriptor::amount("amount", "b.amount")],
|
|
attributes: vec![Attribute::Tone],
|
|
})
|
|
});
|
|
|
|
struct Fake {
|
|
desc: Arc<OpDescriptor>,
|
|
amount: f32,
|
|
helper: Option<Helper>,
|
|
}
|
|
|
|
impl Operation for Fake {
|
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
|
self.desc.clone()
|
|
}
|
|
fn set_param(&mut self, _id: ParamId, value: f32) {
|
|
self.amount = value;
|
|
}
|
|
fn param(&self, _id: ParamId) -> f32 {
|
|
self.amount
|
|
}
|
|
fn is_active(&self) -> bool {
|
|
self.amount != 0.0
|
|
}
|
|
fn wgsl_body(&self) -> String {
|
|
"c = c * amount;".into()
|
|
}
|
|
fn uniforms(&self) -> Vec<Uniform> {
|
|
vec![Uniform {
|
|
name: "amount",
|
|
value: self.amount,
|
|
}]
|
|
}
|
|
fn helpers(&self) -> &'static [Helper] {
|
|
match self.helper {
|
|
Some(_) => SHARED,
|
|
None => &[],
|
|
}
|
|
}
|
|
}
|
|
|
|
static SHARED: &[Helper] = &[Helper {
|
|
name: "luma",
|
|
source: "fn luma(c: vec3<f32>) -> f32 { return c.g; }",
|
|
}];
|
|
|
|
fn fake(desc: Arc<OpDescriptor>, amount: f32, helper: bool) -> Box<dyn Operation> {
|
|
Box::new(Fake {
|
|
desc,
|
|
amount,
|
|
helper: helper.then_some(SHARED[0]),
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn an_inactive_operation_contributes_nothing() {
|
|
// The point of composing rather than branching: an op at neutral
|
|
// must not appear in the source at all.
|
|
let ops = vec![fake(DESC_A.clone(), 0.0, false)];
|
|
let shader = compose(&ops);
|
|
assert!(
|
|
!shader.source.contains("op_a"),
|
|
"a neutral operation must not reach the generated shader"
|
|
);
|
|
assert_eq!(
|
|
shader.uniforms.len(),
|
|
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.clone(), 2.0, false)];
|
|
let shader = compose(&ops);
|
|
assert!(shader.source.contains("---- op_a ----"));
|
|
assert!(shader.source.contains("u.op_a_amount"));
|
|
}
|
|
|
|
#[test]
|
|
fn uniforms_are_prefixed_so_operations_cannot_collide() {
|
|
// Both fakes declare a uniform called `amount`. Without prefixing,
|
|
// the generated struct would have a duplicate field and fail to
|
|
// compile — the failure mode that makes naive concatenation fragile.
|
|
let ops = vec![
|
|
fake(DESC_A.clone(), 1.0, false),
|
|
fake(DESC_B.clone(), 2.0, false),
|
|
];
|
|
let shader = compose(&ops);
|
|
assert!(shader.source.contains("op_a_amount: f32"));
|
|
assert!(shader.source.contains("op_b_amount: f32"));
|
|
assert!(shader.source.contains("c = c * u.op_a_amount;"));
|
|
assert!(shader.source.contains("c = c * u.op_b_amount;"));
|
|
}
|
|
|
|
#[test]
|
|
fn uniform_values_follow_declaration_order() {
|
|
let ops = vec![
|
|
fake(DESC_A.clone(), 1.5, false),
|
|
fake(DESC_B.clone(), 2.5, false),
|
|
];
|
|
let shader = compose(&ops);
|
|
assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5);
|
|
assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5);
|
|
}
|
|
|
|
#[test]
|
|
fn a_shared_helper_is_emitted_once() {
|
|
// Two operations wanting the same helper must not produce a
|
|
// duplicate function definition.
|
|
let ops = vec![
|
|
fake(DESC_A.clone(), 1.0, true),
|
|
fake(DESC_B.clone(), 1.0, true),
|
|
];
|
|
let shader = compose(&ops);
|
|
assert_eq!(
|
|
shader.source.matches("fn luma(").count(),
|
|
1,
|
|
"a helper requested twice must be declared once"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_uniform_block_is_16_byte_aligned() {
|
|
// WGSL rejects a uniform struct whose size is not a multiple of 16.
|
|
for n in 0..6 {
|
|
let ops: Vec<Box<dyn Operation>> = (0..n)
|
|
.map(|i| {
|
|
fake(
|
|
if i % 2 == 0 {
|
|
DESC_A.clone()
|
|
} else {
|
|
DESC_B.clone()
|
|
},
|
|
1.0,
|
|
false,
|
|
)
|
|
})
|
|
.collect();
|
|
let shader = compose(&ops);
|
|
assert_eq!(
|
|
shader.uniforms.len() % 4,
|
|
0,
|
|
"{n} operations produced {} floats, not a multiple of 4",
|
|
shader.uniforms.len()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn structure_hash_ignores_values_but_tracks_the_op_set() {
|
|
// The property the shader cache depends on: moving a slider must not
|
|
// trigger a recompile, but enabling an operation must.
|
|
let a1 = compose(&[fake(DESC_A.clone(), 1.0, false)]).structure_hash;
|
|
let a2 = compose(&[fake(DESC_A.clone(), 9.0, false)]).structure_hash;
|
|
assert_eq!(a1, a2, "a value change must reuse the compiled pipeline");
|
|
|
|
let both = compose(&[
|
|
fake(DESC_A.clone(), 1.0, false),
|
|
fake(DESC_B.clone(), 1.0, false),
|
|
]);
|
|
assert_ne!(a1, both.structure_hash, "a different op-set must recompile");
|
|
}
|
|
|
|
#[test]
|
|
fn structure_hash_is_order_sensitive() {
|
|
// Operation order is data (ARCH §3.4); two orders are different
|
|
// shaders and must not share a cache entry.
|
|
let ab = compose(&[
|
|
fake(DESC_A.clone(), 1.0, false),
|
|
fake(DESC_B.clone(), 1.0, false),
|
|
]);
|
|
let ba = compose(&[
|
|
fake(DESC_B.clone(), 1.0, false),
|
|
fake(DESC_A.clone(), 1.0, false),
|
|
]);
|
|
assert_ne!(ab.structure_hash, ba.structure_hash);
|
|
}
|
|
|
|
#[test]
|
|
fn rewriting_respects_word_boundaries() {
|
|
// `amount` must not corrupt `amount_hi` — the bug a naive
|
|
// string replace would introduce.
|
|
let got = rewrite_uniform("x = amount + amount_hi;", "amount", "u.p_amount");
|
|
assert_eq!(got, "x = u.p_amount + amount_hi;");
|
|
}
|
|
|
|
#[test]
|
|
fn rewriting_leaves_comments_alone() {
|
|
// Found in generated source: a comment reading "A factor of 0 is
|
|
// monochrome" came out as "A u.saturation_factor of 0 is monochrome".
|
|
// The generated source is what gets read when a shader fails to
|
|
// compile, so mangling it works against the one time it matters.
|
|
let got = rewrite_uniform(
|
|
"// A factor of 0 is monochrome\nc = c * factor;",
|
|
"factor",
|
|
"u.op_factor",
|
|
);
|
|
assert_eq!(got, "// A factor of 0 is monochrome\nc = c * u.op_factor;");
|
|
}
|
|
|
|
#[test]
|
|
fn rewriting_resumes_after_a_comment_ends() {
|
|
let got = rewrite_uniform(
|
|
"// factor here is prose\nlet x = factor;\n// factor again\n",
|
|
"factor",
|
|
"u.p",
|
|
);
|
|
assert_eq!(
|
|
got,
|
|
"// factor here is prose\nlet x = u.p;\n// factor again\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewriting_leaves_substrings_alone() {
|
|
let got = rewrite_uniform("total_amount = 1.0;", "amount", "u.a");
|
|
assert_eq!(got, "total_amount = 1.0;");
|
|
}
|
|
|
|
#[test]
|
|
fn as_shot_white_balance_is_applied_even_with_no_operations() {
|
|
// The bug this catches, seen on a real CR2: a Bayer sensor's green
|
|
// photosites collect roughly twice the signal of its red and blue,
|
|
// so an image rendered without the as-shot multipliers comes out
|
|
// violently green. It must not depend on the white balance operation
|
|
// being active — that one carries only the user's offset.
|
|
let shader = compose(&[]);
|
|
assert!(
|
|
shader.source.contains("u.as_shot_wb"),
|
|
"a neutral edit must still apply as-shot white balance"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn white_balance_is_applied_before_the_operations() {
|
|
// Exposure and the tonal controls act on white-balanced values; if
|
|
// the multiply came afterwards, every operation would be reasoning
|
|
// about a green-cast image.
|
|
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
|
|
let source = compose(&ops).source;
|
|
let wb = source.find("u.as_shot_wb").expect("wb applied");
|
|
let op = source.find("---- op_a ----").expect("op present");
|
|
assert!(wb < op, "as-shot white balance must precede the operations");
|
|
}
|
|
|
|
#[test]
|
|
fn a_rendering_operation_takes_over_the_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.clone(), 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.clone(), 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.clone(), 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.clone(), 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.clone(), 1.0, false)];
|
|
let srgb = compose_to(&ops, ColourSpace::Srgb).source;
|
|
assert!(
|
|
!srgb.contains("Linear sRGB -> linear sRGB"),
|
|
"sRGB in, sRGB out must emit no conversion:\n{srgb}"
|
|
);
|
|
assert_eq!(srgb, compose(&ops).source);
|
|
}
|
|
|
|
#[test]
|
|
fn a_wide_output_space_converts_after_the_camera_matrix_and_before_the_clip() {
|
|
// The whole point of the ordering. The camera matrix lands the colour
|
|
// in linear sRGB, the primaries conversion carries it into the wider
|
|
// space, and only then is it clipped — clipping first would discard
|
|
// exactly the colours the wider space was chosen to keep.
|
|
let source = compose_to(&[fake(DESC_A.clone(), 1.0, false)], ColourSpace::DisplayP3).source;
|
|
let camera = source.find("u.cam_to_srgb_0").expect("camera matrix");
|
|
let convert = source
|
|
.find("Linear sRGB -> linear Display P3")
|
|
.expect("primaries conversion");
|
|
let clip = source.find("c = clamp(c,").expect("clip");
|
|
assert!(camera < convert, "the camera matrix must come first");
|
|
assert!(convert < clip, "the clip must come after the conversion");
|
|
}
|
|
|
|
#[test]
|
|
fn the_generated_matrix_is_the_one_the_profile_writer_will_use() {
|
|
// The shader encodes the pixels and `dr-export` describes them, from
|
|
// the same table in `dr-types`. If the composer ever grew its own copy
|
|
// of these numbers the file would be labelled with primaries it does
|
|
// not contain, which is the failure the whole feature exists to avoid.
|
|
let m = ColourSpace::DisplayP3.from_linear_srgb();
|
|
let first_row = format!("dot(vec3<f32>({:.6}, {:.6}, {:.6}), c)", m[0], m[1], m[2]);
|
|
let source = compose_to(&[], ColourSpace::DisplayP3).source;
|
|
assert!(
|
|
source.contains(&first_row),
|
|
"expected {first_row} in:\n{source}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_output_space_encodes_with_its_own_transfer_function() {
|
|
// Adobe RGB's pure 2.199 gamma and ProPhoto's 1.8-with-a-toe are not
|
|
// the sRGB curve, and a file encoded with the wrong one is wrong in a
|
|
// way no amount of correct primaries repairs.
|
|
let marks = [
|
|
(ColourSpace::Srgb, "1.0 / 2.4"),
|
|
(ColourSpace::DisplayP3, "1.0 / 2.4"),
|
|
(ColourSpace::AdobeRgb, "1.0 / 2.19921875"),
|
|
(ColourSpace::ProPhoto, "1.0 / 1.8"),
|
|
];
|
|
for (space, mark) in marks {
|
|
let source = compose_to(&[], space).source;
|
|
assert!(
|
|
source.contains(mark),
|
|
"{space:?} should encode with {mark}:\n{source}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_output_space_changes_the_structure_hash() {
|
|
// The pipeline cache is keyed on this hash. Two spaces sharing one
|
|
// would have the second rendered with the first's compiled shader —
|
|
// an export that came out sRGB and claimed to be Display P3.
|
|
let mut seen: Vec<u64> = Vec::new();
|
|
for space in ColourSpace::ALL {
|
|
let h = compose_to(&[fake(DESC_A.clone(), 1.0, false)], space).structure_hash;
|
|
assert!(!seen.contains(&h), "{space:?} collides with another space");
|
|
seen.push(h);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn generated_source_carries_a_do_not_edit_banner() {
|
|
// Someone will eventually find this in a debugger and try to fix it
|
|
// in place.
|
|
let shader = compose(&[fake(DESC_A.clone(), 1.0, false)]);
|
|
assert!(shader.source.starts_with("// GENERATED"));
|
|
}
|
|
|
|
#[test]
|
|
fn detail_operations_agree_with_themselves() {
|
|
// An operation says which stage it belongs to in two places — through
|
|
// `affects()` and through `detail()` — and the two must say the same
|
|
// thing. Disagreement is the worst possible failure mode here, because
|
|
// it is silent: an operation claiming `Affects::Detail` while
|
|
// returning `None` from `detail()` is fused as a point op and asked
|
|
// for a fragment it does not have, and one returning `Some` while
|
|
// claiming `Affects::Colour` is filtered out of the fused pass and put
|
|
// in the wrong invalidation bucket. Either way the slider moves and
|
|
// nothing happens.
|
|
//
|
|
// Checked over the real chain, plus the test consumer, so that a
|
|
// sharpening operation added later is covered by this without anyone
|
|
// remembering to extend it.
|
|
let mut ops = crate::ops::chain();
|
|
ops.push(Box::new(crate::detail::probe::BoxBlur::new()));
|
|
for op in &ops {
|
|
let id = op.descriptor().id;
|
|
assert_eq!(
|
|
op.detail().is_some(),
|
|
op.affects() == Affects::Detail,
|
|
"{id} disagrees with itself about whether it is a \
|
|
neighbourhood operation"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_detail_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);
|
|
}
|
|
}
|