1use super::RenderNodeCpu;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum ScaleAlgorithm {
6 #[default]
8 Bilinear,
9 Bicubic,
11 Lanczos,
13}
14
15#[cfg(feature = "wgpu")]
18struct ScalePipeline {
19 render_pipeline: wgpu::RenderPipeline,
20 bind_group_layout: wgpu::BindGroupLayout,
21 sampler: wgpu::Sampler,
22}
23
24pub struct ScaleNode {
39 pub width: u32,
41 pub height: u32,
43 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 #[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 #[must_use]
79 pub fn scale_cpu(&self, src: &[u8], in_w: u32, in_h: u32) -> (Vec<u8>, u32, u32) {
80 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
104impl RenderNodeCpu for ScaleNode {
107 fn process_cpu(&self, _rgba: &mut [u8], _w: u32, _h: u32) {
108 }
113}
114
115#[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 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 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 let left = &out[0..4]; let right = &out[4..8]; 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 let node = ScaleNode::new(0, 0, ScaleAlgorithm::Bilinear);
342 let src = vec![10u8, 20, 30, 255, 40, 50, 60, 255]; 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}