Skip to main content

ff_render/graph/
mod.rs

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
16// ── RenderGraph ───────────────────────────────────────────────────────────────
17
18/// Linear chain of render nodes executed in insertion order.
19///
20/// The CPU fallback path ([`process_cpu`](Self::process_cpu)) is always
21/// available and does not require the `wgpu` feature.  When the `wgpu` feature
22/// is enabled, [`process_gpu`](Self::process_gpu) runs every node on the GPU.
23///
24/// # Construction
25///
26/// ```ignore
27/// // GPU+CPU graph (wgpu feature):
28/// let ctx = Arc::new(RenderContext::init().await?);
29/// let graph = RenderGraph::new(Arc::clone(&ctx))
30///     .push(ColorGradeNode { brightness: 0.1, ..Default::default() });
31///
32/// // CPU-only graph (no wgpu feature needed):
33/// let graph = RenderGraph::new_cpu()
34///     .push_cpu(ColorGradeNode { brightness: 0.1, ..Default::default() });
35/// ```
36pub struct RenderGraph {
37    /// Nodes for the CPU fallback path only (added via `push_cpu`).
38    cpu_nodes: Vec<Box<dyn RenderNodeCpu>>,
39    #[cfg(feature = "wgpu")]
40    gpu_nodes: Vec<Box<dyn RenderNode>>,
41    /// `None` when constructed via `new_cpu` — `process_gpu` will return an error.
42    #[cfg(feature = "wgpu")]
43    ctx: Option<Arc<RenderContext>>,
44}
45
46impl RenderGraph {
47    /// Create a GPU+CPU graph.
48    ///
49    /// Nodes added via [`push`](Self::push) run on the GPU and expose a CPU
50    /// fallback via [`RenderNodeCpu`].  Nodes added via
51    /// [`push_cpu`](Self::push_cpu) run on the CPU path only.
52    #[cfg(feature = "wgpu")]
53    #[must_use]
54    pub fn new(ctx: Arc<RenderContext>) -> Self {
55        Self {
56            cpu_nodes: Vec::new(),
57            gpu_nodes: Vec::new(),
58            ctx: Some(ctx),
59        }
60    }
61
62    /// Create a CPU-only graph (no GPU context required).
63    ///
64    /// [`process_gpu`](Self::process_gpu) returns [`RenderError::Composite`]
65    /// when called on a CPU-only graph. Use [`process_cpu`](Self::process_cpu)
66    /// instead.
67    #[must_use]
68    pub fn new_cpu() -> Self {
69        Self {
70            cpu_nodes: Vec::new(),
71            #[cfg(feature = "wgpu")]
72            gpu_nodes: Vec::new(),
73            #[cfg(feature = "wgpu")]
74            ctx: None,
75        }
76    }
77
78    /// Append a GPU+CPU node to the chain.
79    ///
80    /// The node must implement both [`RenderNode`] (GPU, `wgpu` feature only)
81    /// and [`RenderNodeCpu`] (CPU, always available) — the `RenderNode`
82    /// supertrait bound guarantees this.
83    #[cfg(feature = "wgpu")]
84    #[must_use]
85    pub fn push(mut self, node: impl RenderNode + 'static) -> Self {
86        self.gpu_nodes.push(Box::new(node));
87        self
88    }
89
90    /// Append a CPU-only node to the chain.
91    ///
92    /// CPU-only nodes participate in [`process_cpu`](Self::process_cpu) but
93    /// not in [`process_gpu`](Self::process_gpu).
94    ///
95    /// When the `wgpu` feature is not enabled, this is the only `push` method.
96    #[cfg(not(feature = "wgpu"))]
97    #[must_use]
98    pub fn push(mut self, node: impl RenderNodeCpu + 'static) -> Self {
99        self.cpu_nodes.push(Box::new(node));
100        self
101    }
102
103    /// Append a CPU-only node (available regardless of the `wgpu` feature).
104    #[must_use]
105    pub fn push_cpu(mut self, node: impl RenderNodeCpu + 'static) -> Self {
106        self.cpu_nodes.push(Box::new(node));
107        self
108    }
109
110    // ── Processing ────────────────────────────────────────────────────────────
111
112    /// Run the GPU pipeline: upload `rgba` → execute all GPU nodes → download result.
113    ///
114    /// Requires the `wgpu` feature and a GPU context (created via [`new`](Self::new)).
115    /// Returns [`RenderError::Composite`] if called on a CPU-only graph.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error on GPU device failure or staging-buffer readback failure.
120    #[cfg(feature = "wgpu")]
121    pub fn process_gpu(&self, rgba: &[u8], w: u32, h: u32) -> Result<Vec<u8>, RenderError> {
122        let ctx = self.ctx.as_ref().ok_or_else(|| RenderError::Composite {
123            message: "process_gpu called on a CPU-only RenderGraph (no RenderContext)".to_string(),
124        })?;
125        graph_inner::run_gpu(&self.gpu_nodes, ctx, rgba, w, h)
126    }
127
128    /// Run the CPU fallback pipeline: apply each node's `process_cpu` in order.
129    ///
130    /// Both CPU-only nodes (`push_cpu`) and GPU nodes (`push`, wgpu feature)
131    /// participate — GPU nodes expose a CPU path via the `RenderNodeCpu`
132    /// supertrait.
133    #[must_use]
134    pub fn process_cpu(&self, rgba: &[u8], w: u32, h: u32) -> Vec<u8> {
135        let mut out = rgba.to_vec();
136
137        for node in &self.cpu_nodes {
138            node.process_cpu(&mut out, w, h);
139        }
140
141        #[cfg(feature = "wgpu")]
142        for node in &self.gpu_nodes {
143            node.process_cpu(&mut out, w, h);
144        }
145
146        out
147    }
148}
149
150// ── Tests ─────────────────────────────────────────────────────────────────────
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::nodes::ColorGradeNode;
156
157    #[test]
158    fn render_graph_empty_cpu_should_return_input_unchanged() {
159        let graph = RenderGraph::new_cpu();
160        let rgba = vec![100u8, 150, 200, 255];
161        let result = graph.process_cpu(&rgba, 1, 1);
162        assert_eq!(result, rgba, "empty graph must return input unchanged");
163    }
164
165    #[test]
166    fn render_graph_push_cpu_color_grade_should_brighten() {
167        let graph = RenderGraph::new_cpu().push_cpu(ColorGradeNode::new(0.5, 1.0, 1.0, 0.0, 0.0));
168        let rgba = vec![128u8, 128, 128, 255];
169        let result = graph.process_cpu(&rgba, 1, 1);
170        assert!(
171            result[0] > 128,
172            "brightness +0.5 must increase R; got {}",
173            result[0]
174        );
175    }
176
177    #[test]
178    fn render_graph_multiple_cpu_nodes_should_chain() {
179        // Two brightness boosts: +0.1 then +0.1 → total ≈ +0.2.
180        let graph = RenderGraph::new_cpu()
181            .push_cpu(ColorGradeNode::new(0.1, 1.0, 1.0, 0.0, 0.0))
182            .push_cpu(ColorGradeNode::new(0.1, 1.0, 1.0, 0.0, 0.0));
183        let single = RenderGraph::new_cpu().push_cpu(ColorGradeNode::new(0.2, 1.0, 1.0, 0.0, 0.0));
184
185        let rgba = vec![100u8, 100, 100, 255];
186        let chained = graph.process_cpu(&rgba, 1, 1);
187        let single_result = single.process_cpu(&rgba, 1, 1);
188
189        // Both should produce similar (but not necessarily identical) results.
190        let diff = (chained[0] as i32 - single_result[0] as i32).abs();
191        assert!(
192            diff <= 2,
193            "chained vs single brightness boost must be close; got chained={} single={}",
194            chained[0],
195            single_result[0]
196        );
197    }
198}