1#[cfg(feature = "wgpu")]
2mod graph_inner;
3
4use crate::nodes::RenderNodeCpu;
5
6#[cfg(feature = "wgpu")]
7use crate::error::RenderError;
8
9#[cfg(feature = "wgpu")]
10use crate::context::RenderContext;
11#[cfg(feature = "wgpu")]
12use crate::nodes::RenderNode;
13#[cfg(feature = "wgpu")]
14use std::sync::Arc;
15
16pub struct RenderGraph {
37 cpu_nodes: Vec<Box<dyn RenderNodeCpu>>,
39 #[cfg(feature = "wgpu")]
40 gpu_nodes: Vec<Box<dyn RenderNode>>,
41 #[cfg(feature = "wgpu")]
43 ctx: Option<Arc<RenderContext>>,
44 #[cfg(feature = "wgpu")]
48 internal_format: wgpu::TextureFormat,
49}
50
51#[cfg(feature = "wgpu")]
54#[must_use]
55pub(crate) fn select_texture_format(pf: ff_format::PixelFormat) -> wgpu::TextureFormat {
56 if pf.is_high_bit_depth() {
57 wgpu::TextureFormat::Rgba16Float
58 } else {
59 wgpu::TextureFormat::Rgba8Unorm
60 }
61}
62
63impl RenderGraph {
64 #[cfg(feature = "wgpu")]
70 #[must_use]
71 pub fn new(ctx: Arc<RenderContext>) -> Self {
72 Self {
73 cpu_nodes: Vec::new(),
74 gpu_nodes: Vec::new(),
75 ctx: Some(ctx),
76 internal_format: wgpu::TextureFormat::Rgba8Unorm,
77 }
78 }
79
80 #[must_use]
86 pub fn new_cpu() -> Self {
87 Self {
88 cpu_nodes: Vec::new(),
89 #[cfg(feature = "wgpu")]
90 gpu_nodes: Vec::new(),
91 #[cfg(feature = "wgpu")]
92 ctx: None,
93 #[cfg(feature = "wgpu")]
94 internal_format: wgpu::TextureFormat::Rgba8Unorm,
95 }
96 }
97
98 #[cfg(feature = "wgpu")]
110 #[must_use]
111 pub fn with_pixel_format(mut self, pf: ff_format::PixelFormat) -> Self {
112 self.internal_format = select_texture_format(pf);
113 self
114 }
115
116 #[cfg(feature = "wgpu")]
124 #[must_use]
125 pub fn internal_format(&self) -> wgpu::TextureFormat {
126 self.internal_format
127 }
128
129 #[cfg(feature = "wgpu")]
135 #[must_use]
136 pub fn push(mut self, node: impl RenderNode + 'static) -> Self {
137 self.gpu_nodes.push(Box::new(node));
138 self
139 }
140
141 #[cfg(not(feature = "wgpu"))]
148 #[must_use]
149 pub fn push(mut self, node: impl RenderNodeCpu + 'static) -> Self {
150 self.cpu_nodes.push(Box::new(node));
151 self
152 }
153
154 #[must_use]
156 pub fn push_cpu(mut self, node: impl RenderNodeCpu + 'static) -> Self {
157 self.cpu_nodes.push(Box::new(node));
158 self
159 }
160
161 #[cfg(feature = "wgpu")]
178 pub fn process_gpu(&self, rgba: &[u8], w: u32, h: u32) -> Result<Vec<u8>, RenderError> {
179 let ctx = self.ctx.as_ref().ok_or_else(|| RenderError::Composite {
180 message: "process_gpu called on a CPU-only RenderGraph (no RenderContext)".to_string(),
181 })?;
182 graph_inner::run_gpu(&self.gpu_nodes, ctx, rgba, w, h, self.internal_format)
183 }
184
185 #[cfg(feature = "wgpu")]
198 pub fn process_gpu_to_texture(
199 &self,
200 rgba: &[u8],
201 w: u32,
202 h: u32,
203 ) -> Result<crate::sink::TextureHandle, RenderError> {
204 let ctx = self.ctx.as_ref().ok_or_else(|| RenderError::Composite {
205 message: "process_gpu_to_texture called on a CPU-only RenderGraph (no RenderContext)"
206 .to_string(),
207 })?;
208 graph_inner::run_gpu_to_texture(&self.gpu_nodes, ctx, rgba, w, h, self.internal_format)
209 }
210
211 #[must_use]
217 pub fn process_cpu(&self, rgba: &[u8], w: u32, h: u32) -> Vec<u8> {
218 let mut out = rgba.to_vec();
219
220 for node in &self.cpu_nodes {
221 node.process_cpu(&mut out, w, h);
222 }
223
224 #[cfg(feature = "wgpu")]
225 for node in &self.gpu_nodes {
226 node.process_cpu(&mut out, w, h);
227 }
228
229 out
230 }
231
232 #[cfg(feature = "wgpu")]
239 #[must_use]
240 pub fn set_param(&self, param: crate::NodeParam) -> usize {
241 self.gpu_nodes
242 .iter()
243 .filter(|node| node.set_param(param))
244 .count()
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::nodes::ColorGradeNode;
252
253 #[test]
254 fn render_graph_empty_cpu_should_return_input_unchanged() {
255 let graph = RenderGraph::new_cpu();
256 let rgba = vec![100u8, 150, 200, 255];
257 let result = graph.process_cpu(&rgba, 1, 1);
258 assert_eq!(result, rgba, "empty graph must return input unchanged");
259 }
260
261 #[test]
262 fn render_graph_push_cpu_color_grade_should_brighten() {
263 let graph = RenderGraph::new_cpu().push_cpu(ColorGradeNode::new(0.5, 1.0, 1.0, 0.0, 0.0));
264 let rgba = vec![128u8, 128, 128, 255];
265 let result = graph.process_cpu(&rgba, 1, 1);
266 assert!(
267 result[0] > 128,
268 "brightness +0.5 must increase R; got {}",
269 result[0]
270 );
271 }
272
273 #[test]
274 fn render_graph_multiple_cpu_nodes_should_chain() {
275 let graph = RenderGraph::new_cpu()
277 .push_cpu(ColorGradeNode::new(0.1, 1.0, 1.0, 0.0, 0.0))
278 .push_cpu(ColorGradeNode::new(0.1, 1.0, 1.0, 0.0, 0.0));
279 let single = RenderGraph::new_cpu().push_cpu(ColorGradeNode::new(0.2, 1.0, 1.0, 0.0, 0.0));
280
281 let rgba = vec![100u8, 100, 100, 255];
282 let chained = graph.process_cpu(&rgba, 1, 1);
283 let single_result = single.process_cpu(&rgba, 1, 1);
284
285 let diff = (chained[0] as i32 - single_result[0] as i32).abs();
287 assert!(
288 diff <= 2,
289 "chained vs single brightness boost must be close; got chained={} single={}",
290 chained[0],
291 single_result[0]
292 );
293 }
294}
295
296#[cfg(all(test, feature = "wgpu"))]
297mod gpu_tests {
298 use super::{Arc, RenderContext, RenderGraph};
299 use crate::nodes::{ColorGradeNode, RenderNode, RenderNodeCpu};
300
301 fn ctx() -> Option<Arc<RenderContext>> {
303 match futures::executor::block_on(RenderContext::init()) {
304 Ok(ctx) => Some(Arc::new(ctx)),
305 Err(_) => None,
306 }
307 }
308
309 fn fill(ctx: &RenderContext, tex: &wgpu::Texture, color: [u8; 4]) {
311 let (w, h) = (tex.width(), tex.height());
312 let data: Vec<u8> = color
313 .iter()
314 .copied()
315 .cycle()
316 .take((w * h * 4) as usize)
317 .collect();
318 ctx.queue.write_texture(
319 wgpu::TexelCopyTextureInfo {
320 texture: tex,
321 mip_level: 0,
322 origin: wgpu::Origin3d::ZERO,
323 aspect: wgpu::TextureAspect::All,
324 },
325 &data,
326 wgpu::TexelCopyBufferLayout {
327 offset: 0,
328 bytes_per_row: Some(w * 4),
329 rows_per_image: None,
330 },
331 wgpu::Extent3d {
332 width: w,
333 height: h,
334 depth_or_array_layers: 1,
335 },
336 );
337 }
338
339 const COLOR_A: [u8; 4] = [10, 20, 30, 255];
340 const COLOR_B: [u8; 4] = [200, 150, 100, 255];
341 const GREEN: [u8; 4] = [0, 255, 0, 255];
342 const RED: [u8; 4] = [255, 0, 0, 255];
343
344 struct TwoPassNode;
348 impl RenderNodeCpu for TwoPassNode {
349 fn process_cpu(&self, _rgba: &mut [u8], _w: u32, _h: u32) {}
350 }
351 impl RenderNode for TwoPassNode {
352 fn pass_count(&self) -> usize {
353 2
354 }
355 fn process(
356 &self,
357 _inputs: &[&wgpu::Texture],
358 outputs: &[&wgpu::Texture],
359 ctx: &RenderContext,
360 ) {
361 if outputs.len() >= 2 {
362 fill(ctx, outputs[0], COLOR_A);
363 fill(ctx, outputs[1], COLOR_B);
364 } else {
365 fill(ctx, outputs[0], COLOR_A);
366 }
367 }
368 }
369
370 struct TwoInputNode;
373 impl RenderNodeCpu for TwoInputNode {
374 fn process_cpu(&self, _rgba: &mut [u8], _w: u32, _h: u32) {}
375 }
376 impl RenderNode for TwoInputNode {
377 fn input_count(&self) -> usize {
378 2
379 }
380 fn process(
381 &self,
382 inputs: &[&wgpu::Texture],
383 outputs: &[&wgpu::Texture],
384 ctx: &RenderContext,
385 ) {
386 let color = if inputs.len() == 2 { GREEN } else { RED };
387 fill(ctx, outputs[0], color);
388 }
389 }
390
391 #[test]
392 fn executor_should_run_a_two_pass_node_and_read_back_the_final_pass() {
393 let Some(ctx) = ctx() else {
394 return;
395 };
396 let graph = RenderGraph::new(Arc::clone(&ctx)).push(TwoPassNode);
397 let (w, h) = (16u32, 16u32);
398 let rgba = vec![0u8; (w * h * 4) as usize];
399
400 let out = graph.process_gpu(&rgba, w, h).expect("two-pass frame");
401 assert_eq!(
402 &out[0..4],
403 &COLOR_B,
404 "the final pass (COLOR_B) must be read back; got {:?}",
405 &out[0..4]
406 );
407 }
408
409 #[test]
410 fn executor_should_feed_two_inputs_to_a_multi_input_node() {
411 let Some(ctx) = ctx() else {
412 return;
413 };
414 let graph = RenderGraph::new(Arc::clone(&ctx)).push(TwoInputNode);
415 let (w, h) = (16u32, 16u32);
416 let rgba = vec![0u8; (w * h * 4) as usize];
417
418 let out = graph.process_gpu(&rgba, w, h).expect("two-input frame");
419 assert_eq!(
420 &out[0..4],
421 &GREEN,
422 "receiving two inputs must produce GREEN; got {:?}",
423 &out[0..4]
424 );
425 }
426
427 fn alloc_count(ctx: &RenderContext) -> usize {
428 ctx.pool
429 .lock()
430 .unwrap_or_else(std::sync::PoisonError::into_inner)
431 .alloc_count()
432 }
433
434 #[test]
435 fn render_graph_should_not_allocate_textures_after_the_first_frame() {
436 let Some(ctx) = ctx() else {
437 return;
438 };
439 let graph =
441 RenderGraph::new(Arc::clone(&ctx)).push(ColorGradeNode::new(0.0, 1.0, 1.0, 0.0, 0.0));
442 let (w, h) = (16u32, 16u32);
443 let rgba = vec![128u8; (w * h * 4) as usize];
444
445 graph.process_gpu(&rgba, w, h).expect("first frame");
446 let after_first = alloc_count(&ctx);
447 assert!(
448 after_first > 0,
449 "the first frame must allocate its textures; got {after_first}"
450 );
451
452 for _ in 0..3 {
453 graph.process_gpu(&rgba, w, h).expect("subsequent frame");
454 }
455 assert_eq!(
456 alloc_count(&ctx),
457 after_first,
458 "same-size frames must reuse pooled textures (steady state = 0 allocations/frame)"
459 );
460 }
461
462 #[test]
463 fn process_gpu_to_texture_should_return_handle_of_input_dimensions() {
464 let Some(ctx) = ctx() else {
465 return;
466 };
467 let graph =
468 RenderGraph::new(Arc::clone(&ctx)).push(ColorGradeNode::new(0.0, 1.0, 1.0, 0.0, 0.0));
469 let (w, h) = (16u32, 16u32);
470 let rgba = vec![128u8; (w * h * 4) as usize];
471
472 let handle = graph
473 .process_gpu_to_texture(&rgba, w, h)
474 .expect("texture handle");
475 assert_eq!(handle.width, w, "handle width must match input");
476 assert_eq!(handle.height, h, "handle height must match input");
477 assert_eq!(handle.texture.width(), w, "GPU texture width must match");
478 assert_eq!(handle.texture.height(), h, "GPU texture height must match");
479 assert_eq!(
480 ctx.readback_count(),
481 0,
482 "the texture path must not read back to system memory"
483 );
484 }
485
486 #[test]
487 fn scale_gpu_should_produce_requested_dimensions() {
488 use crate::nodes::{ScaleAlgorithm, ScaleNode};
489
490 let Some(ctx) = ctx() else {
491 return;
492 };
493 let (in_w, in_h) = (8u32, 8u32);
494 let (out_w, out_h) = (4u32, 2u32);
495 let graph = RenderGraph::new(Arc::clone(&ctx)).push(ScaleNode::new(
496 out_w,
497 out_h,
498 ScaleAlgorithm::Bilinear,
499 ));
500 let rgba = vec![128u8; (in_w * in_h * 4) as usize];
501
502 let handle = graph
503 .process_gpu_to_texture(&rgba, in_w, in_h)
504 .expect("scaled texture");
505 assert_eq!(
506 (handle.width, handle.height),
507 (out_w, out_h),
508 "handle must report the requested dimensions, not the input size"
509 );
510 assert_eq!(
511 (handle.texture.width(), handle.texture.height()),
512 (out_w, out_h),
513 "the GPU texture must be allocated at the requested dimensions"
514 );
515 }
516
517 #[test]
518 fn scale_gpu_downscale_solid_should_preserve_colour() {
519 use crate::nodes::{ScaleAlgorithm, ScaleNode};
520
521 let Some(ctx) = ctx() else {
522 return;
523 };
524 let (in_w, in_h) = (8u32, 8u32);
525 let (out_w, out_h) = (2u32, 2u32);
526 let mut rgba = Vec::new();
527 for _ in 0..(in_w * in_h) {
528 rgba.extend_from_slice(&[200, 100, 50, 255]);
529 }
530 let graph = RenderGraph::new(Arc::clone(&ctx)).push(ScaleNode::new(
531 out_w,
532 out_h,
533 ScaleAlgorithm::Bilinear,
534 ));
535
536 let out = graph
537 .process_gpu(&rgba, in_w, in_h)
538 .expect("downscaled bytes");
539 assert_eq!(
540 out.len(),
541 (out_w * out_h * 4) as usize,
542 "readback must be at the scaled size (proves the resize happened)"
543 );
544 for px in out.chunks_exact(4) {
545 assert!(
546 (i32::from(px[0]) - 200).abs() <= 4,
547 "R must be preserved through downscale; got {}",
548 px[0]
549 );
550 assert!(
551 (i32::from(px[1]) - 100).abs() <= 4,
552 "G must be preserved; got {}",
553 px[1]
554 );
555 assert!(
556 (i32::from(px[2]) - 50).abs() <= 4,
557 "B must be preserved; got {}",
558 px[2]
559 );
560 }
561 }
562
563 #[allow(clippy::cast_precision_loss)]
566 fn f16_to_f32(bits: u16) -> f32 {
567 let sign = if bits & 0x8000 != 0 { -1.0 } else { 1.0 };
568 let exp = i32::from((bits >> 10) & 0x1f);
569 let frac = f32::from(bits & 0x3ff);
570 if exp == 0 {
571 sign * frac * 2f32.powi(-24)
572 } else if exp == 0x1f {
573 sign * f32::INFINITY
574 } else {
575 sign * (1.0 + frac / 1024.0) * 2f32.powi(exp - 15)
576 }
577 }
578
579 fn plane10(value: u16, count: usize) -> Vec<u8> {
581 value
582 .to_le_bytes()
583 .iter()
584 .copied()
585 .cycle()
586 .take(count * 2)
587 .collect()
588 }
589
590 #[test]
591 fn pipeline_should_select_rgba16float_for_10bit_input() {
592 use super::select_texture_format;
593 use ff_format::PixelFormat;
594
595 assert_eq!(
596 select_texture_format(PixelFormat::Yuv420p10le),
597 wgpu::TextureFormat::Rgba16Float,
598 "10-bit planar input must select Rgba16Float"
599 );
600 assert_eq!(
601 select_texture_format(PixelFormat::P010le),
602 wgpu::TextureFormat::Rgba16Float,
603 "10-bit semi-planar input must select Rgba16Float"
604 );
605 assert_eq!(
606 select_texture_format(PixelFormat::Yuv420p),
607 wgpu::TextureFormat::Rgba8Unorm,
608 "8-bit input must stay Rgba8Unorm"
609 );
610 assert_eq!(
611 select_texture_format(PixelFormat::Rgba),
612 wgpu::TextureFormat::Rgba8Unorm,
613 "8-bit RGBA input must stay Rgba8Unorm"
614 );
615 assert_eq!(
617 RenderGraph::new_cpu()
618 .with_pixel_format(PixelFormat::Yuv420p10le)
619 .internal_format(),
620 wgpu::TextureFormat::Rgba16Float
621 );
622 assert_eq!(
623 RenderGraph::new_cpu()
624 .with_pixel_format(PixelFormat::Yuv420p)
625 .internal_format(),
626 wgpu::TextureFormat::Rgba8Unorm
627 );
628 }
629
630 #[test]
631 fn yuv_upload_should_preserve_10bit_precision_into_rgba16float() {
632 use ff_format::PixelFormat;
633
634 use crate::nodes::{YuvFormat, YuvUploadNode};
635
636 let Some(ctx) = ctx() else {
637 return;
638 };
639 let (w, h) = (2u32, 2u32);
640
641 let render = |y10: u16| -> f32 {
644 let mut node = YuvUploadNode::new_high_bit_depth(YuvFormat::Yuv420p, w, h);
645 node.set_planes(plane10(y10, 4), plane10(512, 1), plane10(512, 1));
647 let graph = RenderGraph::new(Arc::clone(&ctx))
648 .with_pixel_format(PixelFormat::Yuv420p10le)
649 .push(node);
650 let out = graph.process_gpu(&[], w, h).expect("hdr frame");
652 assert_eq!(
653 out.len(),
654 (w * h * 8) as usize,
655 "Rgba16Float readback must be 8 bytes/pixel"
656 );
657 f16_to_f32(u16::from_le_bytes([out[0], out[1]]))
658 };
659
660 let a = render(512);
664 let b = render(515);
665 assert!(
666 (a - 512.0 / 1023.0).abs() < 0.01,
667 "Y=512 must decode to ~0.5005; got {a}"
668 );
669 assert!(
670 (b - 515.0 / 1023.0).abs() < 0.01,
671 "Y=515 must decode to ~0.5034; got {b}"
672 );
673 assert!(
674 (b - a).abs() > 0.0015,
675 "10-bit precision must distinguish Y=512 from Y=515; got a={a} b={b}"
676 );
677 }
678
679 const P010_SHIFT: u32 = 6;
681
682 #[test]
683 fn p010_upload_should_preserve_10bit_precision_into_rgba16float() {
684 use ff_format::PixelFormat;
685
686 use crate::nodes::YuvUploadNode;
687
688 let Some(ctx) = ctx() else {
689 return;
690 };
691 let (w, h) = (2u32, 2u32);
692
693 let render = |y10: u16| -> f32 {
696 let mut node = YuvUploadNode::new_p010(w, h);
697 node.set_planes_semi_planar(
700 plane10(y10 << P010_SHIFT, 4),
701 plane10(512 << P010_SHIFT, 2),
702 );
703 let graph = RenderGraph::new(Arc::clone(&ctx))
704 .with_pixel_format(PixelFormat::P010le)
705 .push(node);
706 let out = graph.process_gpu(&[], w, h).expect("hdr frame");
708 assert_eq!(
709 out.len(),
710 (w * h * 8) as usize,
711 "Rgba16Float readback must be 8 bytes/pixel"
712 );
713 f16_to_f32(u16::from_le_bytes([out[0], out[1]]))
714 };
715
716 let a = render(512);
719 let b = render(515);
720 assert!(
721 (a - 512.0 / 1023.0).abs() < 0.01,
722 "P010 Y=512 must decode to ~0.5005; got {a}"
723 );
724 assert!(
725 (b - 515.0 / 1023.0).abs() < 0.01,
726 "P010 Y=515 must decode to ~0.5034; got {b}"
727 );
728 assert!(
729 (b - a).abs() > 0.0015,
730 "10-bit precision must distinguish Y=512 from Y=515; got a={a} b={b}"
731 );
732 }
733
734 #[test]
735 fn p010_upload_gpu_should_match_planar_10bit_upload() {
736 use ff_format::PixelFormat;
737
738 use crate::nodes::{YuvFormat, YuvUploadNode};
739
740 const Y: [u16; 8] = [200, 500, 800, 300, 900, 100, 600, 400];
741 const CB: [u16; 2] = [300, 700];
742 const CR: [u16; 2] = [800, 200];
743
744 let Some(ctx) = ctx() else {
745 return;
746 };
747 let (w, h) = (4u32, 2u32);
752
753 let samples = |values: &[u16], shift: u32| -> Vec<u8> {
754 values
755 .iter()
756 .flat_map(|v| (*v << shift).to_le_bytes())
757 .collect()
758 };
759 let decode = |out: &[u8]| -> Vec<f32> {
760 out.chunks_exact(2)
761 .map(|c| f16_to_f32(u16::from_le_bytes([c[0], c[1]])))
762 .collect()
763 };
764
765 let mut planar = YuvUploadNode::new_high_bit_depth(YuvFormat::Yuv420p, w, h);
766 planar.set_planes(samples(&Y, 0), samples(&CB, 0), samples(&CR, 0));
767 let expected = decode(
768 &RenderGraph::new(Arc::clone(&ctx))
769 .with_pixel_format(PixelFormat::Yuv420p10le)
770 .push(planar)
771 .process_gpu(&[], w, h)
772 .expect("planar hdr frame"),
773 );
774
775 let mut p010 = YuvUploadNode::new_p010(w, h);
776 p010.set_planes_semi_planar(
777 samples(&Y, P010_SHIFT),
778 samples(&[CB[0], CR[0], CB[1], CR[1]], P010_SHIFT),
779 );
780 let got = decode(
781 &RenderGraph::new(Arc::clone(&ctx))
782 .with_pixel_format(PixelFormat::P010le)
783 .push(p010)
784 .process_gpu(&[], w, h)
785 .expect("p010 hdr frame"),
786 );
787
788 assert_eq!(
789 got.len(),
790 expected.len(),
791 "both graphs must read back the same number of channels"
792 );
793 for (i, (g, e)) in got.iter().zip(&expected).enumerate() {
794 assert!(
795 (g - e).abs() < 0.002,
796 "channel {i} must match the planar path: p010={g} planar={e}"
797 );
798 }
799 let red_col0 = got[0];
803 let red_col2 = got[2 * 4];
804 assert!(
805 (red_col0 - red_col2).abs() > 0.1,
806 "the chroma columns must differ in the output; got {red_col0} and {red_col2}"
807 );
808 }
809}