Skip to main content

ff_render/sink/
mod.rs

1use std::time::Duration;
2
3use ff_preview::FrameSink;
4
5use crate::graph::RenderGraph;
6
7// TextureHandle
8
9/// A GPU texture together with its default view and dimensions.
10///
11/// Window systems (winit, egui) can blit this directly to the display
12/// surface without a CPU round-trip download.
13#[cfg(feature = "wgpu")]
14pub struct TextureHandle {
15    pub texture: wgpu::Texture,
16    pub view: wgpu::TextureView,
17    pub width: u32,
18    pub height: u32,
19}
20
21// GpuFrameSink
22
23/// A [`FrameSink`] that processes each frame through a [`RenderGraph`] before
24/// forwarding to a downstream sink.
25///
26/// When the `wgpu` feature is enabled and the graph was created with a GPU
27/// context, the GPU pipeline runs. On GPU error the unprocessed frame is
28/// forwarded as a fallback.
29///
30/// When the `wgpu` feature is **not** enabled (or the graph is CPU-only), the
31/// CPU fallback pipeline runs transparently.
32///
33/// # Example
34///
35/// ```ignore
36/// let ctx = Arc::new(RenderContext::init().await?);
37/// let graph = RenderGraph::new(ctx)
38///     .push(ColorGradeNode { brightness: 0.2, ..Default::default() });
39/// let sink = GpuFrameSink::new(graph, Box::new(RgbaSink::new()));
40/// runner.set_sink(Box::new(sink));
41/// ```
42pub struct GpuFrameSink {
43    graph: RenderGraph,
44    downstream: Box<dyn FrameSink>,
45}
46
47impl GpuFrameSink {
48    /// Construct a sink that applies `graph` to every incoming frame and
49    /// forwards the result to `downstream`.
50    #[must_use]
51    pub fn new(graph: RenderGraph, downstream: Box<dyn FrameSink>) -> Self {
52        Self { graph, downstream }
53    }
54}
55
56impl FrameSink for GpuFrameSink {
57    fn push_frame(&mut self, rgba: &[u8], width: u32, height: u32, pts: Duration) {
58        // Zero-copy path: when the downstream accepts a GPU frame, hand it the
59        // composited texture directly (no GPU-to-CPU readback). On failure, fall
60        // through to the readback path.
61        #[cfg(feature = "display")]
62        {
63            if self.downstream.accepts_gpu_frame() {
64                match self.graph.process_gpu_to_texture(rgba, width, height) {
65                    Ok(handle) => {
66                        self.downstream.push_frame_gpu(
67                            &handle.texture,
68                            &handle.view,
69                            handle.width,
70                            handle.height,
71                            pts,
72                        );
73                        return;
74                    }
75                    Err(e) => {
76                        log::warn!("GpuFrameSink zero-copy path failed, using readback error={e}");
77                    }
78                }
79            }
80        }
81        #[cfg(feature = "wgpu")]
82        {
83            match self.graph.process_gpu(rgba, width, height) {
84                Ok(processed) => {
85                    self.downstream.push_frame(&processed, width, height, pts);
86                    return;
87                }
88                Err(e) => {
89                    log::warn!("GpuFrameSink GPU processing failed, using CPU fallback error={e}");
90                }
91            }
92        }
93        // CPU fallback (also used when wgpu feature is disabled).
94        let processed = self.graph.process_cpu(rgba, width, height);
95        self.downstream.push_frame(&processed, width, height, pts);
96    }
97
98    fn flush(&mut self) {
99        self.downstream.flush();
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::sync::{Arc, Mutex};
107
108    use crate::nodes::ColorGradeNode;
109
110    struct CollectSink(Arc<Mutex<Vec<Vec<u8>>>>);
111
112    impl FrameSink for CollectSink {
113        fn push_frame(&mut self, rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
114            self.0
115                .lock()
116                .unwrap_or_else(std::sync::PoisonError::into_inner)
117                .push(rgba.to_vec());
118        }
119    }
120
121    #[test]
122    fn gpu_frame_sink_cpu_path_should_forward_processed_frame() {
123        // Use a CPU-only graph so no GPU device is required.
124        let graph = RenderGraph::new_cpu().push_cpu(ColorGradeNode::new(0.5, 1.0, 1.0, 0.0, 0.0));
125
126        let collected = Arc::new(Mutex::new(Vec::new()));
127        let downstream = Box::new(CollectSink(Arc::clone(&collected)));
128        let mut sink = GpuFrameSink::new(graph, downstream);
129
130        let pts = Duration::from_millis(0);
131        // When wgpu feature is enabled, process_gpu will fail (no ctx) and
132        // fall back to process_cpu — which is what we want for this test.
133        sink.push_frame(&[128u8, 128, 128, 255], 1, 1, pts);
134
135        let guard = collected
136            .lock()
137            .unwrap_or_else(std::sync::PoisonError::into_inner);
138        assert_eq!(guard.len(), 1, "exactly one frame must be forwarded");
139        assert!(
140            guard[0][0] > 128,
141            "brightness +0.5 must increase R channel; got {}",
142            guard[0][0]
143        );
144    }
145
146    #[test]
147    fn gpu_frame_sink_flush_should_propagate_to_downstream() {
148        struct FlushTracker(Arc<Mutex<bool>>);
149        impl FrameSink for FlushTracker {
150            fn push_frame(&mut self, _: &[u8], _: u32, _: u32, _: Duration) {}
151            fn flush(&mut self) {
152                *self
153                    .0
154                    .lock()
155                    .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
156            }
157        }
158
159        let flushed = Arc::new(Mutex::new(false));
160        let mut sink = GpuFrameSink::new(
161            RenderGraph::new_cpu(),
162            Box::new(FlushTracker(Arc::clone(&flushed))),
163        );
164        sink.flush();
165        assert!(
166            *flushed
167                .lock()
168                .unwrap_or_else(std::sync::PoisonError::into_inner),
169            "flush must propagate to downstream"
170        );
171    }
172
173    #[test]
174    fn gpu_frame_sink_should_be_send() {
175        fn assert_send<T: Send>() {}
176        assert_send::<GpuFrameSink>();
177    }
178}
179
180#[cfg(all(test, feature = "display"))]
181mod display_tests {
182    use std::sync::{Arc, Mutex, PoisonError};
183    use std::time::Duration;
184
185    use ff_preview::FrameSink;
186
187    use super::GpuFrameSink;
188    use crate::context::RenderContext;
189    use crate::graph::RenderGraph;
190    use crate::nodes::ColorGradeNode;
191
192    /// A headless GPU context, or `None` when no adapter is available (CI).
193    fn ctx() -> Option<Arc<RenderContext>> {
194        match futures::executor::block_on(RenderContext::init()) {
195            Ok(ctx) => Some(Arc::new(ctx)),
196            Err(_) => None,
197        }
198    }
199
200    /// Downstream that accepts a GPU frame and records its dimensions.
201    struct GpuCapture(Arc<Mutex<Option<(u32, u32)>>>);
202    impl FrameSink for GpuCapture {
203        fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
204            unreachable!("the zero-copy path must not call the CPU push_frame");
205        }
206        fn accepts_gpu_frame(&self) -> bool {
207            true
208        }
209        fn push_frame_gpu(
210            &mut self,
211            _texture: &wgpu::Texture,
212            _view: &wgpu::TextureView,
213            width: u32,
214            height: u32,
215            _pts: Duration,
216        ) {
217            *self.0.lock().unwrap_or_else(PoisonError::into_inner) = Some((width, height));
218        }
219    }
220
221    /// CPU-only downstream (default `accepts_gpu_frame() == false`).
222    struct CpuCapture(Arc<Mutex<bool>>);
223    impl FrameSink for CpuCapture {
224        fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
225            *self.0.lock().unwrap_or_else(PoisonError::into_inner) = true;
226        }
227    }
228
229    #[test]
230    fn gpu_sink_should_forward_a_texture_without_cpu_readback() {
231        let Some(ctx) = ctx() else {
232            return;
233        };
234        let graph =
235            RenderGraph::new(Arc::clone(&ctx)).push(ColorGradeNode::new(0.0, 1.0, 1.0, 0.0, 0.0));
236        let got = Arc::new(Mutex::new(None));
237        let mut sink = GpuFrameSink::new(graph, Box::new(GpuCapture(Arc::clone(&got))));
238
239        let (w, h) = (16u32, 16u32);
240        sink.push_frame(&vec![128u8; (w * h * 4) as usize], w, h, Duration::ZERO);
241
242        assert_eq!(
243            *got.lock().unwrap_or_else(PoisonError::into_inner),
244            Some((w, h)),
245            "the GPU downstream must receive the composited texture"
246        );
247        assert_eq!(
248            ctx.readback_count(),
249            0,
250            "the zero-copy display path must perform no GPU-to-CPU readback"
251        );
252    }
253
254    #[test]
255    fn gpu_sink_cpu_downstream_should_use_readback_path() {
256        let Some(ctx) = ctx() else {
257            return;
258        };
259        let graph =
260            RenderGraph::new(Arc::clone(&ctx)).push(ColorGradeNode::new(0.0, 1.0, 1.0, 0.0, 0.0));
261        let got = Arc::new(Mutex::new(false));
262        let mut sink = GpuFrameSink::new(graph, Box::new(CpuCapture(Arc::clone(&got))));
263
264        let (w, h) = (16u32, 16u32);
265        sink.push_frame(&vec![128u8; (w * h * 4) as usize], w, h, Duration::ZERO);
266
267        assert!(
268            *got.lock().unwrap_or_else(PoisonError::into_inner),
269            "a CPU-only downstream must receive a CPU frame"
270        );
271        assert_eq!(
272            ctx.readback_count(),
273            1,
274            "a CPU-only downstream falls back to the readback path (proves the counter moves)"
275        );
276    }
277}