1use std::path::Path;
9
10use super::RenderNodeCpu;
11use crate::error::RenderError;
12
13pub struct LutNode {
20 lut: Vec<[f32; 3]>,
22 size: u32,
24 #[cfg(feature = "wgpu")]
25 pipeline: std::sync::OnceLock<LutPipeline>,
26}
27
28impl LutNode {
29 fn from_grid(lut: Vec<[f32; 3]>, size: u32) -> Self {
32 Self {
33 lut,
34 size,
35 #[cfg(feature = "wgpu")]
36 pipeline: std::sync::OnceLock::new(),
37 }
38 }
39
40 pub fn from_cube(path: &Path) -> Result<Self, RenderError> {
47 let text = read_lut_file(path)?;
48 let (lut, size) = parse_cube(&text).map_err(|reason| RenderError::LutLoad {
49 path: path.display().to_string(),
50 reason,
51 })?;
52 Ok(Self::from_grid(lut, size))
53 }
54
55 pub fn from_3dl(path: &Path) -> Result<Self, RenderError> {
62 let text = read_lut_file(path)?;
63 let (lut, size) = parse_3dl(&text).map_err(|reason| RenderError::LutLoad {
64 path: path.display().to_string(),
65 reason,
66 })?;
67 Ok(Self::from_grid(lut, size))
68 }
69
70 #[allow(
73 clippy::cast_precision_loss,
74 clippy::cast_sign_loss,
75 clippy::cast_possible_truncation,
76 clippy::many_single_char_names
77 )]
78 fn sample(&self, r: f32, g: f32, b: f32) -> [f32; 3] {
79 let n = self.size as usize;
80 let last = (n - 1) as f32;
81 let axis = |v: f32| {
83 let c = v.clamp(0.0, 1.0) * last;
84 let lo = c.floor();
85 let li = (lo as usize).min(n - 1);
86 (li, (li + 1).min(n - 1), c - lo)
87 };
88 let (r0, r1, fr) = axis(r);
89 let (g0, g1, fg) = axis(g);
90 let (b0, b1, fb) = axis(b);
91 let at = |ri: usize, gi: usize, bi: usize| self.lut[ri + n * (gi + n * bi)];
92 let lerp = |a: [f32; 3], b: [f32; 3], t: f32| {
93 [
94 a[0] + (b[0] - a[0]) * t,
95 a[1] + (b[1] - a[1]) * t,
96 a[2] + (b[2] - a[2]) * t,
97 ]
98 };
99 let c00 = lerp(at(r0, g0, b0), at(r1, g0, b0), fr);
100 let c10 = lerp(at(r0, g1, b0), at(r1, g1, b0), fr);
101 let c01 = lerp(at(r0, g0, b1), at(r1, g0, b1), fr);
102 let c11 = lerp(at(r0, g1, b1), at(r1, g1, b1), fr);
103 let c0 = lerp(c00, c10, fg);
104 let c1 = lerp(c01, c11, fg);
105 lerp(c0, c1, fb)
106 }
107}
108
109fn read_lut_file(path: &Path) -> Result<String, RenderError> {
111 std::fs::read_to_string(path).map_err(|e| RenderError::LutLoad {
112 path: path.display().to_string(),
113 reason: e.to_string(),
114 })
115}
116
117#[allow(clippy::cast_possible_truncation)] fn parse_cube(text: &str) -> Result<(Vec<[f32; 3]>, u32), String> {
124 let mut size: Option<usize> = None;
125 let mut entries: Vec<[f32; 3]> = Vec::new();
126 for line in text.lines() {
127 let line = line.trim();
128 if line.is_empty() || line.starts_with('#') {
129 continue;
130 }
131 if let Some(rest) = line.strip_prefix("LUT_3D_SIZE") {
132 let n: usize = rest
133 .trim()
134 .parse()
135 .map_err(|_| format!("invalid LUT_3D_SIZE: {rest}"))?;
136 if !(2..=256).contains(&n) {
137 return Err(format!("LUT_3D_SIZE out of range: {n}"));
138 }
139 size = Some(n);
140 continue;
141 }
142 if line
145 .chars()
146 .next()
147 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
148 {
149 continue;
150 }
151 let vals: Vec<f32> = line
152 .split_whitespace()
153 .map(str::parse::<f32>)
154 .collect::<Result<_, _>>()
155 .map_err(|_| format!("invalid data line: {line}"))?;
156 if vals.len() != 3 {
157 return Err(format!("expected 3 floats, got {}: {line}", vals.len()));
158 }
159 if !vals.iter().all(|v| v.is_finite()) {
160 return Err(format!("non-finite value: {line}"));
161 }
162 entries.push([vals[0], vals[1], vals[2]]);
163 }
164 let size = size.ok_or("missing LUT_3D_SIZE")?;
165 let expected = size * size * size;
166 if entries.len() != expected {
167 return Err(format!(
168 "expected {expected} entries for size {size}, got {}",
169 entries.len()
170 ));
171 }
172 Ok((entries, size as u32))
173}
174
175#[allow(
184 clippy::cast_precision_loss,
185 clippy::cast_sign_loss,
186 clippy::cast_possible_truncation
187)] fn parse_3dl(text: &str) -> Result<(Vec<[f32; 3]>, u32), String> {
189 let mut lines = text
190 .lines()
191 .map(str::trim)
192 .filter(|l| !l.is_empty() && !l.starts_with('#'));
193 let header = lines.next().ok_or("empty .3dl")?;
194 let size = header.split_whitespace().count();
195 if !(2..=256).contains(&size) {
196 return Err(format!("invalid .3dl mesh size: {size}"));
197 }
198 let mut ints: Vec<[u32; 3]> = Vec::new();
199 let mut max_seen: u32 = 0;
200 for line in lines {
201 let v: Vec<u32> = line
202 .split_whitespace()
203 .map(str::parse::<u32>)
204 .collect::<Result<_, _>>()
205 .map_err(|_| format!("invalid .3dl data line: {line}"))?;
206 if v.len() != 3 {
207 return Err(format!("expected an R G B triple: {line}"));
208 }
209 max_seen = max_seen.max(v[0]).max(v[1]).max(v[2]);
210 ints.push([v[0], v[1], v[2]]);
211 }
212 let expected = size * size * size;
213 if ints.len() != expected {
214 return Err(format!(
215 "expected {expected} entries for size {size}, got {}",
216 ints.len()
217 ));
218 }
219 let denom = normalise_denominator(max_seen) as f32;
220 let mut lut = vec![[0.0f32; 3]; expected];
221 for (n, e) in ints.iter().enumerate() {
222 let b = n % size;
224 let g = (n / size) % size;
225 let r = n / (size * size);
226 lut[r + size * (g + size * b)] = [
227 e[0] as f32 / denom,
228 e[1] as f32 / denom,
229 e[2] as f32 / denom,
230 ];
231 }
232 Ok((lut, size as u32))
233}
234
235fn normalise_denominator(max: u32) -> u32 {
238 (8..=16)
239 .map(|bits| (1u32 << bits) - 1)
240 .find(|&d| d >= max)
241 .unwrap_or((1u32 << 16) - 1)
242}
243
244impl RenderNodeCpu for LutNode {
247 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
248 fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
249 for px in rgba.as_chunks_mut::<4>().0 {
250 let [r, g, b] = self.sample(
251 f32::from(px[0]) / 255.0,
252 f32::from(px[1]) / 255.0,
253 f32::from(px[2]) / 255.0,
254 );
255 px[0] = (r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
256 px[1] = (g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
257 px[2] = (b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
258 }
260 }
261}
262
263#[cfg(feature = "wgpu")]
266struct LutPipeline {
267 render_pipeline: wgpu::RenderPipeline,
268 bind_group_layout: wgpu::BindGroupLayout,
269 lut_texture: wgpu::Texture,
270}
271
272#[cfg(feature = "wgpu")]
273fn lut_texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
274 wgpu::BindGroupLayoutEntry {
277 binding,
278 visibility: wgpu::ShaderStages::FRAGMENT,
279 ty: wgpu::BindingType::Texture {
280 sample_type: wgpu::TextureSampleType::Float { filterable: false },
281 view_dimension: wgpu::TextureViewDimension::D3,
282 multisampled: false,
283 },
284 count: None,
285 }
286}
287
288#[cfg(feature = "wgpu")]
289impl LutNode {
290 fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &LutPipeline {
291 self.pipeline.get_or_init(|| {
292 use super::blur::{fullscreen_pipeline, texture_entry};
293 let device = &ctx.device;
294 let size = self.size;
295
296 let mut texels: Vec<u8> = Vec::with_capacity(self.lut.len() * 16);
297 for px in &self.lut {
298 for c in [px[0], px[1], px[2], 1.0] {
299 texels.extend_from_slice(&c.to_le_bytes());
300 }
301 }
302 let lut_texture = device.create_texture(&wgpu::TextureDescriptor {
303 label: Some("Lut 3D"),
304 size: wgpu::Extent3d {
305 width: size,
306 height: size,
307 depth_or_array_layers: size,
308 },
309 mip_level_count: 1,
310 sample_count: 1,
311 dimension: wgpu::TextureDimension::D3,
312 format: wgpu::TextureFormat::Rgba32Float,
313 usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
314 view_formats: &[],
315 });
316 ctx.queue.write_texture(
317 wgpu::TexelCopyTextureInfo {
318 texture: &lut_texture,
319 mip_level: 0,
320 origin: wgpu::Origin3d::ZERO,
321 aspect: wgpu::TextureAspect::All,
322 },
323 &texels,
324 wgpu::TexelCopyBufferLayout {
325 offset: 0,
326 bytes_per_row: Some(size * 16),
327 rows_per_image: Some(size),
328 },
329 wgpu::Extent3d {
330 width: size,
331 height: size,
332 depth_or_array_layers: size,
333 },
334 );
335
336 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
337 label: Some("Lut shader"),
338 source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/lut.wgsl").into()),
339 });
340 let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
341 label: Some("Lut BGL"),
342 entries: &[texture_entry(0), lut_texture_entry(1)],
343 });
344 let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "Lut");
345
346 LutPipeline {
347 render_pipeline,
348 bind_group_layout: bgl,
349 lut_texture,
350 }
351 })
352 }
353}
354
355#[cfg(feature = "wgpu")]
356impl super::RenderNode for LutNode {
357 fn process(
358 &self,
359 inputs: &[&wgpu::Texture],
360 outputs: &[&wgpu::Texture],
361 ctx: &crate::context::RenderContext,
362 ) {
363 let Some(input) = inputs.first() else {
364 log::warn!("LutNode::process called with no inputs");
365 return;
366 };
367 let Some(output) = outputs.first() else {
368 log::warn!("LutNode::process called with no outputs");
369 return;
370 };
371 let pd = self.get_or_create_pipeline(ctx);
372 let input_view = input.create_view(&wgpu::TextureViewDescriptor::default());
373 let lut_view = pd
374 .lut_texture
375 .create_view(&wgpu::TextureViewDescriptor::default());
376 let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
377 let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
378 label: Some("Lut BG"),
379 layout: &pd.bind_group_layout,
380 entries: &[
381 wgpu::BindGroupEntry {
382 binding: 0,
383 resource: wgpu::BindingResource::TextureView(&input_view),
384 },
385 wgpu::BindGroupEntry {
386 binding: 1,
387 resource: wgpu::BindingResource::TextureView(&lut_view),
388 },
389 ],
390 });
391 super::blur::run_fullscreen(
392 ctx,
393 &pd.render_pipeline,
394 &bind_group,
395 &output_view,
396 "Lut pass",
397 );
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 fn identity_cube(size: usize) -> String {
407 let mut s = format!("# test\nLUT_3D_SIZE {size}\n");
408 let last = (size - 1) as f32;
409 for b in 0..size {
410 for g in 0..size {
411 for r in 0..size {
412 let (rf, gf, bf) = (r as f32 / last, g as f32 / last, b as f32 / last);
413 s.push_str(&format!("{rf} {gf} {bf}\n"));
414 }
415 }
416 }
417 s
418 }
419
420 #[test]
421 fn parse_cube_should_read_identity_grid() {
422 let (lut, size) = parse_cube(&identity_cube(2)).expect("parse");
423 assert_eq!(size, 2);
424 assert_eq!(lut.len(), 8);
425 assert_eq!(lut[1], [1.0, 0.0, 0.0]);
427 assert_eq!(lut[4], [0.0, 0.0, 1.0]);
429 }
430
431 #[test]
432 fn parse_cube_missing_size_should_error() {
433 assert!(parse_cube("0.0 0.0 0.0\n").is_err());
434 }
435
436 #[test]
437 fn lut_identity_should_be_noop() {
438 let (lut, size) = parse_cube(&identity_cube(17)).expect("parse");
439 let node = LutNode::from_grid(lut, size);
440 let original = vec![10u8, 128, 220, 255, 60, 90, 200, 255];
441 let mut rgba = original.clone();
442 node.process_cpu(&mut rgba, 2, 1);
443 for (a, b) in rgba.iter().zip(original.iter()) {
444 assert!(
445 (i32::from(*a) - i32::from(*b)).abs() <= 1,
446 "identity LUT must preserve the pixel; got {a} vs {b}"
447 );
448 }
449 }
450
451 #[test]
452 fn lut_known_shift_should_match_reference() {
453 let mut lut = vec![[0.0f32; 3]; 8];
455 for b in 0..2 {
456 for g in 0..2 {
457 for r in 0..2 {
458 lut[r + 2 * (g + 2 * b)] = [r as f32 / 2.0, g as f32 / 2.0, b as f32 / 2.0];
459 }
460 }
461 }
462 let node = LutNode::from_grid(lut, 2);
463 let mut rgba = vec![200u8, 100, 40, 255];
464 node.process_cpu(&mut rgba, 1, 1);
465 for (got, inp) in rgba[..3].iter().zip([200u8, 100, 40]) {
467 let expected = (f32::from(inp) / 2.0).round() as i32;
468 assert!(
469 (i32::from(*got) - expected).abs() <= 1,
470 "halving LUT: got {got}, expected ~{expected}"
471 );
472 }
473 }
474
475 #[test]
476 fn from_cube_missing_file_should_return_lut_load() {
477 assert!(matches!(
478 LutNode::from_cube(Path::new("does-not-exist-9f3a.cube")),
479 Err(RenderError::LutLoad { .. })
480 ));
481 }
482
483 #[test]
484 fn from_3dl_missing_file_should_return_lut_load() {
485 assert!(matches!(
486 LutNode::from_3dl(Path::new("does-not-exist-3a9f.3dl")),
487 Err(RenderError::LutLoad { .. })
488 ));
489 }
490
491 #[test]
492 fn from_cube_should_load_a_valid_file() {
493 let path = std::env::temp_dir().join(format!("avio_lut_{}.cube", std::process::id()));
496 std::fs::write(&path, identity_cube(9)).expect("write temp cube");
497 let node = LutNode::from_cube(&path).expect("load cube");
498 let _ = std::fs::remove_file(&path);
499 let original = vec![10u8, 128, 220, 255];
500 let mut rgba = original.clone();
501 node.process_cpu(&mut rgba, 1, 1);
502 for (a, b) in rgba.iter().zip(original.iter()) {
503 assert!(
504 (i32::from(*a) - i32::from(*b)).abs() <= 1,
505 "a loaded identity .cube must preserve the pixel; got {a} vs {b}"
506 );
507 }
508 }
509
510 #[test]
511 fn parse_3dl_should_read_grid() {
512 let mut s = String::from("# mesh\n0 1023\n");
515 for r in 0..2 {
516 for g in 0..2 {
517 for b in 0..2 {
518 let q = |v: usize| v * 1023;
519 s.push_str(&format!("{} {} {}\n", q(r), q(g), q(b)));
520 }
521 }
522 }
523 let (lut, size) = parse_3dl(&s).expect("parse 3dl");
524 assert_eq!(size, 2);
525 assert!((lut[1][0] - 1.0).abs() < 1e-3 && lut[1][1].abs() < 1e-3);
527 assert!((lut[4][2] - 1.0).abs() < 1e-3 && lut[4][0].abs() < 1e-3);
529 }
530}
531
532#[cfg(all(test, feature = "wgpu"))]
533mod gpu_tests {
534 use super::*;
535 use crate::context::RenderContext;
536 use crate::graph::RenderGraph;
537 use std::sync::Arc;
538
539 fn ctx() -> Option<Arc<RenderContext>> {
540 match futures::executor::block_on(RenderContext::init()) {
541 Ok(ctx) => Some(Arc::new(ctx)),
542 Err(_) => None,
543 }
544 }
545
546 fn identity_node(size: usize) -> LutNode {
547 let last = (size - 1) as f32;
548 let mut lut = vec![[0.0f32; 3]; size * size * size];
549 for b in 0..size {
550 for g in 0..size {
551 for r in 0..size {
552 lut[r + size * (g + size * b)] =
553 [r as f32 / last, g as f32 / last, b as f32 / last];
554 }
555 }
556 }
557 LutNode::from_grid(lut, size as u32)
558 }
559
560 #[test]
561 fn lut_gpu_identity_should_preserve_input() {
562 let Some(ctx) = ctx() else {
563 return;
564 };
565 let frame = vec![10u8, 128, 220, 255, 60, 90, 200, 255];
566 let gpu = RenderGraph::new(Arc::clone(&ctx))
567 .push(identity_node(17))
568 .process_gpu(&frame, 2, 1)
569 .expect("gpu lut");
570 for i in 0..frame.len() {
571 assert!(
572 (i32::from(gpu[i]) - i32::from(frame[i])).abs() <= 2,
573 "identity LUT must preserve the input on the GPU path at {i}"
574 );
575 }
576 }
577
578 #[test]
579 fn lut_gpu_halving_should_match_cpu() {
580 let Some(ctx) = ctx() else {
581 return;
582 };
583 let mut lut = vec![[0.0f32; 3]; 8];
585 for b in 0..2 {
586 for g in 0..2 {
587 for r in 0..2 {
588 lut[r + 2 * (g + 2 * b)] = [r as f32 / 2.0, g as f32 / 2.0, b as f32 / 2.0];
589 }
590 }
591 }
592 let frame = vec![200u8, 100, 40, 255];
593 let gpu = RenderGraph::new(Arc::clone(&ctx))
594 .push(LutNode::from_grid(lut.clone(), 2))
595 .process_gpu(&frame, 1, 1)
596 .expect("gpu lut");
597 let mut cpu = frame.clone();
598 LutNode::from_grid(lut, 2).process_cpu(&mut cpu, 1, 1);
599 for i in 0..3 {
600 assert!(
601 (i32::from(gpu[i]) - i32::from(cpu[i])).abs() <= 2,
602 "GPU and CPU LUT must agree at channel {i}: gpu={} cpu={}",
603 gpu[i],
604 cpu[i]
605 );
606 }
607 }
608}