Skip to main content

ff_render/nodes/
scale.rs

1use super::RenderNodeCpu;
2
3/// Resampling algorithm for [`ScaleNode`].
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum ScaleAlgorithm {
6    /// Bilinear — fast, good quality for moderate scaling (default).
7    #[default]
8    Bilinear,
9    /// Bicubic — medium quality.
10    Bicubic,
11    /// Lanczos — high quality, best for downscaling.
12    Lanczos,
13}
14
15// Pipeline cache
16
17#[cfg(feature = "wgpu")]
18struct ScalePipeline {
19    render_pipeline: wgpu::RenderPipeline,
20    bind_group_layout: wgpu::BindGroupLayout,
21    sampler: wgpu::Sampler,
22}
23
24// ScaleNode
25
26/// Resample a frame to a target resolution.
27///
28/// The GPU path renders into a `width` x `height` output target (the executor
29/// allocates a differently sized target from [`output_dimensions`]) using the
30/// node's [`ScaleAlgorithm`] sampler, so it truly resizes rather than blitting
31/// same-size. The CPU path is exposed as [`scale_cpu`](Self::scale_cpu), which
32/// returns a new resized buffer (the in-place [`RenderNodeCpu::process_cpu`]
33/// cannot change dimensions, so it stays a no-op).
34///
35/// A `width` or `height` of `0` means "keep the input size" (passthrough).
36///
37/// [`output_dimensions`]: crate::nodes::RenderNode::output_dimensions
38pub struct ScaleNode {
39    /// Target width in pixels (`0` = keep input width).
40    pub width: u32,
41    /// Target height in pixels (`0` = keep input height).
42    pub height: u32,
43    /// Sampling algorithm.
44    pub algorithm: ScaleAlgorithm,
45    #[cfg(feature = "wgpu")]
46    pipeline: std::sync::OnceLock<ScalePipeline>,
47}
48
49impl ScaleNode {
50    #[must_use]
51    pub fn new(width: u32, height: u32, algorithm: ScaleAlgorithm) -> Self {
52        Self {
53            width,
54            height,
55            algorithm,
56            #[cfg(feature = "wgpu")]
57            pipeline: std::sync::OnceLock::new(),
58        }
59    }
60
61    /// Output size for the given input size: the configured `width` x `height`,
62    /// or the input size when either is `0` (passthrough).
63    #[must_use]
64    pub fn target_size(&self, in_w: u32, in_h: u32) -> (u32, u32) {
65        if self.width == 0 || self.height == 0 {
66            (in_w, in_h)
67        } else {
68            (self.width, self.height)
69        }
70    }
71
72    /// Resize an RGBA frame on the CPU, returning `(pixels, out_w, out_h)`.
73    ///
74    /// `src` is `in_w` x `in_h` RGBA (`in_w * in_h * 4` bytes). The output is
75    /// [`target_size`](Self::target_size) at the node's [`ScaleAlgorithm`]
76    /// (Bilinear -> triangle, Bicubic -> Catmull-Rom, Lanczos -> Lanczos3). A
77    /// malformed `src` (wrong length) is returned unchanged.
78    #[must_use]
79    pub fn scale_cpu(&self, src: &[u8], in_w: u32, in_h: u32) -> (Vec<u8>, u32, u32) {
80        // A zero-dimension source has nothing to resample; return it as-is.
81        if in_w == 0 || in_h == 0 {
82            return (src.to_vec(), in_w, in_h);
83        }
84        let (out_w, out_h) = self.target_size(in_w, in_h);
85        let Some(img) = image::RgbaImage::from_raw(in_w, in_h, src.to_vec()) else {
86            return (src.to_vec(), in_w, in_h);
87        };
88        let filter = match self.algorithm {
89            ScaleAlgorithm::Bilinear => image::imageops::FilterType::Triangle,
90            ScaleAlgorithm::Bicubic => image::imageops::FilterType::CatmullRom,
91            ScaleAlgorithm::Lanczos => image::imageops::FilterType::Lanczos3,
92        };
93        let resized = image::imageops::resize(&img, out_w, out_h, filter);
94        (resized.into_raw(), out_w, out_h)
95    }
96}
97
98impl Default for ScaleNode {
99    fn default() -> Self {
100        Self::new(0, 0, ScaleAlgorithm::Bilinear)
101    }
102}
103
104// CPU path — no-op
105
106impl RenderNodeCpu for ScaleNode {
107    fn process_cpu(&self, _rgba: &mut [u8], _w: u32, _h: u32) {
108        // Resizing changes dimensions, which the in-place `process_cpu(&mut [u8])`
109        // signature cannot express (the buffer size is fixed). Use
110        // [`ScaleNode::scale_cpu`] for a real CPU resize; here it is a no-op so
111        // a ScaleNode in the CPU fallback chain passes the frame through.
112    }
113}
114
115// GPU path
116
117#[cfg(feature = "wgpu")]
118impl ScaleNode {
119    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &ScalePipeline {
120        self.pipeline.get_or_init(|| {
121            let device = &ctx.device;
122
123            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
124                label: Some("Scale shader"),
125                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/scale.wgsl").into()),
126            });
127
128            let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
129                label: Some("Scale BGL"),
130                entries: &[
131                    wgpu::BindGroupLayoutEntry {
132                        binding: 0,
133                        visibility: wgpu::ShaderStages::FRAGMENT,
134                        ty: wgpu::BindingType::Texture {
135                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
136                            view_dimension: wgpu::TextureViewDimension::D2,
137                            multisampled: false,
138                        },
139                        count: None,
140                    },
141                    wgpu::BindGroupLayoutEntry {
142                        binding: 1,
143                        visibility: wgpu::ShaderStages::FRAGMENT,
144                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
145                        count: None,
146                    },
147                ],
148            });
149
150            let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
151                label: Some("Scale layout"),
152                bind_group_layouts: &[Some(&bgl)],
153                immediate_size: 0,
154            });
155
156            // Use linear filtering for Bilinear (default) and Bicubic.
157            // Lanczos would require a custom kernel — Phase 3 addition.
158            let filter = match self.algorithm {
159                ScaleAlgorithm::Bilinear | ScaleAlgorithm::Bicubic | ScaleAlgorithm::Lanczos => {
160                    wgpu::FilterMode::Linear
161                }
162            };
163
164            let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
165                label: Some("Scale sampler"),
166                address_mode_u: wgpu::AddressMode::ClampToEdge,
167                address_mode_v: wgpu::AddressMode::ClampToEdge,
168                mag_filter: filter,
169                min_filter: filter,
170                ..Default::default()
171            });
172
173            let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
174                label: Some("Scale pipeline"),
175                layout: Some(&pipeline_layout),
176                vertex: wgpu::VertexState {
177                    module: &shader,
178                    entry_point: Some("vs_main"),
179                    buffers: &[],
180                    compilation_options: wgpu::PipelineCompilationOptions::default(),
181                },
182                fragment: Some(wgpu::FragmentState {
183                    module: &shader,
184                    entry_point: Some("fs_main"),
185                    targets: &[Some(wgpu::ColorTargetState {
186                        format: wgpu::TextureFormat::Rgba8Unorm,
187                        blend: None,
188                        write_mask: wgpu::ColorWrites::ALL,
189                    })],
190                    compilation_options: wgpu::PipelineCompilationOptions::default(),
191                }),
192                primitive: wgpu::PrimitiveState::default(),
193                depth_stencil: None,
194                multisample: wgpu::MultisampleState::default(),
195                multiview_mask: None,
196                cache: None,
197            });
198
199            ScalePipeline {
200                render_pipeline,
201                bind_group_layout: bgl,
202                sampler,
203            }
204        })
205    }
206}
207
208#[cfg(feature = "wgpu")]
209impl super::RenderNode for ScaleNode {
210    fn output_dimensions(&self, in_w: u32, in_h: u32) -> (u32, u32) {
211        self.target_size(in_w, in_h)
212    }
213
214    fn process(
215        &self,
216        inputs: &[&wgpu::Texture],
217        outputs: &[&wgpu::Texture],
218        ctx: &crate::context::RenderContext,
219    ) {
220        let Some(input) = inputs.first() else {
221            log::warn!("ScaleNode::process called with no inputs");
222            return;
223        };
224        let Some(output) = outputs.first() else {
225            log::warn!("ScaleNode::process called with no outputs");
226            return;
227        };
228
229        let pd = self.get_or_create_pipeline(ctx);
230
231        let input_view = input.create_view(&wgpu::TextureViewDescriptor::default());
232        let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
233
234        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
235            label: Some("Scale BG"),
236            layout: &pd.bind_group_layout,
237            entries: &[
238                wgpu::BindGroupEntry {
239                    binding: 0,
240                    resource: wgpu::BindingResource::TextureView(&input_view),
241                },
242                wgpu::BindGroupEntry {
243                    binding: 1,
244                    resource: wgpu::BindingResource::Sampler(&pd.sampler),
245                },
246            ],
247        });
248
249        let mut encoder = ctx
250            .device
251            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
252                label: Some("Scale pass"),
253            });
254        {
255            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
256                label: Some("Scale pass"),
257                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
258                    view: &output_view,
259                    resolve_target: None,
260                    depth_slice: None,
261                    ops: wgpu::Operations {
262                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
263                        store: wgpu::StoreOp::Store,
264                    },
265                })],
266                depth_stencil_attachment: None,
267                timestamp_writes: None,
268                occlusion_query_set: None,
269                multiview_mask: None,
270            });
271            pass.set_pipeline(&pd.render_pipeline);
272            pass.set_bind_group(0, &bind_group, &[]);
273            pass.draw(0..6, 0..1);
274        }
275        ctx.queue.submit(std::iter::once(encoder.finish()));
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn scale_node_cpu_path_is_passthrough() {
285        let node = ScaleNode::new(100, 100, ScaleAlgorithm::Bilinear);
286        let original = vec![10u8, 20, 30, 255];
287        let mut rgba = original.clone();
288        node.process_cpu(&mut rgba, 1, 1);
289        assert_eq!(rgba, original, "ScaleNode CPU path must be a no-op");
290    }
291
292    #[test]
293    fn scale_algorithm_default_should_be_bilinear() {
294        assert_eq!(ScaleAlgorithm::default(), ScaleAlgorithm::Bilinear);
295    }
296
297    #[test]
298    fn scale_cpu_should_resize_not_passthrough() {
299        // 4x2 frame: left half red, right half blue. Downscale to 2x2. A real
300        // resize yields a 2x2 buffer with a red-dominant left column and a
301        // blue-dominant right column; a no-op/passthrough would not change dims.
302        let node = ScaleNode::new(2, 2, ScaleAlgorithm::Bilinear);
303        let mut src = Vec::new();
304        for _y in 0..2 {
305            for x in 0..4 {
306                if x < 2 {
307                    src.extend_from_slice(&[255, 0, 0, 255]);
308                } else {
309                    src.extend_from_slice(&[0, 0, 255, 255]);
310                }
311            }
312        }
313
314        let (out, out_w, out_h) = node.scale_cpu(&src, 4, 2);
315        assert_eq!(
316            (out_w, out_h),
317            (2, 2),
318            "must resize to the requested dimensions"
319        );
320        assert_eq!(out.len(), 2 * 2 * 4, "output must be a 2x2 RGBA buffer");
321        assert_ne!(
322            out, src,
323            "output must differ from the input (not a passthrough)"
324        );
325        // Pixel (col, row) at index (row * 2 + col) * 4.
326        let left = &out[0..4]; // (0, 0)
327        let right = &out[4..8]; // (1, 0)
328        assert!(
329            left[0] > left[2],
330            "left column must stay red-dominant after resize; got {left:?}"
331        );
332        assert!(
333            right[2] > right[0],
334            "right column must stay blue-dominant after resize; got {right:?}"
335        );
336    }
337
338    #[test]
339    fn scale_cpu_zero_dimensions_should_passthrough() {
340        // A ScaleNode with width/height 0 keeps the input size.
341        let node = ScaleNode::new(0, 0, ScaleAlgorithm::Bilinear);
342        let src = vec![10u8, 20, 30, 255, 40, 50, 60, 255]; // 2x1 RGBA
343        let (out, out_w, out_h) = node.scale_cpu(&src, 2, 1);
344        assert_eq!((out_w, out_h), (2, 1), "0 dimensions keep the input size");
345        assert_eq!(out, src, "passthrough must return the input unchanged");
346    }
347}