Files
DarkRoom/core/dr-pipeline/src/operation.rs
T
dtourolle 5786977a51 Develop a JPEG through the same pipeline as a RAW
DemosaicedImage gains a second producer, from_rgba8, alongside the CFA path.
Nothing about the type is CFA-specific — it is "an image on the GPU, ready to
adjust" — which is what lets develop mode work on a JPEG without the edit
graph or any operation knowing the source was not a RAW file.

The one real difference is the transfer function: sensor data is linear, a
JPEG is gamma-encoded. Every operation assumes linear scene-referred colour
(exposure is a multiply, and doubling a gamma-encoded value is not a stop), so
the shader prologue linearises once, at the only point where the two source
kinds still differ. The flag rides in as_shot_wb.w, which was padding. For a
JPEG the white balance uniform is neutral and the colour matrix is identity,
so both stay unconditional multiplies rather than becoming branches.

max_dimension is exposed because it is a hardware limit the caller must plan
around, not a failure to report afterwards: a 13728x8928 film scan exceeds the
common 8192 texture limit, and fitting it first is the only way to develop it
at all.

Assisted-by: LLM
2026-08-09 21:07:20 +02:00

777 lines
30 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 crate::descriptor::{OpDescriptor, ParamId, Presentation};
use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS};
/// 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).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Affects {
/// Per-pixel colour only. Everything in this milestone.
Colour,
/// Pixel positions — crop, rotate. Invalidates geometry-dependent caches.
Geometry,
}
/// 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.
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
}
/// 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,
}
/// 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,
}
/// 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.
const BASE_UNIFORM_FIELDS: usize = 16;
/// 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.
///
/// 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.
pub fn compose(ops: &[Box<dyn Operation>]) -> ComposedShader {
compose_with_framing(ops, &Framing::new())
}
/// 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.
pub fn compose_with_framing(ops: &[Box<dyn Operation>], framing: &Framing) -> ComposedShader {
let active: Vec<&dyn Operation> = ops
.iter()
.map(|o| o.as_ref())
.filter(|o| o.is_active())
.collect();
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",
);
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
// 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, " }}");
}
// 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 {
""
};
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<rgba8unorm, write>;
{sampler_helper}{helper_src}// Linear sRGB to the display transfer function.
//
// The one place quantisation happens: everything above runs in linear f16,
// and this is the final encode (ARCH §5.2).
fn encode_srgb(c: vec3<f32>) -> vec3<f32> {{
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));
}}
// The inverse, for sources that arrive already display-encoded.
//
// 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.
c = c * u.as_shot_wb.rgb;
{body}
// 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),
);
// Clip to the display gamut and encode.
c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(encode_srgb(c), 1.0));
}}
",
active.len()
);
// Framing enters the hash by structure only — which branches its prologue
// generated, never how far a slider moved. Dragging the crop handles must
// reuse the compiled pipeline and re-upload uniforms.
let structure_hash = mix(hash_structure(&active), framing.structure_key());
ComposedShader {
source,
uniforms: uniform_values,
structure_hash,
}
}
/// 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.
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;
}
// 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));
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.
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 op-set and order — the structure, not the values.
///
/// Two edits with the same operations at different slider positions produce
/// the same hash and reuse one compiled pipeline (ARCH §6.13: the hash is
/// over integer state only, so it is exactly deterministic).
fn hash_structure(active: &[&dyn Operation]) -> 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 op in active {
for byte in op.descriptor().id.0.as_bytes() {
h ^= u64::from(*byte);
h = h.wrapping_mul(0x100_0000_01b3);
}
// A separator, so ["ab", "c"] and ["a", "bc"] differ.
h ^= 0xff;
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.
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::{LocalizedKey, OpId, ParamDescriptor};
static DESC_A: OpDescriptor = OpDescriptor {
id: OpId("op_a"),
label: LocalizedKey("a"),
params: &[ParamDescriptor::amount("amount", "a.amount")],
};
static DESC_B: OpDescriptor = OpDescriptor {
id: OpId("op_b"),
label: LocalizedKey("b"),
params: &[ParamDescriptor::amount("amount", "b.amount")],
};
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 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 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"));
}
}