Files
DarkRoom/core/dr-pipeline/src/lib.rs
T
dtourolle 0007fa459f Develop a linear DNG from windows and reduced copies of it
DemosaicedImage::linear_rgb16_window uploads part of a linear DNG, or a
box-reduced copy of it, and says where it sits in the frame; size() now
reports the frame and texture_size() the texels, and the fused pass
writes the window into the shader's uniforms. EditGraph::source_region
finds the part of the source a view reads, and tiles::plan cuts a render
too large for one texture into halo-grown, grid-aligned tiles.

The GPU test renders frames a tile at a time from their own windows and
compares them with the whole: identical for point operations, within one
code value when straightened with clarity on.
2026-09-27 17:35:16 -04:00

401 lines
15 KiB
Rust

//! TRACES: R3
//! The develop pipeline — operations, descriptors, and shader composition.
//!
//! # What this crate is
//!
//! An operation here is three things: a **descriptor** saying what its
//! parameters are, a **WGSL fragment** saying what it does to a colour, and
//! the **state** of its current settings. It is not a shader, not a pipeline,
//! and not a control — those belong to `dr-gpu` and `dr-ui` respectively.
//!
//! That split is what lets this crate have no GPU dependency at all: the
//! generated WGSL is a string, and everything about it can be tested without
//! a device (ARCH §6.5a).
//!
//! # Composable shaders
//!
//! The interesting property. Operations are separate in Rust but *fused* on
//! the GPU: [`operation::compose`] concatenates the enabled operations'
//! fragments into one compute shader, so an edit with three active
//! adjustments runs as one dispatch with one texture read and one write.
//!
//! An operation at neutral settings contributes nothing — no code, no
//! uniform, no branch. The shader for a given op-set compiles once and is
//! cached by [`operation::ComposedShader::structure_hash`], which covers the
//! operations and their order but not their values; moving a slider uploads
//! uniforms and reuses the pipeline.
//!
//! # Where this sits
//!
//! Input is the demosaiced texture from `dr-gpu`: linear, scene-referred,
//! camera colour space. Working in linear light is what makes exposure a
//! single multiply and white balance a per-channel scale; on gamma-encoded
//! data neither would be physically meaningful (ARCH §5.2).
pub mod bundled;
pub mod coverage;
pub mod declared;
pub mod descriptor;
pub mod detail;
pub mod framing;
pub mod graph;
pub mod history;
pub mod lens;
pub mod mask;
pub mod neutral;
pub mod operation;
pub mod ops;
pub mod orphan;
pub mod preset;
pub mod sidecar;
pub mod spot;
pub mod state;
pub mod tiles;
pub mod view;
pub use coverage::Coverage;
pub use declared::{Declaration, DeclaredOp};
pub use descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
Presentation, Scale, Unit, WidgetDemand, WidgetKind,
};
pub use detail::{
compose_detail, ComposedDetail, ComposedDetailPass, DetailPass, DetailStage, RenderScale,
};
pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability};
pub use history::{Edit, Entry as HistoryEntry, History, Step};
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
OutputMode, Stage, Uniform, CLIP_ONSET, RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
SOURCE_WINDOW_UNIFORM_FIELDS, SOURCE_WINDOW_UNIFORM_OFFSET, WHOLE_SOURCE_WINDOW,
};
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Reach, Scope};
pub use sidecar::{Sidecar, Version};
pub use spot::{Spot, SpotMode, SpotSet};
pub use state::{EditState, FilmRebake, FilmRef};
#[cfg(test)]
mod tests {
use super::*;
/// Every operation in the default chain, activated.
///
/// Parameters are moved away from their defaults by differing amounts,
/// scaled by position. A uniform nudge is not enough: the tone curve's
/// neutral is a *relationship* between its parameters rather than a set
/// of values, so shifting every point by the same amount slides it along
/// the identity diagonal and leaves the operation correctly inactive.
/// Varying the step breaks that symmetry, as any real edit would.
fn fully_active() -> EditGraph {
let mut g = EditGraph::default_chain();
for desc in g.descriptors() {
for (i, p) in desc.params.iter().enumerate() {
let v = match &p.kind {
ParamKind::Scalar { min, max, .. } => {
// A fraction that differs per parameter, so no two
// move in lockstep.
let fraction = 0.15 + 0.05 * (i % 4) as f32;
let step = (max - min) * fraction;
if p.default + step <= *max {
p.default + step
} else {
p.default - step
}
}
ParamKind::Bool => 1.0 - p.default,
// The last variant, so the choice differs from the
// default whatever the list holds. A single-variant enum
// cannot be moved off its default and is correctly left
// where it is.
ParamKind::Enum { variants } => variants.len().saturating_sub(1) as f32,
};
g.set_param(desc.id, p.id, v);
}
}
// `film_sim` is the one node a moved parameter cannot activate: it
// needs a stock's measured tables, which are not parameters and which
// no slider produces. So it is loaded explicitly here.
//
// This is the single per-node step in an otherwise generic helper, and
// it is deliberate rather than an oversight: a node that carries
// measurements is a real second kind of node, and pretending otherwise
// would mean silently leaving it out of every test that uses this.
g.set_film(Some(crate::graph::Film {
stock: "test_stock".into(),
print: None,
tables: 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]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [3.0; 3],
grain_uniformity: 0.97,
},
}));
g
}
#[test]
fn every_operation_can_be_activated_together() {
let g = fully_active();
assert!(!g.is_neutral());
let shader = g.compose();
// The chain has two kinds of operation in it and they arrive in
// different places: a point operation is a block in the fused shader,
// while a neighbourhood operation is a pass of the detail chain and
// contributes no fused block at all — it reads pixels it is not
// writing, and a fused fragment is handed a colour with no coordinate.
//
// So the assertion is that each operation reaches exactly one of the
// two, checked against the chain rather than a literal, and phrased so
// that adding either kind extends it without an edit here. The XOR is
// the point: a plain count of fused blocks cannot tell "moved to the
// detail stage" from "vanished from both", and this test has now been
// broken three times by exactly that ambiguity.
//
// Composed at 1:1 deliberately. An acutance operation's radius is in
// source pixels, so on a proxy it may honestly decline to draw at all
// (`RenderScale::resolves`) — which would put it in neither half and
// make the assertion fail for a reason that is not a defect.
let detail = g.compose_detail((4000, 3000), (4000, 3000));
let mut fused_blocks = 0;
for desc in g.descriptors() {
let id = desc.id.0;
// The film is loaded here, and a stock is a rendering: the view
// transform it replaces is correctly in neither stage (FR-DEV-3f,
// FR-DEV-3j).
if id == crate::ops::view_transform::ID.0 {
assert!(!shader.source.contains("---- view_transform ----"));
continue;
}
// A view operation is in the view pass when a detail stage
// follows, which it does here (D19).
let block = format!("---- {id} ----");
let point = shader.source.contains(&block)
|| shader
.view
.as_ref()
.is_some_and(|v| v.source.contains(&block));
let neighbourhood = detail
.passes
.iter()
.any(|p| p.label.starts_with(&format!("{id}/")));
assert!(
point ^ neighbourhood,
"{id} reaches {} of the two stages; an active operation \
belongs to exactly one",
if point { "both" } else { "neither" }
);
fused_blocks += usize::from(point);
}
let view_blocks = shader
.view
.as_ref()
.map_or(0, |v| v.source.matches("---- ").count());
assert_eq!(
shader.source.matches("---- ").count() + view_blocks,
fused_blocks,
"the fused shader carries a block nothing in the chain asked for"
);
}
#[test]
fn the_full_chain_generates_a_well_formed_uniform_block() {
let shader = fully_active().compose();
assert_eq!(
shader.uniforms.len() % 4,
0,
"the uniform block must be 16-byte aligned"
);
assert!(shader.uniforms.iter().all(|v| v.is_finite()));
}
#[test]
fn no_operation_declares_a_duplicate_id() {
// Two operations sharing an id would collide in the generated
// uniform struct and produce a shader that does not compile.
let g = EditGraph::default_chain();
let mut ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
let before = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(before, ids.len(), "operation ids must be unique");
}
#[test]
fn no_operation_declares_a_duplicate_parameter() {
for desc in EditGraph::default_chain().descriptors() {
let mut ids: Vec<&str> = desc.params.iter().map(|p| p.id.0).collect();
let before = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(before, ids.len(), "{} has a duplicate parameter", desc.id);
}
}
#[test]
fn every_default_is_within_its_declared_range() {
// A default outside its own range would mean a fresh image opens
// with a value the UI cannot represent.
for desc in EditGraph::default_chain().descriptors() {
for p in &desc.params {
assert_eq!(
p.clamp(p.default),
p.default,
"{}.{} default {} is outside its range",
desc.id,
p.id,
p.default
);
}
}
}
#[test]
fn defaults_leave_every_operation_inactive() {
// The invariant behind "opening an image shows the image": every
// operation must read its own default as neutral.
let g = EditGraph::default_chain();
for desc in g.descriptors() {
for p in &desc.params {
assert_eq!(
g.param(desc.id, p.id),
Some(p.default),
"{}.{} does not start at its default",
desc.id,
p.id
);
}
}
assert!(g.is_neutral());
}
#[test]
fn generated_uniform_names_are_valid_wgsl_identifiers() {
let shader = fully_active().compose();
// Only the `struct Params` block. Scanning the whole source picks up
// helper *signatures* such as `fn contrast_curve(x: f32, ...)`, whose
// parameters are not uniform declarations at all.
let body = shader
.source
.split_once("struct Params {")
.expect("a uniform struct is always generated")
.1
.split_once('}')
.expect("the struct is closed")
.0;
let mut checked = 0;
for line in body.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
let Some((name, _)) = trimmed.split_once(':') else {
continue;
};
let name = name.trim();
assert!(
!name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.starts_with(|c: char| c.is_ascii_digit()),
"{name} is not a valid WGSL identifier"
);
checked += 1;
}
assert!(checked > 0, "the struct should declare fields");
}
#[test]
fn no_fragment_declares_a_wgsl_reserved_keyword() {
// Caught the hard way: `let target = ...` in the contrast fragment
// failed to compile with "name `target` is a reserved keyword", and
// the error pointed at generated source rather than at the operation
// that wrote it. Checking here names the culprit directly.
//
// Not the full reserved list — the ones a colour operation would
// plausibly reach for.
const RESERVED: &[&str] = &[
"target",
"sample",
"filter",
"texture",
"buffer",
"binding",
"const",
"enum",
"mat",
"vec",
"ptr",
"ref",
"shared",
"static",
"typedef",
"union",
"unless",
"handle",
"layout",
"packed",
"premerge",
"regardless",
"typedef",
"active",
"do",
"enum",
"input",
"output",
"private",
"resource",
"restrict",
"self",
"std",
"where",
];
let g = EditGraph::default_chain();
for cap in g.capabilities() {
// Activate the whole operation so its fragment is emitted.
let mut probe = EditGraph::default_chain();
for p in &cap.params {
if let ParamKind::Scalar { max, .. } = p.kind {
probe.set_param(cap.id, p.id, max * 0.5);
}
}
let source = probe.compose().source;
for keyword in RESERVED {
let declaration = format!("let {keyword} ");
let var_declaration = format!("var {keyword} ");
assert!(
!source.contains(&declaration) && !source.contains(&var_declaration),
"{} declares `{keyword}`, which is a WGSL reserved keyword",
cap.id
);
}
}
}
#[test]
fn a_full_chain_declares_each_helper_once() {
// Six of the seven operations want `luminance`. A duplicate function
// definition fails to compile, so this is the property that keeps
// helper sharing safe as operations are added.
let source = fully_active().compose().source;
for helper in ["luminance", "tone_position", "colour_saturation"] {
let count = source.matches(&format!("fn {helper}(")).count();
assert!(count <= 1, "{helper} declared {count} times");
}
}
}