Tell the shader which colour space it is encoding for
The generated shader ended with `encode_srgb` and a clamp, so every photograph leaving DarkRoom had been through sRGB's gamut whatever the settings page said. Export refused the other three spaces rather than tag clipped pixels with a gamut they did not contain — correct, and not something an encoder could fix. So the output space becomes a parameter of composition. `compose_for` emits a constant primaries matrix after the camera matrix and before the clip, and generates the transfer function to match: the sRGB curve for sRGB and Display P3, a pure 2.199 gamma for Adobe RGB, 1.8 with a linear toe for ProPhoto. The ordering the camera matrix depends on is untouched — operations still run in camera space — and sRGB emits no conversion at all, so the shader compiled on nearly every frame is byte-for-byte what it was. The numbers live in dr-types, derived from four chromaticity pairs per space rather than tabulated. That is not tidiness: the shader encodes the pixels and the ICC profile describes them, and a file whose profile disagrees with its own contents is worse than one with no profile. One derivation makes them agree by construction, and can be checked against the values the specifications publish. Profiles are generated here too — minimal v2 matrix/TRC, about 2 KB, pure Rust, no lcms to satisfy under the NDK. A JPEG carries it in APP2, a PNG in iCCP, a TIFF in tag 34675. sRGB gets one as well, because untagged does not mean sRGB, it means guess. The refusal survives in a sharper form. A `Frame` now carries the space it was rendered in, and export refuses to label it anything else. The develop session still composes for sRGB, so a P3 export from the interface fails with an accurate error instead of producing a file that lies — the frontend half is a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user