diff --git a/contributors/people.toml b/contributors/people.toml index f6cc761bfc..b3cab8e04f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -188,3 +188,6 @@ sync_github_name = true [people.fjamesprice] sync_github_name = true + +[people.ha1des] +sync_github_name = true diff --git a/crates/cms/README.md b/crates/cms/README.md index c48d5366fd..70b4ab3c9c 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -20,6 +20,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou * `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain. * `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning). * `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture. +* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for + display shaders. Float tables retain near-black detail; unsupported chains return `None` + for the 3D LUT fallback. No GPU dependency is required by this crate. * `transform::cached(src, dst, opts)`: process-wide transform cache. * `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding. * `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of @@ -36,6 +39,10 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon (native only). +* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus + offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated + separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB + 33³ table cannot resolve steep inverse gamma curves near black, even with float texels. * Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero, negative or non-finite white is refused with `CmsError::Invalid`); BPC uses diff --git a/crates/cms/src/gpu_matrix_shaper.rs b/crates/cms/src/gpu_matrix_shaper.rs new file mode 100644 index 0000000000..09631bf98d --- /dev/null +++ b/crates/cms/src/gpu_matrix_shaper.rs @@ -0,0 +1,131 @@ +//! Separable RGB display transforms without a coarse 3D interpolation near black. + +/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display +/// transform. Values are floats independent of the document's pixel depth. +/// +/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply +/// [`Self::matrix_rows`] to that result, then sample the output table at +/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel +/// separately between its two adjacent table entries. These nonuniform output samples +/// retain shadow detail where a pure gamma curve's inverse has an infinite slope. +/// +/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels. +/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable. +/// This representation covers SDR display output; input and output are clamped to `[0, 1]`. +/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the +/// [`crate::Lut3d`] fallback. +#[derive(Clone, Debug, PartialEq)] +pub struct GpuMatrixShaper { + matrix: [[f32; 4]; 3], + input: Vec<[f32; 4]>, + output: Vec<[f32; 4]>, +} + +impl GpuMatrixShaper { + /// The input table has one more entry than this number of equal intervals. + pub const INPUT_INTERVALS: usize = 4096; + /// Output entries are spaced uniformly in the fourth root of the linear value. + pub const OUTPUT_ENTRIES: usize = 4096; + + /// Row-major matrix: each row contains three RGB coefficients followed by its offset. + pub fn matrix_rows(&self) -> [[f32; 4]; 3] { + self.matrix + } + + /// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1. + pub fn input_table(&self) -> &[[f32; 4]] { + &self.input + } + + /// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1. + pub fn output_table(&self) -> &[[f32; 4]] { + &self.output + } + + pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec], output: Option<&[Vec]>) -> Option { + if !matrix.iter().flatten().all(|v| v.is_finite()) { + return None; + } + let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?; + let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?; + let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max)); + for row in matrix { + let magnitude = + row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::() + f64::from(row[3]).abs(); + if magnitude > f64::from(f32::MAX) { + return None; + } + } + Some(Self { matrix, input, output }) + } +} + +fn pack_table(tables: Option<&[Vec]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option> { + if let Some(tables) = tables + && (tables.len() != 3 || tables.iter().any(|t| t.len() != entries)) + { + return None; + } + let mut packed: Vec<[f32; 4]> = Vec::new(); + // Both lengths are fixed constants, but a failed allocation still means the caller + // should retain its existing fallback rather than abort while opening a document. + packed.try_reserve_exact(entries).ok()?; + for i in 0..entries { + let rgb = match tables { + Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?], + None => [identity(i as f32 / (entries - 1) as f32); 3], + }; + if !rgb.iter().all(|v| v.is_finite()) { + return None; + } + if let Some(previous) = packed.last() + && rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite()) + { + return None; + } + packed.push([rgb[0], rgb[1], rgb[2], 1.0]); + } + Some(packed) +} + +#[cfg(test)] +mod tests { + use super::*; + + const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; + + #[test] + fn rejects_invalid_tables_and_nonfinite_values() { + let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3]; + let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3]; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some()); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none()); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none()); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none()); + let mut invalid = inputs.clone(); + invalid[0].pop(); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none()); + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut invalid = inputs.clone(); + invalid[1][123] = v; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none()); + let mut invalid = outputs.clone(); + invalid[2][321] = v; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none()); + let mut invalid = IDENTITY; + invalid[1][3] = v; + assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none()); + } + } + + #[test] + fn rejects_finite_data_that_overflows_shader_arithmetic() { + let mut matrix = IDENTITY; + matrix[0] = [f32::MAX; 4]; + assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none()); + let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3]; + inputs[0][100] = -f32::MAX; + inputs[0][101] = f32::MAX; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none()); + } +} diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs index f93b00372b..259d51bf77 100644 --- a/crates/cms/src/lib.rs +++ b/crates/cms/src/lib.rs @@ -8,6 +8,7 @@ //! * [`builtin`] ships sRGB, Display P3, Adobe RGB (1998)-compatible, ProPhoto-compatible, //! linear sRGB, Rec. 2020, Gray 2.2, sGray, Lab D50 and a synthetic coated CMYK profile. //! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas. +//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms. //! //! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications, //! CIE 15, and Adobe's published black point compensation paper. This crate depends on no @@ -19,6 +20,7 @@ pub mod builtin; pub mod clut; pub mod curve; pub mod gamut; +mod gpu_matrix_shaper; pub mod lut3d; pub mod lutfile; pub mod math; @@ -32,6 +34,7 @@ pub use builtin::Builtin; pub use clut::Clut; pub use curve::Curve; pub use gamut::GamutCheck; +pub use gpu_matrix_shaper::GpuMatrixShaper; pub use lut3d::Lut3d; pub use profile::{ColorSpace, Pcs, Profile, ProfileClass}; pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached}; diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs index d262956a78..04d11247f4 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -9,7 +9,7 @@ use crate::curve::Curve; use crate::math; use crate::pipeline::{Pipeline, Stage}; use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass}; -use crate::{CmsError, Intent}; +use crate::{CmsError, GpuMatrixShaper, Intent}; /// Sample storage of a byte buffer for [`Transform::convert_bytes_many`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -39,9 +39,9 @@ impl Default for TransformOptions { /// Grid sizes of the device-link tables built for integer (and fast float) evaluation. pub const GRID_3D: usize = 33; pub const GRID_4D: usize = 17; -const TABLE_1D: usize = 4096; +const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS; /// Resolution of output shaper tables (indexed by `x^(1/4)`). -const OUT_TABLE: usize = 4096; +const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES; /// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ. const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287]; @@ -226,6 +226,31 @@ impl Transform { &self.pipeline } + /// A separable RGB transform for accurate GPU display conversion, when available. + /// + /// Reuses the input/matrix/output acceleration of the integer conversion path instead + /// of interpolating final encoded RGB over a coarse 3D grid. Returns `None` for other + /// channel counts, CLUTs, proofing stages, or invalid/unavailable table data, so callers + /// can keep their existing display-LUT fallback. This is an SDR representation; see + /// [`GpuMatrixShaper`] for the precise sampling and clamping contract. + pub fn gpu_matrix_shaper(&self) -> Option { + if self.inputs != 3 || self.outputs != 3 { + return None; + } + let Core::Shaper { curves, matrix, .. } = &self.core else { return None }; + let mut rows = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; + if let Some((nr, nc, coefficients, offsets)) = matrix { + if *nr != 3 || *nc != 3 || coefficients.len() != 9 || offsets.len() != 3 { + return None; + } + for (row, (coefficients, offset)) in rows.iter_mut().zip(coefficients.as_chunks::<3>().0.iter().zip(offsets)) { + let [r, g, b] = coefficients; + *row = [*r, *g, *b, *offset]; + } + } + GpuMatrixShaper::from_parts(rows, curves, self.post.as_ref().map(|p| p.tables.as_slice())) + } + /// Exact evaluation of one colour (`inputs` → `outputs` normalised values). #[inline] pub fn eval(&self, input: &[f32], out: &mut [f32]) { diff --git a/crates/cms/tests/gpu_matrix_shaper.rs b/crates/cms/tests/gpu_matrix_shaper.rs new file mode 100644 index 0000000000..20f05e8aa1 --- /dev/null +++ b/crates/cms/tests/gpu_matrix_shaper.rs @@ -0,0 +1,197 @@ +//! Independent ICC matrix/TRC reference for display shadows. +//! +//! Fixtures are generated here from the published sRGB equation and a synthetic monitor, +//! with no photograph or device profile. The expected values implement ICC.1:2010 Annex F.3 +//! directly in f64, without Transform, Curve evaluation, or photocraft_cms::math: +//! https://www.color.org/specification/ICC1v43_2010-12.pdf + +use photocraft_cms::pipeline::{Pipeline, Stage}; +use photocraft_cms::{Builtin, Clut, Curve, GpuMatrixShaper, Intent, Lut3d, Profile, Transform, TransformOptions}; + +const SOURCE_MATRIX: [[f64; 3]; 3] = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]]; +// Deliberately synthetic, D50-referenced wider-gamut monitor; not a user's measured profile. +const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.55, 0.27, 0.1442], [0.25, 0.69, 0.06], [0.01, 0.06, 0.7549]]; +const GAMMA: f64 = 2.2; +const SOURCE_ENTRIES: usize = 1024; +const DISPLAY_TOLERANCE: f64 = 1.0 / 255.0; + +fn fixed16(x: f64) -> f64 { + (x * 65536.0).round() / 65536.0 +} + +fn srgb_sample(i: usize) -> f64 { + let x = i as f64 / (SOURCE_ENTRIES - 1) as f64; + let linear = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) }; + (linear * 65535.0).round() / 65535.0 +} + +fn synthetic_profiles() -> (Profile, Profile) { + let mut source = Builtin::Srgb.profile().clone(); + source.description = "Synthetic sampled sRGB for display regression".into(); + source.matrix = Some(SOURCE_MATRIX); + source.trc = Some(std::array::from_fn(|_| Curve::Table((0..SOURCE_ENTRIES).map(|i| srgb_sample(i) as f32).collect()))); + let source = source.with_encoded_bytes(); + let mut monitor = Builtin::Srgb.profile().clone(); + monitor.description = "Synthetic gamma 2.2 display for regression".into(); + monitor.matrix = Some(MONITOR_MATRIX); + monitor.trc = Some(std::array::from_fn(|_| Curve::Parametric { kind: 0, p: [GAMMA, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] })); + let monitor = monitor.with_encoded_bytes(); + // Exercise actual ICC serialization/parsing, including fixed-point matrix/TRC values. + (Profile::parse(&source.to_bytes()).unwrap(), Profile::parse(&monitor.to_bytes()).unwrap()) +} + +fn synthetic_transform(bpc: bool) -> Transform { + let (source, monitor) = synthetic_profiles(); + Transform::new(&source, &monitor, Intent::RelativeColorimetric, bpc).unwrap() +} + +fn reference(rgb: [f32; 3]) -> [f64; 3] { + let linear = rgb.map(|x| { + let p = f64::from(x.clamp(0.0, 1.0)) * (SOURCE_ENTRIES - 1) as f64; + let i = (p as usize).min(SOURCE_ENTRIES - 2); + srgb_sample(i) + (srgb_sample(i + 1) - srgb_sample(i)) * (p - i as f64) + }); + let xyz = SOURCE_MATRIX.map(|row| row.iter().zip(linear).map(|(m, x)| fixed16(*m) * x).sum::()); + // Solve M_monitor * linear_monitor = XYZ using elimination, rather than the production + // matrix inverse/multiplication implementation. + let mut a: [[f64; 4]; 3] = std::array::from_fn(|r| [fixed16(MONITOR_MATRIX[r][0]), fixed16(MONITOR_MATRIX[r][1]), fixed16(MONITOR_MATRIX[r][2]), xyz[r]]); + for pivot in 0..3 { + let divisor = a[pivot][pivot]; + for value in &mut a[pivot][pivot..] { + *value /= divisor; + } + let normalized = a[pivot]; + for (r, row) in a.iter_mut().enumerate() { + if r != pivot { + let factor = row[pivot]; + for (value, coefficient) in row.iter_mut().zip(normalized).skip(pivot) { + *value -= factor * coefficient; + } + } + } + } + std::array::from_fn(|c| a[c][3].clamp(0.0, 1.0).powf(1.0 / fixed16(GAMMA))) +} + +/// CPU interpretation of the public GPU data layout; expected colours come only from +/// `reference`, so this tests the sampled representation, not Transform against itself. +fn sample_gpu(gpu: &GpuMatrixShaper, rgb: [f32; 3]) -> [f32; 3] { + fn interpolate(table: &[[f32; 4]], channel: usize, coordinate: f32) -> f32 { + let i = (coordinate as usize).min(table.len() - 2); + let f = coordinate - i as f32; + table[i][channel] + (table[i + 1][channel] - table[i][channel]) * f + } + let linear: [f32; 3] = std::array::from_fn(|c| interpolate(gpu.input_table(), c, rgb[c].clamp(0.0, 1.0) * GpuMatrixShaper::INPUT_INTERVALS as f32)); + let rows = gpu.matrix_rows(); + std::array::from_fn(|c| { + let row = rows[c]; + let value = row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2] + row[3]; + let coordinate = value.clamp(0.0, 1.0).sqrt().sqrt() * (GpuMatrixShaper::OUTPUT_ENTRIES - 1) as f32; + interpolate(gpu.output_table(), c, coordinate).clamp(0.0, 1.0) + }) +} + +fn check_reference(gpu: &GpuMatrixShaper, rgb: [f32; 3]) { + let actual = sample_gpu(gpu, rgb); + let expected = reference(rgb); + for c in 0..3 { + assert!((f64::from(actual[c]) - expected[c]).abs() <= DISPLAY_TOLERANCE, "input {rgb:?}, channel {c}: GPU {actual:?}, ICC {expected:?}"); + } +} + +#[test] +fn sampled_srgb_to_gamma_display_preserves_neutral_and_colored_shadows() { + for bpc in [false, true] { + let transform = synthetic_transform(bpc); + let gpu = transform.gpu_matrix_shaper().unwrap(); + assert_eq!(gpu.input_table().len(), GpuMatrixShaper::INPUT_INTERVALS + 1); + assert_eq!(gpu.output_table().len(), GpuMatrixShaper::OUTPUT_ENTRIES); + assert!(gpu.input_table().iter().chain(gpu.output_table()).all(|v| v[3] == 1.0)); + for v in 0..=255 { + check_reference(&gpu, [v as f32 / 255.0; 3]); + } + // Includes coloured shadows within the old LUT's very first 0..8/255 cell. + for r in 0..=16 { + for g in 0..=16 { + for b in 0..=16 { + check_reference(&gpu, [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]); + } + } + } + for rgb in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.9, 0.6, 0.3]] { + check_reference(&gpu, rgb); + } + } +} + +#[test] +fn coarse_final_rgb_lut_fails_the_same_icc_shadow_reference() { + let transform = synthetic_transform(false); + let gpu = transform.gpu_matrix_shaper().unwrap(); + let coarse = Lut3d::from_transform(&transform, 33); + let rgb = [2.0 / 255.0; 3]; + let expected = reference(rgb); + let old = coarse.sample(rgb); + // Even float LUT nodes fail, before the additional rounding of RGBA8 upload. This + // guards the cause of the original bug rather than a particular observed screenshot. + assert!(old.iter().zip(expected).all(|(a, e)| e - f64::from(*a) > 2.0 / 255.0), "old {old:?}, reference {expected:?}"); + check_reference(&gpu, rgb); +} + +#[test] +fn gpu_display_representation_accepts_u8_u16_and_float_samples() { + let transform = synthetic_transform(false); + let gpu = transform.gpu_matrix_shaper().unwrap(); + for v in [0_u8, 1, 2, 7, 8, 16, 32, 128, 255] { + check_reference(&gpu, [v as f32 / 255.0; 3]); + } + for v in [0_u16, 1, 2, 15, 63, 257, 514, 1028, 4096, 32768, 65535] { + check_reference(&gpu, [v as f32 / 65535.0; 3]); + } + for rgb in [[0.000003, 0.00001, 0.00002], [0.00123, 0.00456, 0.00789], [0.2, 0.45, 0.8], [-0.5, 0.0, 2.0]] { + check_reference(&gpu, rgb); + } + assert_eq!(sample_gpu(&gpu, [0.0; 3]), [0.0; 3]); + // White differs very slightly from 1 because the synthetic matrices use ICC s15Fixed16. + check_reference(&gpu, [1.0; 3]); +} + +#[test] +fn absent_curves_and_matrix_use_identity_without_losing_offsets() { + let identity = Transform::from_pipeline(Pipeline::new(3, Vec::new()), TransformOptions::default()).gpu_matrix_shaper().unwrap(); + for rgb in [[0.0; 3], [1.0; 3], [0.001, 0.2, 0.8], [-2.0, 0.5, 2.0]] { + for (actual, input) in sample_gpu(&identity, rgb).into_iter().zip(rgb) { + assert!((actual - input.clamp(0.0, 1.0)).abs() < 1e-6); + } + } + let matrix = Stage::Matrix { rows: 3, cols: 3, m: vec![0.8, 0.0, 0.0, 0.0, 0.7, 0.0, 0.0, 0.0, 0.6], offset: vec![0.01, 0.02, 0.03] }; + let transform = Transform::from_pipeline(Pipeline::new(3, vec![matrix]), TransformOptions::default()).gpu_matrix_shaper().unwrap(); + let input = [0.1, 0.2, 0.3]; + let output = sample_gpu(&transform, input); + for (actual, expected) in output.into_iter().zip([0.09, 0.16, 0.21]) { + assert!((actual - expected).abs() < 1e-6, "{output:?}"); + } +} + +#[test] +fn unsupported_clut_proof_and_non_rgb_transforms_retain_fallback() { + let identity_clut = Clut::sample(vec![2; 3], 3, |input, output| output.copy_from_slice(input)); + let clut = Transform::from_pipeline(Pipeline::new(3, vec![Stage::Clut(identity_clut)]), TransformOptions::default()); + assert!(clut.gpu_matrix_shaper().is_none()); + let srgb = Builtin::Srgb.profile(); + let lab = Transform::new(Builtin::LabD50.profile(), srgb, Intent::RelativeColorimetric, false).unwrap(); + assert!(lab.gpu_matrix_shaper().is_none()); + let gray = Transform::new(Builtin::GrayGamma22.profile(), srgb, Intent::RelativeColorimetric, false).unwrap(); + assert!(gray.gpu_matrix_shaper().is_none()); + let proof = Transform::proof(srgb, Builtin::DisplayP3.profile(), srgb, Intent::RelativeColorimetric, false, false).unwrap(); + assert!(proof.gpu_matrix_shaper().is_none()); +} + +#[test] +fn malformed_matrix_storage_is_not_exposed_to_the_gpu() { + for (coefficients, offsets) in [(vec![2.0; 8], vec![0.0; 3]), (vec![2.0; 9], vec![0.0; 2]), (vec![f64::INFINITY; 9], vec![0.0; 3])] { + let transform = + Transform::from_pipeline(Pipeline::new(3, vec![Stage::Matrix { rows: 3, cols: 3, m: coefficients, offset: offsets }]), TransformOptions::default()); + assert!(transform.gpu_matrix_shaper().is_none()); + } +} diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index d3a6d36931..4f3435cc7a 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -459,6 +459,21 @@ impl ColorState { self.canvas_lut_with(doc, size, false, display) } + /// Accurate GPU representation of RGB matrix/TRC display transforms. Gamut warnings, + /// proof simulations beyond profile conversion, and transforms containing a CLUT retain + /// the existing 3D LUT path. The shader applies the 32-bit preview before this transform, + /// just as it does before the display LUT. + pub fn gpu_canvas_matrix_shaper_for(&self, doc: &Document, display: Option) -> Result> { + let proof = self.proof(doc.id); + if proof.gamut_warning || (proof.enabled && proof.setup.kind != crate::proof_sim::ProofKind::Profile) { + return Ok(None); + } + if !proof.enabled && self.canvas_display_for(doc, display)?.transform.is_none() { + return Ok(None); + } + Ok(self.display_transform_for(doc, display)?.gpu_matrix_shaper()) + } + fn canvas_lut_with(&self, doc: &Document, size: usize, hdr: bool, on: Option) -> Result>> { let size = size.max(2); let pv = self.proof(doc.id); @@ -1338,6 +1353,44 @@ mod tests { assert_eq!(sh.color.mode, ColorMode::Cmyk); } + #[test] + fn gpu_matrix_shaper_keeps_special_proof_simulations_on_the_lut_path() { + let mut s = session("rgb", 8); + let d = doc(&s).clone(); + // These valid synthetic RGB profiles make the ordinary profile-proof pipeline + // separable. Thus the test cannot pass just because a typical proof happens to + // contain unsupported stages; the simulation kind must be excluded explicitly. + let mut proof = Builtin::LinearSrgb.profile().clone(); + proof.description = "Synthetic identity-matrix proof".into(); + proof.matrix = Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + proof.trc = Some([photocraft_cms::Curve::Identity, photocraft_cms::Curve::Identity, photocraft_cms::Curve::Identity]); + let proof = proof.with_encoded_bytes(); + let mut monitor = proof.clone(); + monitor.description = "Synthetic identity-matrix gamma monitor".into(); + monitor.trc = Some(std::array::from_fn(|_| photocraft_cms::Curve::Gamma(2.2))); + let monitor = monitor.with_encoded_bytes(); + s.color.set_displays(Ok(vec![crate::display_color::Display { + id: 1, + name: "Synthetic monitor".into(), + frame: [0.0, 0.0, 800.0, 600.0], + profile_name: None, + icc: Some(monitor.to_bytes()), + }])); + let pv = s.color.proof_mut(d.id); + pv.setup.profile = Arc::new(Profile::parse(&proof.to_bytes()).unwrap()); + pv.enabled = true; + assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_some(), "ordinary profile conversion is separable"); + for command in ["view.proofSetup.legacyMacintoshRgb", "view.proofSetup.colorBlindnessProtanopia", "view.proofSetup.colorBlindnessDeuteranopia"] { + // These commands retain the previously selected custom profile. + s.execute(command, json!({})).unwrap(); + assert!(s.color.display_transform_for(&d, Some(1)).unwrap().gpu_matrix_shaper().is_some(), "fixture must exercise the shortcut"); + assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_none(), "{command} must retain its simulation"); + assert!(s.color.gpu_canvas_lut_for(&d, 9, Some(1)).unwrap().is_some(), "{command} still has its LUT fallback"); + } + s.execute("view.proofColors", json!({"on": false})).unwrap(); + assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_some(), "disabled simulations do not block ordinary display conversion"); + } + #[test] fn proofing_state_and_gamut_warning() { let mut s = session("rgb", 8); diff --git a/crates/engine/src/display_color.rs b/crates/engine/src/display_color.rs index 1e4aafc909..49b36f9031 100644 --- a/crates/engine/src/display_color.rs +++ b/crates/engine/src/display_color.rs @@ -1,5 +1,5 @@ //! Colour-managed canvas display: the document's composite shown through document profile → -//! monitor profile, on the GPU canvas (folded into the display 3D LUT) and the CPU canvas +//! monitor profile, on the GPU canvas (matrix/shaper or display 3D LUT) and the CPU canvas //! (an 8-bit transform of the composite). //! //! * The composite is in [`composite_profile`] (the document profile for RGB, its gray curve as diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 184e9ccce3..99cf13fa02 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3013,7 +3013,7 @@ const DISPLAY_LUT: usize = 33; /// Keep the GPU display LUT under `key` (the document's texture or a preview of it) in step with /// `doc`'s colour management: document → monitor profile and View › Proof Colors / Gamut /// Warning (the 32-bit preview is applied by the canvas shader, see [`hdr_preview`]). Returns the canvas `display` mode (0 none — the identity, e.g. sRGB on -/// an sRGB monitor —, 1 LUT, 2 LUT + gamut warning). +/// an sRGB monitor —, 1 LUT, 2 LUT + gamut warning, 3 matrix/shaper). fn sync_display_lut(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, key: u64, display: Option) -> u8 { let Some(gpu) = app.gpu.clone() else { return 0 }; let output = display.unwrap_or(0); @@ -3024,6 +3024,13 @@ fn sync_display_lut(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, key { return mode; } + // Keep the curve/matrix/curve stages separate: interpolating a finished display + // transform on a 33³ lattice crushes shadows when the monitor curve is steep near zero. + if let Ok(Some(shaper)) = app.session.color.gpu_canvas_matrix_shaper_for(doc, display) { + gpu.set_display_matrix_shaper(key, output, &shaper); + gpu.cache_display_lut_signature(key, output, sig, 3); + return 3; + } let gamut = app.session.color.proof(doc.id).gamut_warning; let mode = match app.session.color.gpu_canvas_lut_for(doc, DISPLAY_LUT, display) { Ok(Some(bytes)) => { @@ -6863,7 +6870,7 @@ mod tests { let key = doc.id.0; // sRGB on the sRGB display needs no LUT; on the P3 display it does. assert_eq!(sync_display_lut(&mut app, &doc, key, Some(1)), 0); - assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 1); + assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 3); let gpu = app.gpu.clone().unwrap(); let (s1, s4) = (gpu.display_lut_signature(key, 1).unwrap(), gpu.display_lut_signature(key, 4).unwrap()); assert_ne!(s1.0, s4.0); @@ -6871,7 +6878,7 @@ mod tests { // Drawing one window leaves the other's LUT alone. for _ in 0..3 { assert_eq!(sync_display_lut(&mut app, &doc, key, Some(1)), 0); - assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 1); + assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 3); } assert_eq!((gpu.display_lut_signature(key, 1), gpu.display_lut_signature(key, 4)), (Some(s1), Some(s4))); assert!(gpu.has_display_lut(key, 4)); diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 0043859892..5c90ce5023 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -79,7 +79,7 @@ pub struct ViewParams { /// Distinguishes views painted in the same frame (main canvas, extra windows). pub view_key: u64, /// Display transform: 0 none, 1 the document's display LUT (document → monitor profile, - /// Proof Colors), 2 LUT plus the gamut warning (see [`GpuCanvas::set_display_lut`]). + /// Proof Colors), 2 LUT plus the gamut warning, 3 matrix/shaper (see [`GpuCanvas::set_display_lut`]). pub display: u8, /// The display the view is on (its LUT is per document and display, #569; 0 = unknown). pub output: u32, @@ -798,12 +798,25 @@ impl GpuCanvas { log::warn!("set_display_lut: {} bytes for a {size}³ LUT; ignored", bytes.len()); return; } - let bg = lut_bind_group(device, queue, &res.lut_bgl, size, bytes); + let bg = lut_bind_group(device, queue, &res.lut_bgl, size, bytes, None); res.luts.insert((doc, output), bg); } } } + /// Set a separately sampled curve/matrix/curve display transform. CMS validates this + /// representation and provides bounded tables; no profile interpretation lives in the UI. + pub fn set_display_matrix_shaper(&self, doc: u64, output: u32, shaper: &photocraft_cms::GpuMatrixShaper) { + if !self.health.is_ok() { + return; + } + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return }; + let bg = lut_bind_group(&self.rs.device, &self.rs.queue, &res.lut_bgl, 2, &identity_lut_bytes(), Some(shaper)); + res.display_lut_signatures.remove(&(doc, output)); + res.luts.insert((doc, output), bg); + } + /// Read back level 0 of document `key`'s canvas texture: its format, size and the stored /// (premultiplied) texels as `f32` RGBA, row-major. Waits for the GPU; for tests, benchmarks /// and debugging. @@ -1595,8 +1608,16 @@ impl Default for CanvasStyle { } } -/// A `size`³ RGBA8 3D texture bound for the canvas shader. -fn lut_bind_group(device: &wgpu::Device, queue: &wgpu::Queue, bgl: &wgpu::BindGroupLayout, size: u32, bytes: &[u8]) -> wgpu::BindGroup { +/// A display transform: either the existing 3D LUT or separate full-float shapers and +/// matrix. Pack curves into short rows to stay within WebGL2's texture-size limits. +fn lut_bind_group( + device: &wgpu::Device, + queue: &wgpu::Queue, + bgl: &wgpu::BindGroupLayout, + size: u32, + bytes: &[u8], + shaper: Option<&photocraft_cms::GpuMatrixShaper>, +) -> wgpu::BindGroup { let extent = wgpu::Extent3d { width: size, height: size, depth_or_array_layers: size }; let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("pc_display_lut"), @@ -1615,10 +1636,48 @@ fn lut_bind_group(device: &wgpu::Device, queue: &wgpu::Queue, bgl: &wgpu::BindGr extent, ); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let (width, height, curves, matrix) = if let Some(s) = shaper { + let width = 1024usize; + let count = s.input_table().len() + s.output_table().len(); + let height = count.div_ceil(width); + let mut curves = Vec::with_capacity(width * height * 16); + for value in s.input_table().iter().chain(s.output_table()).flatten() { + curves.extend_from_slice(&value.to_le_bytes()); + } + curves.resize(width * height * 16, 0); + (width as u32, height as u32, curves, s.matrix_rows()) + } else { + (1, 1, vec![0; 16], [[0.0; 4]; 3]) + }; + let extent = wgpu::Extent3d { width, height, depth_or_array_layers: 1 }; + let curves_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("pc_display_shapers"), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + // textureLoad + explicit interpolation needs no optional float-filtering feature. + format: wgpu::TextureFormat::Rgba32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &curves_texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + &curves, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(width * 16), rows_per_image: Some(height) }, + extent, + ); + let curves_view = curves_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let matrix_bytes: Vec = matrix.iter().flatten().flat_map(|v| v.to_le_bytes()).collect(); + let matrix_buffer = init_buffer(device, "pc_display_matrix", &matrix_bytes, wgpu::BufferUsages::UNIFORM); device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_display_lut"), layout: bgl, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }], + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&curves_view) }, + wgpu::BindGroupEntry { binding: 2, resource: matrix_buffer.as_entire_binding() }, + ], }) } @@ -1731,7 +1790,20 @@ impl Resources { }, count: None, }; - let lut_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_display_lut"), entries: &[lut_entry] }); + let curves_entry = wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }; + let lut_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pc_display_lut"), + entries: &[lut_entry, curves_entry, uniform_entry(2, 48, wgpu::ShaderStages::FRAGMENT)], + }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_canvas"), bind_group_layouts: &[Some(&view_bgl), Some(&tile_bgl), Some(&lut_bgl)], @@ -1802,7 +1874,7 @@ impl Resources { }); let encode_pipeline = pipeline(device, "pc_encode", &encode_layout, &encode_module, ("vs", "fs"), FORMAT, None); let encode_pipeline_high = high.then(|| pipeline(device, "pc_encode_16f", &encode_layout, &encode_module, ("vs", "fs"), FORMAT_HIGH, None)); - let identity_lut = lut_bind_group(device, queue, &lut_bgl, 2, &identity_lut_bytes()); + let identity_lut = lut_bind_group(device, queue, &lut_bgl, 2, &identity_lut_bytes(), None); Self { lut_bgl, luts: HashMap::new(), @@ -2259,7 +2331,7 @@ struct View { a: vec4, // screen_w, screen_h, scale (device px per doc px), pixels_per_point b: vec4, // doc origin x, y (device px), doc w, h (doc px) c: vec4, // filter mode, lod, grid alpha, checker square (device px) - d: vec4, // pattern preview offset x, y (doc px), display (0 none, 1 LUT, 2 LUT + gamut), output linear + d: vec4, // pattern preview offset x, y (doc px), display (0 none, 1 LUT, 2 LUT + gamut, 3 matrix/shaper), output linear e: vec4, // checker light rgb, gamut warning opacity f: vec4, // checker dark rgb, 32-bit preview gain (2^exposure; 0 = off) g: vec4, // gamut warning rgb, 32-bit preview 1 / gamma @@ -2272,6 +2344,36 @@ struct Tile { r: vec4 }; // x, y, w, h in doc px @group(1) @binding(0) var tex: texture_2d; @group(1) @binding(1) var tile: Tile; @group(2) @binding(0) var lut: texture_3d; +@group(2) @binding(1) var display_curves: texture_2d; +struct DisplayMatrix { r: vec4, g: vec4, b: vec4 }; +@group(2) @binding(2) var display_matrix: DisplayMatrix; + +fn curve_entry(index: u32) -> vec3 { + let width = textureDimensions(display_curves).x; + return textureLoad(display_curves, vec2(i32(index % width), i32(index / width)), 0).rgb; +} + +fn display_curve(c: vec3, offset: u32, intervals: f32) -> vec3 { + let p = clamp(c, vec3(0.0), vec3(1.0)) * intervals; + let lo = vec3(floor(p)); + let hi = min(lo + vec3(1u), vec3(u32(intervals))); + let f = fract(p); + return vec3( + mix(curve_entry(offset + lo.r).r, curve_entry(offset + hi.r).r, f.r), + mix(curve_entry(offset + lo.g).g, curve_entry(offset + hi.g).g, f.g), + mix(curve_entry(offset + lo.b).b, curve_entry(offset + hi.b).b, f.b) + ); +} + +fn matrix_display(c: vec3) -> vec3 { + // Layout matches cms::GpuMatrixShaper: 4097 input entries, then 4096 inverse-curve + // entries indexed by the fourth root. This preserves near-black precision without + // interpolating across clipped corners of a coarse RGB cube. + let v = vec4(display_curve(c, 0u, 4096.0), 1.0); + let linear = vec3(dot(display_matrix.r, v), dot(display_matrix.g, v), dot(display_matrix.b, v)); + let u = sqrt(sqrt(clamp(linear, vec3(0.0), vec3(1.0)))); + return display_curve(u, 4097u, 4095.0); +} struct VOut { @builtin(position) pos: vec4 }; @@ -2416,10 +2518,15 @@ fn fs_tile(in: VOut) -> @location(0) vec4 { if (view.d.z > 0.5 && col.a > 0.0) { // Colour management, Proof Colors, Gamut Warning: the document's display LUT (alpha flags // out-of-gamut colours). - let n = f32(textureDimensions(lut).x); let c = clamp(col.rgb / col.a, vec3(0.0), vec3(1.0)); - let l = textureSampleLevel(lut, samp, (c * (n - 1.0) + 0.5) / n, 0.0); - let shown = select(l.rgb, mix(l.rgb, view.g.xyz, view.e.w), view.d.z > 1.5 && l.a > 0.5); + var shown: vec3; + if (view.d.z > 2.5) { + shown = matrix_display(c); + } else { + let n = f32(textureDimensions(lut).x); + let l = textureSampleLevel(lut, samp, (c * (n - 1.0) + 0.5) / n, 0.0); + shown = select(l.rgb, mix(l.rgb, view.g.xyz, view.e.w), view.d.z > 1.5 && l.a > 0.5); + } col = vec4(shown * col.a, col.a); } var rgb = col.rgb + checker(d) * (1.0 - col.a); diff --git a/crates/ui-egui/tests/display_shadows.rs b/crates/ui-egui/tests/display_shadows.rs new file mode 100644 index 0000000000..2ff7f4edeb --- /dev/null +++ b/crates/ui-egui/tests/display_shadows.rs @@ -0,0 +1,305 @@ +//! The on-screen GPU conversion must preserve the ICC matrix/TRC result near black. +//! +//! No photo, hardware profile or application's output is an oracle here: the generated +//! profiles and expected values implement ICC.1:2010 Annex F.3's matrix/TRC equations. +//! A uniform 33³ display LUT used to make input sRGB 1–7 noticeably too dark. + +use std::sync::Arc; + +use photocraft_cms::{Builtin, Profile, curve::Curve}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; +use photocraft_engine::display_color::Display; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use photocraft_ui_egui::{PhotocraftApp, canvas::ViewXform, gpu_canvas}; + +// D50-adapted sRGB and Display P3 colourants. ICC stores them as signed 15.16 values. +const SOURCE_MATRIX: [[f64; 3]; 3] = + [[0.436_074_7, 0.385_064_9, 0.143_080_4], [0.222_504_5, 0.716_878_6, 0.060_616_9], [0.013_932_2, 0.097_104_5, 0.714_173_3]]; +const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.515_102, 0.291_965, 0.157_153], [0.241_182, 0.692_236, 0.066_581_9], [-0.001_049_41, 0.041_881_8, 0.784_378]]; +const PATCH: u32 = 56; +const COLS: usize = 8; +const RGB: [[u8; 3]; 32] = [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + [3, 3, 3], + [4, 4, 4], + [5, 5, 5], + [7, 7, 7], + [8, 8, 8], + [9, 9, 9], + [10, 10, 10], + [11, 11, 11], + [12, 12, 12], + [16, 16, 16], + [32, 32, 32], + [128, 128, 128], + [255, 255, 255], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [2, 1, 3], + [7, 2, 1], + [1, 7, 2], + [2, 1, 7], + [3, 5, 2], + [255, 0, 0], + [0, 255, 0], + [0, 0, 255], + [24, 72, 153], + [153, 72, 24], + [72, 153, 24], + [254, 254, 254], + [64, 96, 192], +]; + +fn fixed(v: f64) -> f64 { + (v * 65536.0).round() / 65536.0 +} + +/// Literal source ICC `curv` entries, with ICC's unsigned-16 encoding. +fn source_samples() -> Vec { + (0..1024) + .map(|i| { + let x = i as f64 / 1023.0; + let y = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) }; + (y * 65535.0).round() / 65535.0 + }) + .collect() +} + +fn profiles() -> (Arc>, Arc>) { + let mut source = Builtin::Srgb.profile().clone(); + source.description = "Synthetic sampled sRGB".into(); + source.matrix = Some(SOURCE_MATRIX.map(|row| row.map(fixed))); + source.trc = Some(std::array::from_fn(|_| Curve::Table(source_samples().into_iter().map(|v| v as f32).collect()))); + let mut monitor = Builtin::DisplayP3.profile().clone(); + monitor.description = "Synthetic gamma 2.2 monitor".into(); + monitor.matrix = Some(MONITOR_MATRIX.map(|row| row.map(fixed))); + monitor.trc = Some(std::array::from_fn(|_| Curve::Gamma(fixed(2.2)))); + (source.with_encoded_bytes().to_bytes(), monitor.with_encoded_bytes().to_bytes()) +} + +/// Solve the monitor matrix without using any photocraft-cms transform/math/curve evaluator. +fn solve(mut m: [[f64; 3]; 3], mut xyz: [f64; 3]) -> [f64; 3] { + for column in 0..3 { + let pivot = (column..3).max_by(|&a, &b| m[a][column].abs().total_cmp(&m[b][column].abs())).unwrap(); + m.swap(column, pivot); + xyz.swap(column, pivot); + let divisor = m[column][column]; + for v in &mut m[column] { + *v /= divisor; + } + xyz[column] /= divisor; + let normalized = m[column]; + for row in 0..3 { + if row != column { + let factor = m[row][column]; + for (value, coefficient) in m[row].iter_mut().zip(normalized) { + *value -= factor * coefficient; + } + xyz[row] -= factor * xyz[column]; + } + } + } + xyz +} + +fn reference(rgb: [u8; 3]) -> [u8; 3] { + let table = source_samples(); + let linear = rgb.map(|v| { + let pos = f64::from(v) / 255.0 * 1023.0; + let lo = pos.floor() as usize; + let hi = (lo + 1).min(1023); + table[lo] + (table[hi] - table[lo]) * (pos - lo as f64) + }); + let xyz = SOURCE_MATRIX.map(|row| (0..3).map(|c| fixed(row[c]) * linear[c]).sum()); + solve(MONITOR_MATRIX.map(|row| row.map(fixed)), xyz).map(|v| (v.clamp(0.0, 1.0).powf(1.0 / fixed(2.2)) * 255.0).round() as u8) +} + +fn chart(depth: SampleType, icc: Arc>) -> Document { + let mut doc = Document::new("ICC shadow patches", Size::new(COLS as u32 * PATCH, RGB.len() as u32 / COLS as u32 * PATCH), ColorMode::Rgb, depth); + doc.icc_profile = Some(icc); + let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); + for (i, rgb) in RGB.iter().enumerate() { + let x = i as u32 % COLS as u32 * PATCH; + let y = i as u32 / COLS as u32 * PATCH; + surface.fill_rect( + Rect::new(x as i32, y as i32, (x + PATCH) as i32, (y + PATCH) as i32), + &[f32::from(rgb[0]) / 255.0, f32::from(rgb[1]) / 255.0, f32::from(rgb[2]) / 255.0, 1.0], + ); + } + doc.layers.push(Layer::new("Patches", LayerContent::Raster(surface))); + doc +} + +#[test] +fn gpu_display_shadows_match_independent_icc_reference_at_all_depths() { + let _gpu = photocraft_testkit::gpu_lock(); + // Catch only device discovery: a shader or application failure must fail the regression. + let Ok(rs) = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(gpu_canvas::wgpu_setup(), Default::default()))) + else { + eprintln!("skipping: no GPU adapter"); + return; + }; + if !gpu_canvas::supports_f16_canvas(&rs.adapter) || std::env::var("PHOTOCRAFT_CANVAS_F16").as_deref() == Ok("0") { + eprintln!("skipping: adapter can't render/filter Rgba16Float"); + return; + } + let (source, monitor) = profiles(); + // Verify the synthetic fixture was encoded as intended; no production evaluator is the oracle. + let parsed = Profile::parse(&source).expect("source fixture"); + assert_eq!(parsed.matrix, Some(SOURCE_MATRIX.map(|row| row.map(fixed)))); + let renderer = egui_kittest::wgpu::WgpuTestRenderer::from_render_state(rs); + let mut harness = + egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.0).with_max_steps(64).renderer(renderer).build_eframe( + move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(cc.wgpu_render_state.as_ref().expect("render state").clone()); + app.session.color.set_displays(Ok(vec![Display { + id: 1, + name: "Synthetic display".into(), + frame: [0.0, 0.0, 1000.0, 700.0], + profile_name: None, + icc: Some(monitor), + }])); + app + }, + ); + let mut failures = Vec::new(); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + harness.state_mut().session.open_document(chart(depth, source.clone()), None); + harness.state_mut().sync_views(); + harness.run_steps(6); + assert!(harness.state().gpu_active(), "the regression must exercise the GPU canvas"); + let xform = ViewXform::active(harness.state()).expect("canvas mapping"); + let image = harness.render().expect("render GPU canvas"); + // Optional local diagnostic artifact: entirely synthetic, never a user's document. + if let Some(dir) = std::env::var_os("PHOTOCRAFT_DISPLAY_TEST_OUTPUT") { + let path = std::path::PathBuf::from(dir).join(format!("display-shadows-{depth:?}.png")); + image.save(&path).expect("save requested synthetic chart"); + eprintln!("synthetic chart: {}", path.display()); + } + for (i, rgb) in RGB.iter().enumerate() { + let x = (i % COLS) as f32 * PATCH as f32 + PATCH as f32 / 2.0; + let y = (i / COLS) as f32 * PATCH as f32 + PATCH as f32 / 2.0; + let point = xform.to_screen(x, y); + let got = image.get_pixel(point.x.round() as u32, point.y.round() as u32).0; + let want = reference(*rgb); + if (0..3).any(|c| got[c].abs_diff(want[c]) > 1) { + failures.push(format!("{depth:?} input {rgb:?}: GPU {got:?}, ICC {want:?}")); + } + } + } + assert!(failures.is_empty(), "GPU display differs by more than one RGB8 code:\n{}", failures.join("\n")); +} +/// Release-only manual evidence, not a noisy CI performance threshold. Both paths render +/// the same 24 MP texture at 100%; timings include identical framebuffer allocation/readback +/// and wait for GPU completion. Profile/texture uploads are outside the measured frames. +#[test] +#[ignore = "manual 24 MP GPU timing; run with --release --ignored --nocapture"] +fn compare_display_paths_24mp() { + use photocraft_cms::{Intent, Lut3d, Transform}; + use photocraft_ui_egui::gpu_canvas::{GpuCanvas, ViewParams}; + use std::time::Instant; + + let _gpu = photocraft_testkit::gpu_lock(); + let rs = egui_kittest::wgpu::create_render_state(gpu_canvas::wgpu_setup(), Default::default()); + let info = rs.adapter.get_info(); + let gpu = GpuCanvas::new(&rs); + let (source, monitor) = profiles(); + let source = Profile::parse(&source).expect("synthetic source"); + let monitor = Profile::parse(&monitor).expect("synthetic monitor"); + let transform = Transform::new(&source, &monitor, Intent::RelativeColorimetric, true).expect("display transform"); + let lut = Lut3d::from_transform(&transform, 33).to_rgba8(); + let shaper = transform.gpu_matrix_shaper().expect("matrix/shaper"); + + let mut prep_lut_ms = Vec::new(); + let mut prep_shaper_ms = Vec::new(); + for _ in 0..5 { + let start = Instant::now(); + std::hint::black_box(Lut3d::from_transform(&transform, 33).to_rgba8()); + prep_lut_ms.push(start.elapsed().as_secs_f64() * 1000.0); + let start = Instant::now(); + std::hint::black_box(transform.gpu_matrix_shaper().expect("matrix/shaper")); + prep_shaper_ms.push(start.elapsed().as_secs_f64() * 1000.0); + } + + const SIZE: [u32; 2] = [6000, 4000]; + let rgba: Vec = (0..SIZE[0] as usize * SIZE[1] as usize) + .flat_map(|i| { + let rgb = RGB[(i / 64 + i / SIZE[0] as usize / 64) % RGB.len()]; + [rgb[0], rgb[1], rgb[2], 255] + }) + .collect(); + gpu.upload_full(1, SIZE, &rgba); + drop(rgba); + gpu.set_display_lut(1, 1, 33, Some(&lut)); + gpu.set_display_matrix_shaper(1, 2, &shaper); + assert!(gpu.has(1, SIZE), "24 MP texture uploaded"); + let renderer = egui_kittest::wgpu::WgpuTestRenderer::from_render_state(rs); + let params = ViewParams { + doc: 1, + doc_size: SIZE, + zoom: 1.0, + center: [SIZE[0] as f32 / 2.0, SIZE[1] as f32 / 2.0], + rotation: 0.0, + shadow: false, + pixel_grid: false, + view_key: 0, + display: 1, + output: 1, + hdr: None, + offset: [0.0, 0.0], + }; + let mut harness = + egui_kittest::Harness::builder().with_size(egui::vec2(SIZE[0] as f32, SIZE[1] as f32)).with_pixels_per_point(1.0).renderer(renderer).build_ui_state( + |ui, params| { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(SIZE[0] as f32, SIZE[1] as f32)); + GpuCanvas::paint(&ui.painter().with_clip_rect(rect), rect, *params); + }, + params, + ); + let mut render_lut_ms = Vec::new(); + let mut render_shaper_ms = Vec::new(); + // Alternation reduces order/temperature bias; round zero is the warm-up for each mode. + for round in 0..6 { + for (display, output) in [(1, 1), (3, 2)] { + harness.state_mut().display = display; + harness.state_mut().output = output; + let start = Instant::now(); + harness.run_steps(1); + // WgpuTestRenderer waits for submission and mapped readback before returning. + let image = harness.render().expect("render 24 MP frame"); + std::hint::black_box(&image); + let elapsed = start.elapsed().as_secs_f64() * 1000.0; + assert_eq!([image.width(), image.height()], SIZE); + if round != 0 { + if display == 1 { + render_lut_ms.push(elapsed); + } else { + render_shaper_ms.push(elapsed); + } + } + } + } + for (label, mut samples) in [ + ("LUT preparation", prep_lut_ms), + ("matrix/shaper preparation", prep_shaper_ms), + ("LUT 24 MP render + readback", render_lut_ms), + ("matrix/shaper 24 MP render + readback", render_shaper_ms), + ] { + samples.sort_by(f64::total_cmp); + eprintln!("{label}: median {:.3} ms, max {:.3} ms; samples {samples:?}", samples[2], samples[4]); + } + eprintln!( + "adapter: {} ({:?}); release={}; 6000x4000 RGBA8, 100% zoom, 1 warm-up + 5 alternating measured frames per path", + info.name, + info.backend, + !cfg!(debug_assertions) + ); +} diff --git a/docs/architecture.md b/docs/architecture.md index 614cabfb05..79c674bf23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # PhotoCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (workspace size and test counts recounted; current-state summary added; the design plan below is kept, with its deviations listed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (accurate GPU matrix/TRC display conversion; original design plan retained) · **Target:** Adobe Photoshop 2026 ## Current state (2026-10-10) @@ -18,7 +18,7 @@ What exists on `main` today. The numbered sections below are the original design UI, CLI, JSON control channel and MCP server all dispatch by command id. 628 Photoshop menu items map to them (`crates/ui-egui/src/menu_catalog.rs`). - **Rendering:** CPU compositor (`compose`, the oracle) and wgpu compositor (`gpu`, ≤ 1/255 of the - CPU); colour through our own ICC engine (`cms`), display transform folded into a GPU 3D LUT. + CPU); colour through our own ICC engine (`cms`), GPU display conversion through matrix/shaper stages or a 3D LUT fallback. - **Files:** own PSD/PSB crate (`psd`), flat codecs (`codecs`), camera raw (`raw`), HEIF (`heif`), Affinity (`affinity`), document mapping (`io`), native `.pcraft` (`format`, atomic saves). Coverage: [`file-format-parity.md`](file-format-parity.md). @@ -349,7 +349,7 @@ Both backends consume the same plan. This is the only place that encodes Photosh ### 7.3 Color - Blending happens in document space by default, which is Photoshop-compatible. A per-document "linear light blending" option is also available. -- **Display transform** (`engine/src/display_color.rs`): the canvas is always colour-managed, document profile → monitor profile (relative colorimetric + BPC), cached per (document profile, mode, monitor). On the GPU canvas the transform, plus Proof Colors / Gamut Warning / 32-bit preview, is baked into a 33³ 3D LUT the canvas shader's final pass applies; the CPU canvas runs an 8-bit `photocraft-cms` transform on the composite. When the document profile matches the monitor (sRGB on sRGB) there is no LUT and no transform. Linear composites (EXR/HDR, tagged linear sRGB on import) are stored sRGB-encoded in the 8-bit canvas texture. CMYK documents are read through their embedded CMYK profile (`photocraft_color::convert::with_cmyk_space`, entered by the compositors and composite exports). +- **Display transform** (`engine/src/display_color.rs`): the canvas is always colour-managed, document profile → monitor profile (relative colorimetric + BPC), cached per (document profile, mode, monitor). On the GPU canvas RGB matrix/TRC transforms retain separate full-float input curves, matrix/offsets and inverse output curves (sampled densely near black); this avoids the shadow darkening of a uniform RGB cube (#7958). Other transforms and Gamut Warning retain the 33³ display LUT, and the shader applies 32-bit preview before either conversion; the CPU canvas runs an 8-bit `photocraft-cms` transform on the composite. When the document profile matches the monitor (sRGB on sRGB) there is no LUT and no transform. Linear composites (EXR/HDR, tagged linear sRGB on import) are stored sRGB-encoded in the 8-bit canvas texture. CMYK documents are read through their embedded CMYK profile (`photocraft_color::convert::with_cmyk_space`, entered by the compositors and composite exports). - **Monitor profile:** Edit › Color Settings › Monitor Profile: `auto`, a built-in RGB profile or an `.icc` path. In `auto` each window uses the profile of the display it is on (#569): the desktop app reads every display's id, name, frame and ICC profile at launch and again when the app comes back to the front, when any window is on an unknown or resized display (at most every 30 s; a trigger inside that window is deferred) and when Edit › Color Settings opens (`ui-egui/src/monitor_status.rs`). macOS reads AppKit `NSScreen` through `osascript` (no FFI); X11 reads RandR outputs and the default colord profile matched by exact `XRANDR_name`; native Wayland and Xwayland windows from a Wayland session stay on sRGB because eframe/winit cannot safely set the app surface's Wayland image description. The macOS helper is stopped after 10 s. There is deliberately no periodic re-read (#569 decision: a read costs about 0.2 s of CPU), so a profile reassigned while PhotoCraft stays in front with no window moving is picked up at the next return to the front or Color Settings. X11 selects the root window's RandR output by winit's current-monitor name and other windows by their overlapping RandR frame; macOS follows `NSWindow.screen`. Elsewhere `auto` is sRGB. The GPU canvas keeps one texture per document and a display LUT per (document, display); CPU canvas textures are per (document, display). `ColorState::monitor_status_for` says what is applied per display: `auto`, `manual`, or `fallback` to sRGB with the reason (missing, unreadable, non-RGB or unusable as a destination), and whether the reading is an earlier one kept after a failed re-read; `edit.colorSettings` (`monitorStatus`, `displays`), Help › System Info and the Color Settings dialog show it, and a fallback in `auto` posts a notice. Letting macOS colour-match the canvas instead is #581 (an architecture decision). - **HDR/EDR output** (an `rgba16float` surface with an extended-range colorspace) is a later-phase feature. The interfaces already carry `f32` pixels. @@ -589,6 +589,7 @@ Each phase ends with a demoable build and green CI on all targets. Phases 8–10 | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | GPU display matrix/shaper path preserves near-black ICC values; complex transforms retain the 3D LUT fallback (#7958) | | 2026-10-13 | minor | Workspace size and `#[test]` counts recounted (393k → 539k lines, 5,460 → 8,040 tests); other current-state figures unchanged | | 2026-10-10 | minor | Current-state summary and deviations from the plan; status line; broken link to `photoshop-parity.md` repointed to `target-app-parity.md` | | 2026-09-30 | major | Design plan v1 |