Merge branch 'colour-managed-export'

This commit is contained in:
2026-08-17 09:28:22 +02:00
11 changed files with 1672 additions and 66 deletions
+17 -2
View File
@@ -285,9 +285,24 @@ impl EditGraph {
!self.ops.iter().any(|o| o.is_active()) && !self.framing.edits_image()
}
/// Generate the fused shader for the current state.
/// Generate the fused shader for the current state, encoded to sRGB.
///
/// What the display path wants. An export that has been asked for a wider
/// space wants [`Self::compose_for`] instead, and must say so: the space
/// is baked into the shader, so a frame rendered by this one is sRGB and
/// nothing downstream can make it anything else.
pub fn compose(&self) -> ComposedShader {
compose_with_framing(&self.ops, &self.framing)
self.compose_for(dr_types::ColourSpace::Srgb)
}
/// TRACES: FR-EXP-2
/// Generate the fused shader, encoded to a chosen output space.
///
/// Not stored on the graph, because it is not part of the edit: the same
/// graph renders to the screen and to a file in the same breath, and the
/// two want different answers.
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader {
compose_with_framing(&self.ops, &self.framing, output)
}
}
+208 -19
View File
@@ -22,6 +22,8 @@
use std::fmt::Write as _;
use dr_types::{ColourSpace, Transfer};
use crate::descriptor::{OpDescriptor, ParamId, Presentation};
use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS};
@@ -152,23 +154,41 @@ const BASE_UNIFORM_FIELDS: usize = 16;
/// 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.
/// 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.
/// 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())
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.
pub fn compose_with_framing(ops: &[Box<dyn Operation>], framing: &Framing) -> ComposedShader {
///
/// # 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 {
let active: Vec<&dyn Operation> = ops
.iter()
.map(|o| o.as_ref())
@@ -271,6 +291,9 @@ pub fn compose_with_framing(ops: &[Box<dyn Operation>], framing: &Framing) -> Co
""
};
let to_output = primaries_conversion(output);
let encode_output = encode_output_fn(output);
let source = format!(
"// GENERATED — do not edit.
//
@@ -286,17 +309,8 @@ struct Params {{
@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.
{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
@@ -345,10 +359,12 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
dot(u.cam_to_srgb_1.rgb, c),
dot(u.cam_to_srgb_2.rgb, c),
);
// Clip to the display gamut and encode.
{to_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_srgb(c), 1.0));
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(encode_output(c), 1.0));
}}
",
active.len()
@@ -357,7 +373,15 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
// 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());
//
// The output space enters it too, and must: it changes the source, so two
// spaces sharing a hash would have the second silently rendered with the
// first's shader — a Display P3 export that came out sRGB and said
// otherwise.
let structure_hash = mix(
mix(hash_structure(&active), framing.structure_key()),
output as u64,
);
ComposedShader {
source,
@@ -366,6 +390,87 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
}
}
/// 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.
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.
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
@@ -766,6 +871,90 @@ mod tests {
assert!(op < matrix, "the camera matrix must come after operations");
}
/// 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, 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, 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, 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