Add the develop pipeline: demosaic and seven raw adjustments

Decode through display, on the GPU: black/white normalisation, Bayer
demosaic, camera colour transform, and the first seven adjustment
operations — white balance, exposure, highlights/shadows, blacks/whites,
brilliance, vibrance, saturation.

Composable shaders. Each operation contributes a WGSL fragment rather
than owning a pass, and dr-pipeline fuses the *active* ones into a single
compute shader. One texture read and one write per frame regardless of
how many adjustments are in play, while the operations stay independent
in Rust — adding one is a new file, with no central shader to edit. An
operation at neutral settings contributes no code, no uniform and no
branch. Uniforms are prefixed per operation so two may both declare
`amount`; helpers dedupe by name from a single source of truth.

Pipelines cache on a structure hash covering the op-set and its order but
not the values, so dragging a slider uploads uniforms and reuses the
compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one
pipeline compiled across ten slider positions.

The UI is generated, not written. EditGraph::capabilities() reports
parameters with their kinds, ranges, defaults and current values; the
panel builds one control per entry chosen by ParamKind. No file in ui/
names an operation, and dr-pipeline has no wgpu dependency, so codegen is
testable without a device (ARCH §6.5a).

Three defects found against real files, each silent:

- rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer
  populated. The live matrices are in `color_matrix`, keyed by
  illuminant. Reading the old field yields no colour transform at all.
- `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it
  divides each of four rows by its own sum, and the unused fourth
  (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it.
  `wb_coeffs[3]` is NaN for the same reason and is normalised at decode.
- As-shot white balance reached the uniform block but no shader read it,
  so the first render of a real CR2 came out violently green. Green
  photosites collect roughly twice the signal of red and blue. Now
  applied unconditionally before any operation, with tests on ordering.

Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected
interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves
visible zippering on any high-contrast edge at 1:1. Two of the four
packed CFA constants were wrong on the first attempt, so all four layouts
are asserted to reconstruct the same colour. Crop origins at odd
coordinates re-phase the pattern; without that, red and blue swap.

X-Trans reports GpuError::UnsupportedCfa rather than approximating with
the Bayer path, which would look like a corrupt file.

206 tests, including GPU tests proving every operation and the full
seven-operation chain generate compilable WGSL.

Known gaps: the display path still reads back to the CPU each frame,
which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the
`readback` feature and waits on spike S1 wiring Slint's texture import.
Curve shapes are a first draft and want tuning against real photographs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:37:58 +02:00
co-authored by Claude Opus 5
parent cc1c5c892d
commit 78e3e6b846
29 changed files with 5865 additions and 68 deletions
+564
View File
@@ -0,0 +1,564 @@
//! 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};
/// 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] {
&[]
}
}
/// 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;
/// 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.
pub fn compose(ops: &[Box<dyn Operation>]) -> 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 as_shot_wb: vec4<f32>,\n",
);
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
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());
}
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>;
{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));
}}
@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;
}}
// Source is the demosaiced image: linear, scene-referred, camera space.
let src_dims = textureDimensions(source);
let coord = vec2<i32>(
i32(gid.x * src_dims.x / dims.x),
i32(gid.y * src_dims.y / dims.y),
);
var c = textureLoad(source, coord, 0).rgb;
// 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.
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.
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()
);
let structure_hash = hash_structure(&active);
ComposedShader {
source,
uniforms: uniform_values,
structure_hash,
}
}
/// 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.
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;
while i < src.len() {
if 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(),
BASE_UNIFORM_FIELDS,
"it must contribute no uniforms either"
);
}
#[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[BASE_UNIFORM_FIELDS], 1.5);
assert_eq!(shader.uniforms[BASE_UNIFORM_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_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"));
}
}