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}