1use crate::render::spectral_kernel::{emf_to_spd, spd_to_xyz, xyz_to_linear_srgb};
28
29pub fn cpu_batch_emf_to_display_gamut_mapped(emf_in: &[f32], rgb_out: &mut [u8]) -> usize {
40 let n = emf_in.len() / 3;
41 let n = n.min(rgb_out.len() / 3);
42
43 for i in 0..n {
44 let alpha = emf_in[i * 3];
45 let mu = emf_in[i * 3 + 1];
46 let sigma = emf_in[i * 3 + 2];
47
48 let spd = emf_to_spd(alpha, mu, sigma);
50 let xyz = spd_to_xyz(&spd);
51 let rgb = xyz_to_linear_srgb(&xyz);
52
53 let r = rgb.r.clamp(0.0, 1.0);
55 let g = rgb.g.clamp(0.0, 1.0);
56 let b = rgb.b.clamp(0.0, 1.0);
57
58 let enc = |c: f32| -> u8 {
60 let encoded = if c <= 0.0031308 {
61 12.92 * c
62 } else {
63 1.055 * c.powf(1.0 / 2.4) - 0.055
64 };
65 (encoded * 255.0).round().clamp(0.0, 255.0) as u8
66 };
67
68 rgb_out[i * 3] = enc(r);
69 rgb_out[i * 3 + 1] = enc(g);
70 rgb_out[i * 3 + 2] = enc(b);
71 }
72
73 n
74}
75
76pub fn cpu_batch_emf_to_xyz(emf_in: &[f32], xyz_out: &mut [f32]) -> usize {
85 let n = emf_in.len() / 3;
86 let n = n.min(xyz_out.len() / 3);
87
88 for i in 0..n {
89 let spd = emf_to_spd(emf_in[i * 3], emf_in[i * 3 + 1], emf_in[i * 3 + 2]);
90 let xyz = spd_to_xyz(&spd);
91 xyz_out[i * 3] = xyz.x;
92 xyz_out[i * 3 + 1] = xyz.y;
93 xyz_out[i * 3 + 2] = xyz.z;
94 }
95
96 n
97}
98
99pub const GPU_COLOUR_KERNEL_WGSL: &str = r#"
108// P7.4 — GPU colour-projection / gamut batch kernel.
109// Processes one EMF [α, μ, σ] element per invocation.
110
111const SPD_SAMPLES: u32 = 41u;
112const LAMBDA_MIN: f32 = 380.0;
113const LAMBDA_STEP: f32 = 10.0;
114
115// CIE 1931 2-degree observer CMFs (41 samples, 10nm steps).
116const CMF_X = array<f32, 41>(
117 0.001368, 0.004243, 0.014310, 0.043510, 0.134380, 0.283900, 0.348280,
118 0.336200, 0.290800, 0.195360, 0.095640, 0.032010, 0.004900, 0.009300,
119 0.063270, 0.165500, 0.290400, 0.433450, 0.594500, 0.762100, 0.916300,
120 1.026300, 1.062200, 1.002600, 0.854450, 0.642400, 0.447900, 0.283500,
121 0.164900, 0.087400, 0.046770, 0.022700, 0.011359, 0.005790, 0.002899,
122 0.001440, 0.000690, 0.000332, 0.000166, 0.000083, 0.000042,
123);
124
125const CMF_Y = array<f32, 41>(
126 0.000039, 0.000120, 0.000396, 0.001210, 0.004000, 0.011600, 0.023000,
127 0.038000, 0.060000, 0.090980, 0.139020, 0.208020, 0.323000, 0.503000,
128 0.710000, 0.862000, 0.954000, 0.994950, 0.995000, 0.952000, 0.870000,
129 0.757000, 0.631000, 0.503000, 0.381000, 0.265000, 0.175000, 0.107000,
130 0.061000, 0.032000, 0.017000, 0.008210, 0.004102, 0.002091, 0.001047,
131 0.000520, 0.000249, 0.000120, 0.000060, 0.000030, 0.000015,
132);
133
134const CMF_Z = array<f32, 41>(
135 0.006450, 0.020050, 0.067850, 0.207400, 0.645600, 1.282500, 1.453000,
136 1.562100, 1.562700, 1.385600, 1.114600, 0.777500, 0.445600, 0.198700,
137 0.068100, 0.019800, 0.004100, 0.000500, 0.000200, 0.000010, 0.000000,
138 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000,
139 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000,
140 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000,
141);
142
143// Precomputed Y normalisation = 1 / Σȳ.
144const Y_NORM: f32 = 0.01068128;
145
146// XYZ → linear sRGB matrix.
147const M_R: vec3<f32> = vec3(3.2404542, -1.5371385, -0.4985314);
148const M_G: vec3<f32> = vec3(-0.9692660, 1.8760108, 0.0415560);
149const M_B: vec3<f32> = vec3(0.0556434, -0.2040259, 1.0572252);
150
151struct EmfInput {
152 data: array<f32>,
153};
154
155struct RgbOutput {
156 data: array<u32>,
157};
158
159@group(0) @binding(0) var<storage, read> emf_in: EmfInput;
160@group(0) @binding(1) var<storage, read_write> rgb_out: RgbOutput;
161@group(0) @binding(2) var<uniform> params: Params;
162
163struct Params {
164 count: u32,
165};
166
167@compute @workgroup_size(64)
168fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
169 let idx = gid.x;
170 if (idx >= params.count) {
171 return;
172 }
173
174 let alpha = emf_in.data[idx * 3u];
175 let mu = emf_in.data[idx * 3u + 1u];
176 let sigma = emf_in.data[idx * 3u + 2u];
177
178 // EMF → SPD: Gaussian peak.
179 let lambda = 400.0 + clamp(sigma, 0.0, 1.0) * 300.0;
180 let width = 10.0 + clamp(mu, 0.0, 1.0) * 140.0;
181 let amplitude = max(alpha, 0.0);
182
183 // SPD → XYZ.
184 var x = 0.0;
185 var y = 0.0;
186 var z = 0.0;
187 for (var i = 0u; i < SPD_SAMPLES; i = i + 1u) {
188 let l = LAMBDA_MIN + f32(i) * LAMBDA_STEP;
189 let d = (l - lambda) / width;
190 let s = amplitude * exp(-0.5 * d * d);
191 x = x + s * CMF_X[i];
192 y = y + s * CMF_Y[i];
193 z = z + s * CMF_Z[i];
194 }
195 x = x * Y_NORM;
196 y = y * Y_NORM;
197 z = z * Y_NORM;
198
199 // XYZ → linear sRGB.
200 let xyz = vec3<f32>(x, y, z);
201 var r = dot(M_R, xyz);
202 var g = dot(M_G, xyz);
203 var b = dot(M_B, xyz);
204
205 // Gamut map: clamp to [0, 1].
206 r = clamp(r, 0.0, 1.0);
207 g = clamp(g, 0.0, 1.0);
208 b = clamp(b, 0.0, 1.0);
209
210 // sRGB gamma encode.
211 let encode = fn(c: f32) -> f32 {
212 if (c <= 0.0031308) {
213 return 12.92 * c;
214 }
215 return 1.055 * pow(c, 1.0 / 2.4) - 0.055;
216 };
217
218 let er = encode(r);
219 let eg = encode(g);
220 let eb = encode(b);
221
222 // Pack into u32 (RGB24).
223 let packed = u32(round(er * 255.0)) |
224 (u32(round(eg * 255.0)) << 8u) |
225 (u32(round(eb * 255.0)) << 16u);
226
227 rgb_out.data[idx] = packed;
228}
229"#;
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub struct GpuBufferLayout {
234 pub emf_stride: usize,
235 pub rgb_stride: usize,
236 pub params_size: usize,
237}
238
239impl Default for GpuBufferLayout {
240 fn default() -> Self {
241 Self {
242 emf_stride: 12, rgb_stride: 4, params_size: 4, }
246 }
247}
248
249pub const DIFF_TOLERANCE_U8: u8 = 2;
257
258pub fn diff_cpu_gpu(cpu_out: &[u8], gpu_out: &[u8]) -> usize {
263 let n = cpu_out.len().min(gpu_out.len()) / 3;
264 let mut mismatches = 0;
265
266 for i in 0..n {
267 let dr = (cpu_out[i * 3] as i16 - gpu_out[i * 3] as i16).unsigned_abs() as u8;
268 let dg = (cpu_out[i * 3 + 1] as i16 - gpu_out[i * 3 + 1] as i16).unsigned_abs() as u8;
269 let db = (cpu_out[i * 3 + 2] as i16 - gpu_out[i * 3 + 2] as i16).unsigned_abs() as u8;
270 if dr > DIFF_TOLERANCE_U8 || dg > DIFF_TOLERANCE_U8 || db > DIFF_TOLERANCE_U8 {
271 mismatches += 1;
272 }
273 }
274
275 mismatches
276}
277
278#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn cpu_batch_deterministic() {
288 let emf = [1.0, 0.3, 0.5, 0.8, 0.2, 0.7, 1.0, 0.0, 0.0];
289 let mut out1 = [0u8; 9];
290 let mut out2 = [0u8; 9];
291 cpu_batch_emf_to_display_gamut_mapped(&emf, &mut out1);
292 cpu_batch_emf_to_display_gamut_mapped(&emf, &mut out2);
293 assert_eq!(out1, out2, "CPU batch must be deterministic");
294 }
295
296 #[test]
297 fn cpu_batch_xyz_deterministic() {
298 let emf = [1.0, 0.3, 0.5, 0.8, 0.2, 0.7];
299 let mut out1 = [0.0f32; 6];
300 let mut out2 = [0.0f32; 6];
301 cpu_batch_emf_to_xyz(&emf, &mut out1);
302 cpu_batch_emf_to_xyz(&emf, &mut out2);
303 assert_eq!(out1, out2, "CPU batch XYZ must be deterministic");
304 }
305
306 #[test]
307 fn cpu_batch_valid_rgb() {
308 let emf = [1.0, 0.0, 0.0, 1.0, 0.5, 0.5, 1.0, 0.0, 1.0];
309 let mut out = [0u8; 9];
310 let n = cpu_batch_emf_to_display_gamut_mapped(&emf, &mut out);
311 assert_eq!(n, 3);
312 }
314
315 #[test]
316 fn cpu_batch_partial_output() {
317 let emf = [1.0, 0.3, 0.5, 0.8, 0.2, 0.7, 1.0, 0.0, 0.0];
318 let mut out = [0u8; 6]; let n = cpu_batch_emf_to_display_gamut_mapped(&emf, &mut out);
320 assert_eq!(n, 2, "should process only 2 elements");
321 }
322
323 #[test]
324 fn gpu_kernel_source_contains_cmf_data() {
325 assert!(
326 GPU_COLOUR_KERNEL_WGSL.contains("CMF_X"),
327 "WGSL must contain CMF X data"
328 );
329 assert!(
330 GPU_COLOUR_KERNEL_WGSL.contains("CMF_Y"),
331 "WGSL must contain CMF Y data"
332 );
333 assert!(
334 GPU_COLOUR_KERNEL_WGSL.contains("CMF_Z"),
335 "WGSL must contain CMF Z data"
336 );
337 }
338
339 #[test]
340 fn gpu_kernel_source_contains_pipeline() {
341 assert!(
342 GPU_COLOUR_KERNEL_WGSL.contains("SPD"),
343 "WGSL must mention SPD"
344 );
345 assert!(
346 GPU_COLOUR_KERNEL_WGSL.contains("XYZ"),
347 "WGSL must mention XYZ"
348 );
349 assert!(
350 GPU_COLOUR_KERNEL_WGSL.contains("sRGB"),
351 "WGSL must mention sRGB"
352 );
353 assert!(
354 GPU_COLOUR_KERNEL_WGSL.contains("clamp"),
355 "WGSL must gamut-map with clamp"
356 );
357 }
358
359 #[test]
360 fn gpu_kernel_source_has_workgroup_size() {
361 assert!(
362 GPU_COLOUR_KERNEL_WGSL.contains("workgroup_size(64)"),
363 "WGSL must specify workgroup size"
364 );
365 }
366
367 #[test]
368 fn buffer_layout_defaults() {
369 let layout = GpuBufferLayout::default();
370 assert_eq!(layout.emf_stride, 12);
371 assert_eq!(layout.rgb_stride, 4);
372 assert_eq!(layout.params_size, 4);
373 }
374
375 #[test]
376 fn diff_cpu_gpu_self_zero() {
377 let cpu = [100u8, 150, 200, 50, 60, 70];
378 let mismatches = diff_cpu_gpu(&cpu, &cpu);
379 assert_eq!(mismatches, 0, "self-comparison should have zero mismatches");
380 }
381
382 #[test]
383 fn diff_cpu_gpu_within_tolerance() {
384 let cpu = [100u8, 150, 200, 50, 60, 70];
385 let gpu = [101u8, 149, 202, 51, 58, 72]; let mismatches = diff_cpu_gpu(&cpu, &gpu);
387 assert_eq!(
388 mismatches, 0,
389 "within-tolerance differences should not count"
390 );
391 }
392
393 #[test]
394 fn diff_cpu_gpu_outside_tolerance() {
395 let cpu = [100u8, 150, 200, 50, 60, 70];
396 let gpu = [110u8, 150, 200, 50, 60, 70]; let mismatches = diff_cpu_gpu(&cpu, &gpu);
398 assert_eq!(mismatches, 1, "out-of-tolerance should count as mismatch");
399 }
400
401 #[test]
402 fn cpu_batch_blue_dominates_at_low_sigma() {
403 let emf = [1.0, 0.0, 0.0]; let mut out = [0u8; 3];
405 cpu_batch_emf_to_display_gamut_mapped(&emf, &mut out);
406 assert!(out[2] >= out[0], "B should dominate R at σ=0");
407 assert!(out[2] >= out[1], "B should dominate G at σ=0");
408 }
409
410 #[test]
411 fn cpu_batch_red_dominates_at_high_sigma() {
412 let emf = [1.0, 0.0, 1.0]; let mut out = [0u8; 3];
414 cpu_batch_emf_to_display_gamut_mapped(&emf, &mut out);
415 assert!(out[0] >= out[1], "R should dominate G at σ=1");
416 assert!(out[0] >= out[2], "R should dominate B at σ=1");
417 }
418}