Skip to main content

ff_render/nodes/
crossfade.rs

1use super::RenderNodeCpu;
2
3// ── Pipeline cache ────────────────────────────────────────────────────────────
4
5#[cfg(feature = "wgpu")]
6struct CrossfadePipeline {
7    render_pipeline: wgpu::RenderPipeline,
8    bind_group_layout: wgpu::BindGroupLayout,
9    sampler: wgpu::Sampler,
10    uniform_buf: wgpu::Buffer,
11}
12
13// ── CrossfadeNode ─────────────────────────────────────────────────────────────
14
15/// Linear crossfade between two RGBA frames.
16///
17/// - `factor = 0.0` → output equals the input frame (`inputs[0]` / `process_cpu` argument).
18/// - `factor = 1.0` → output equals `to_rgba`.
19/// - `factor = 0.5` → arithmetic mean of both frames.
20///
21/// The "to" frame is stored in the node at construction time.
22pub struct CrossfadeNode {
23    /// Blend factor: 0.0 (from) → 1.0 (to).
24    pub factor: f32,
25    /// The "to" frame as RGBA bytes. Length must equal `to_width × to_height × 4`.
26    pub to_rgba: Vec<u8>,
27    /// Width of `to_rgba`.
28    pub to_width: u32,
29    /// Height of `to_rgba`.
30    pub to_height: u32,
31    #[cfg(feature = "wgpu")]
32    pipeline: std::sync::OnceLock<CrossfadePipeline>,
33}
34
35impl CrossfadeNode {
36    /// Construct a crossfade node.
37    ///
38    /// `to_rgba` is the "to" frame (second input). It must be
39    /// `to_width × to_height × 4` bytes of RGBA data.
40    #[must_use]
41    pub fn new(factor: f32, to_rgba: Vec<u8>, to_width: u32, to_height: u32) -> Self {
42        Self {
43            factor,
44            to_rgba,
45            to_width,
46            to_height,
47            #[cfg(feature = "wgpu")]
48            pipeline: std::sync::OnceLock::new(),
49        }
50    }
51}
52
53// ── CPU path ──────────────────────────────────────────────────────────────────
54
55impl RenderNodeCpu for CrossfadeNode {
56    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
57    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
58        if self.to_rgba.len() != rgba.len() {
59            log::warn!(
60                "CrossfadeNode::process_cpu skipped: size mismatch from={} to={}",
61                rgba.len(),
62                self.to_rgba.len()
63            );
64            return;
65        }
66        for (src, dst) in rgba.iter_mut().zip(self.to_rgba.iter()) {
67            let blended = (1.0 - self.factor) * f32::from(*src) + self.factor * f32::from(*dst);
68            *src = (blended + 0.5) as u8;
69        }
70    }
71}
72
73// ── GPU path ──────────────────────────────────────────────────────────────────
74
75#[cfg(feature = "wgpu")]
76impl CrossfadeNode {
77    #[allow(clippy::too_many_lines)]
78    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &CrossfadePipeline {
79        self.pipeline.get_or_init(|| {
80            let device = &ctx.device;
81
82            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
83                label: Some("Crossfade shader"),
84                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/crossfade.wgsl").into()),
85            });
86
87            // binding 0: tex_from, 1: tex_to, 2: sampler, 3: uniforms
88            let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
89                label: Some("Crossfade BGL"),
90                entries: &[
91                    wgpu::BindGroupLayoutEntry {
92                        binding: 0,
93                        visibility: wgpu::ShaderStages::FRAGMENT,
94                        ty: wgpu::BindingType::Texture {
95                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
96                            view_dimension: wgpu::TextureViewDimension::D2,
97                            multisampled: false,
98                        },
99                        count: None,
100                    },
101                    wgpu::BindGroupLayoutEntry {
102                        binding: 1,
103                        visibility: wgpu::ShaderStages::FRAGMENT,
104                        ty: wgpu::BindingType::Texture {
105                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
106                            view_dimension: wgpu::TextureViewDimension::D2,
107                            multisampled: false,
108                        },
109                        count: None,
110                    },
111                    wgpu::BindGroupLayoutEntry {
112                        binding: 2,
113                        visibility: wgpu::ShaderStages::FRAGMENT,
114                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
115                        count: None,
116                    },
117                    wgpu::BindGroupLayoutEntry {
118                        binding: 3,
119                        visibility: wgpu::ShaderStages::FRAGMENT,
120                        ty: wgpu::BindingType::Buffer {
121                            ty: wgpu::BufferBindingType::Uniform,
122                            has_dynamic_offset: false,
123                            min_binding_size: None,
124                        },
125                        count: None,
126                    },
127                ],
128            });
129
130            let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
131                label: Some("Crossfade layout"),
132                bind_group_layouts: &[Some(&bgl)],
133                immediate_size: 0,
134            });
135
136            let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
137                label: Some("Crossfade pipeline"),
138                layout: Some(&pipeline_layout),
139                vertex: wgpu::VertexState {
140                    module: &shader,
141                    entry_point: Some("vs_main"),
142                    buffers: &[],
143                    compilation_options: wgpu::PipelineCompilationOptions::default(),
144                },
145                fragment: Some(wgpu::FragmentState {
146                    module: &shader,
147                    entry_point: Some("fs_main"),
148                    targets: &[Some(wgpu::ColorTargetState {
149                        format: wgpu::TextureFormat::Rgba8Unorm,
150                        blend: None,
151                        write_mask: wgpu::ColorWrites::ALL,
152                    })],
153                    compilation_options: wgpu::PipelineCompilationOptions::default(),
154                }),
155                primitive: wgpu::PrimitiveState::default(),
156                depth_stencil: None,
157                multisample: wgpu::MultisampleState::default(),
158                multiview_mask: None,
159                cache: None,
160            });
161
162            let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
163                label: Some("Crossfade sampler"),
164                address_mode_u: wgpu::AddressMode::ClampToEdge,
165                address_mode_v: wgpu::AddressMode::ClampToEdge,
166                mag_filter: wgpu::FilterMode::Linear,
167                min_filter: wgpu::FilterMode::Linear,
168                ..Default::default()
169            });
170
171            // 4 × f32 = 16 bytes — matches CrossfadeUniforms in the shader.
172            let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
173                label: Some("Crossfade uniforms"),
174                size: 16,
175                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
176                mapped_at_creation: false,
177            });
178
179            CrossfadePipeline {
180                render_pipeline,
181                bind_group_layout: bgl,
182                sampler,
183                uniform_buf,
184            }
185        })
186    }
187}
188
189#[cfg(feature = "wgpu")]
190impl super::RenderNode for CrossfadeNode {
191    fn input_count(&self) -> usize {
192        2
193    }
194
195    fn process(
196        &self,
197        inputs: &[&wgpu::Texture],
198        outputs: &[&wgpu::Texture],
199        ctx: &crate::context::RenderContext,
200    ) {
201        let Some(tex_from) = inputs.first() else {
202            log::warn!("CrossfadeNode::process called with no inputs");
203            return;
204        };
205        let Some(output) = outputs.first() else {
206            log::warn!("CrossfadeNode::process called with no outputs");
207            return;
208        };
209
210        let pd = self.get_or_create_pipeline(ctx);
211
212        // Write factor uniform.
213        let uniform_bytes: Vec<u8> = [self.factor, 0.0_f32, 0.0_f32, 0.0_f32]
214            .iter()
215            .flat_map(|f| f.to_le_bytes())
216            .collect();
217        ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniform_bytes);
218
219        // Upload the "to" frame to a temporary GPU texture.
220        let to_tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
221            label: Some("Crossfade to_tex"),
222            size: wgpu::Extent3d {
223                width: self.to_width,
224                height: self.to_height,
225                depth_or_array_layers: 1,
226            },
227            mip_level_count: 1,
228            sample_count: 1,
229            dimension: wgpu::TextureDimension::D2,
230            format: wgpu::TextureFormat::Rgba8Unorm,
231            usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
232            view_formats: &[],
233        });
234        ctx.queue.write_texture(
235            wgpu::TexelCopyTextureInfo {
236                texture: &to_tex,
237                mip_level: 0,
238                origin: wgpu::Origin3d::ZERO,
239                aspect: wgpu::TextureAspect::All,
240            },
241            &self.to_rgba,
242            wgpu::TexelCopyBufferLayout {
243                offset: 0,
244                bytes_per_row: Some(self.to_width * 4),
245                rows_per_image: None,
246            },
247            wgpu::Extent3d {
248                width: self.to_width,
249                height: self.to_height,
250                depth_or_array_layers: 1,
251            },
252        );
253
254        let from_view = tex_from.create_view(&wgpu::TextureViewDescriptor::default());
255        let to_view = to_tex.create_view(&wgpu::TextureViewDescriptor::default());
256        let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
257
258        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
259            label: Some("Crossfade BG"),
260            layout: &pd.bind_group_layout,
261            entries: &[
262                wgpu::BindGroupEntry {
263                    binding: 0,
264                    resource: wgpu::BindingResource::TextureView(&from_view),
265                },
266                wgpu::BindGroupEntry {
267                    binding: 1,
268                    resource: wgpu::BindingResource::TextureView(&to_view),
269                },
270                wgpu::BindGroupEntry {
271                    binding: 2,
272                    resource: wgpu::BindingResource::Sampler(&pd.sampler),
273                },
274                wgpu::BindGroupEntry {
275                    binding: 3,
276                    resource: pd.uniform_buf.as_entire_binding(),
277                },
278            ],
279        });
280
281        let mut encoder = ctx
282            .device
283            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
284                label: Some("Crossfade pass"),
285            });
286        {
287            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
288                label: Some("Crossfade pass"),
289                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
290                    view: &out_view,
291                    resolve_target: None,
292                    depth_slice: None,
293                    ops: wgpu::Operations {
294                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
295                        store: wgpu::StoreOp::Store,
296                    },
297                })],
298                depth_stencil_attachment: None,
299                timestamp_writes: None,
300                occlusion_query_set: None,
301                multiview_mask: None,
302            });
303            pass.set_pipeline(&pd.render_pipeline);
304            pass.set_bind_group(0, &bind_group, &[]);
305            pass.draw(0..6, 0..1);
306        }
307        ctx.queue.submit(std::iter::once(encoder.finish()));
308    }
309}
310
311// ── Tests ─────────────────────────────────────────────────────────────────────
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn crossfade_node_factor_zero_should_return_from_frame() {
319        let to = vec![200u8, 200, 200, 255];
320        let node = CrossfadeNode::new(0.0, to, 1, 1);
321        let mut rgba = vec![50u8, 60, 70, 255];
322        let original = rgba.clone();
323        node.process_cpu(&mut rgba, 1, 1);
324        assert_eq!(rgba[0], original[0], "factor=0 must keep from-frame R");
325    }
326
327    #[test]
328    fn crossfade_node_factor_one_should_return_to_frame() {
329        let to = vec![200u8, 200, 200, 255];
330        let node = CrossfadeNode::new(1.0, to.clone(), 1, 1);
331        let mut rgba = vec![50u8, 50, 50, 255];
332        node.process_cpu(&mut rgba, 1, 1);
333        // Allow ±1 for float rounding.
334        assert!(
335            (rgba[0] as i32 - 200).abs() <= 1,
336            "factor=1 must return to-frame R; got {}",
337            rgba[0]
338        );
339    }
340
341    #[test]
342    fn crossfade_node_factor_half_should_produce_arithmetic_mean() {
343        // from = 0, to = 200 → expected mean = 100
344        let to = vec![200u8, 200, 200, 255];
345        let node = CrossfadeNode::new(0.5, to, 1, 1);
346        let mut rgba = vec![0u8, 0, 0, 255];
347        node.process_cpu(&mut rgba, 1, 1);
348        let diff = (rgba[0] as i32 - 100).abs();
349        assert!(
350            diff <= 1,
351            "factor=0.5 must produce arithmetic mean ~100; got {}",
352            rgba[0]
353        );
354    }
355
356    #[test]
357    fn crossfade_node_size_mismatch_should_leave_rgba_unchanged() {
358        let to = vec![200u8; 8]; // 2 pixels
359        let node = CrossfadeNode::new(0.5, to, 2, 1);
360        let original = vec![50u8, 50, 50, 255]; // 1 pixel
361        let mut rgba = original.clone();
362        node.process_cpu(&mut rgba, 1, 1); // size mismatch — must be a no-op
363        assert_eq!(rgba, original, "size mismatch must leave rgba unchanged");
364    }
365}