Sampling the overcast sky on a Canon 6D frame set tint to -100 and temperature to -15 for a patch the canvas showed as pure white. A clipped photosite is sensor white, not a colour: every channel stopped counting, so what the tap hands back is the as-shot multipliers themselves, which are strongly magenta, and the solver dutifully drove green to its stop. The display shader already fades such a pixel to a neutral of the same brightness before any operation runs, so the picker was balancing against something the photographer could not see. The probe now refuses a sample with any channel at or above the onset the shader fades from, the way the solver already refuses black. The threshold is one constant, CLIP_ONSET, formatted into the shader and read by the probe, so the two cannot drift apart.
378 lines
14 KiB
Rust
378 lines
14 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 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 preset;
|
|
pub mod sidecar;
|
|
pub mod spot;
|
|
pub mod starter;
|
|
pub mod state;
|
|
|
|
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, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
|
|
RESERVED_UNIFORM_FIELDS,
|
|
};
|
|
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, 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],
|
|
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;
|
|
let point = shader.source.contains(&format!("---- {id} ----"));
|
|
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);
|
|
}
|
|
assert_eq!(
|
|
shader.source.matches("---- ").count(),
|
|
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");
|
|
}
|
|
}
|
|
}
|