Merge branch 'colour-managed-export'
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