Skip to main content

oxigdal_gpu/kernels/
resampling.rs

1//! GPU kernels for raster resampling operations.
2//!
3//! This module provides GPU-accelerated resampling operations including
4//! nearest neighbor, bilinear, and bicubic interpolation.
5
6use crate::buffer::GpuBuffer;
7use crate::context::GpuContext;
8use crate::error::{GpuError, GpuResult};
9use crate::shaders::{
10    ComputePipelineBuilder, WgslShader, create_compute_bind_group_layout, storage_buffer_layout,
11    uniform_buffer_layout,
12};
13use bytemuck::{Pod, Zeroable};
14use tracing::debug;
15use wgpu::{
16    BindGroupDescriptor, BindGroupEntry, BufferUsages, CommandEncoderDescriptor,
17    ComputePassDescriptor, ComputePipeline,
18};
19
20/// Resampling method.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ResamplingMethod {
23    /// Nearest neighbor (fast, blocky).
24    NearestNeighbor,
25    /// Bilinear interpolation (smooth, fast).
26    Bilinear,
27    /// Bicubic interpolation (highest quality, slower).
28    Bicubic,
29    /// Lanczos resampling with window radius `a` (typical values: 2 or 3).
30    ///
31    /// Evaluates `L(x) = sinc(x) * sinc(x/a)` for `|x| < a`, else 0.
32    /// The 2D kernel is the outer product of two 1D Lanczos kernels.
33    /// Results are normalized by dividing by the total weight sum.
34    ///
35    /// Valid range for `a`: 1..=8.
36    Lanczos {
37        /// Window radius (half-width). Valid range: 1..=8.
38        a: u32,
39    },
40}
41
42impl ResamplingMethod {
43    /// Get the shader entry point name for this resampling method.
44    pub fn entry_point(&self) -> &'static str {
45        match self {
46            Self::NearestNeighbor => "nearest_neighbor",
47            Self::Bilinear => "bilinear",
48            Self::Bicubic => "bicubic",
49            Self::Lanczos { .. } => "lanczos",
50        }
51    }
52}
53
54/// Resampling parameters.
55#[derive(Debug, Clone, Copy, Pod, Zeroable)]
56#[repr(C)]
57pub struct ResamplingParams {
58    /// Source width.
59    pub src_width: u32,
60    /// Source height.
61    pub src_height: u32,
62    /// Destination width.
63    pub dst_width: u32,
64    /// Destination height.
65    pub dst_height: u32,
66}
67
68impl ResamplingParams {
69    /// Create new resampling parameters.
70    pub fn new(src_width: u32, src_height: u32, dst_width: u32, dst_height: u32) -> Self {
71        Self {
72            src_width,
73            src_height,
74            dst_width,
75            dst_height,
76        }
77    }
78
79    /// Calculate scale factors.
80    pub fn scale_factors(&self) -> (f32, f32) {
81        let scale_x = self.src_width as f32 / self.dst_width as f32;
82        let scale_y = self.src_height as f32 / self.dst_height as f32;
83        (scale_x, scale_y)
84    }
85}
86
87/// GPU kernel for resampling operations.
88pub struct ResamplingKernel {
89    context: GpuContext,
90    pipeline: ComputePipeline,
91    bind_group_layout: wgpu::BindGroupLayout,
92    workgroup_size: (u32, u32),
93    method: ResamplingMethod,
94}
95
96impl ResamplingKernel {
97    /// Create a new resampling kernel.
98    ///
99    /// The workgroup size is taken from `context.tuner.raster_2d` so it
100    /// automatically adapts to low-end and high-end GPUs.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if shader compilation or pipeline creation fails,
105    /// or if `method` parameters are out of valid range.
106    pub fn new(context: &GpuContext, method: ResamplingMethod) -> GpuResult<Self> {
107        let workgroup_size = context.tuner.raster_2d;
108        Self::new_with_workgroup_size(context, method, workgroup_size)
109    }
110
111    /// Create a new resampling kernel with an explicit workgroup size.
112    ///
113    /// Prefer [`ResamplingKernel::new`] which derives the size from the
114    /// context's tuner automatically.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if shader compilation or pipeline creation fails,
119    /// or if `method` parameters are out of valid range.
120    pub fn new_with_workgroup_size(
121        context: &GpuContext,
122        method: ResamplingMethod,
123        workgroup_size: (u32, u32),
124    ) -> GpuResult<Self> {
125        debug!(
126            "Creating resampling kernel: {:?} (workgroup {}x{})",
127            method, workgroup_size.0, workgroup_size.1
128        );
129
130        // Validate Lanczos window parameter before touching the GPU.
131        if let ResamplingMethod::Lanczos { a } = method {
132            if a == 0 || a > 8 {
133                return Err(GpuError::invalid_kernel_params(format!(
134                    "Lanczos window parameter a={a} out of valid range 1..=8"
135                )));
136            }
137        }
138
139        let shader_source = Self::resampling_shader(method, workgroup_size);
140        let mut shader = WgslShader::new(shader_source, method.entry_point());
141        let shader_module = shader.compile(context.device())?;
142
143        let bind_group_layout = create_compute_bind_group_layout(
144            context.device(),
145            &[
146                storage_buffer_layout(0, true),  // input
147                uniform_buffer_layout(1),        // params
148                storage_buffer_layout(2, false), // output
149            ],
150            Some("ResamplingKernel BindGroupLayout"),
151        )?;
152
153        let pipeline =
154            ComputePipelineBuilder::new(context.device(), shader_module, method.entry_point())
155                .bind_group_layout(&bind_group_layout)
156                .label(format!("ResamplingKernel Pipeline: {:?}", method))
157                .build()?;
158
159        Ok(Self {
160            context: context.clone(),
161            pipeline,
162            bind_group_layout,
163            workgroup_size,
164            method,
165        })
166    }
167
168    /// Get shader source for resampling method, with workgroup size interpolated
169    /// into the WGSL `@workgroup_size` attribute at build time.
170    ///
171    /// Because `@workgroup_size` requires compile-time constants in WGSL, the
172    /// sizes are baked into the shader source string rather than passed as
173    /// push constants.  The pipeline is therefore implicitly keyed by workgroup
174    /// size, which is the correct behaviour — different devices compile
175    /// different (but correct) pipelines.
176    fn resampling_shader(method: ResamplingMethod, workgroup_size: (u32, u32)) -> String {
177        let (wg_x, wg_y) = workgroup_size;
178
179        let common = r#"
180struct ResamplingParams {
181    src_width: u32,
182    src_height: u32,
183    dst_width: u32,
184    dst_height: u32,
185}
186
187@group(0) @binding(0) var<storage, read> input: array<f32>;
188@group(0) @binding(1) var<uniform> params: ResamplingParams;
189@group(0) @binding(2) var<storage, read_write> output: array<f32>;
190
191fn get_pixel(x: u32, y: u32) -> f32 {
192    if (x >= params.src_width || y >= params.src_height) {
193        return 0.0;
194    }
195    return input[y * params.src_width + x];
196}
197
198fn lerp(a: f32, b: f32, t: f32) -> f32 {
199    return a + (b - a) * t;
200}
201"#;
202
203        match method {
204            ResamplingMethod::NearestNeighbor => format!(
205                r#"
206{}
207
208@compute @workgroup_size({wg_x}, {wg_y})
209fn nearest_neighbor(@builtin(global_invocation_id) global_id: vec3<u32>) {{
210    let dst_x = global_id.x;
211    let dst_y = global_id.y;
212
213    if (dst_x >= params.dst_width || dst_y >= params.dst_height) {{
214        return;
215    }}
216
217    let scale_x = f32(params.src_width) / f32(params.dst_width);
218    let scale_y = f32(params.src_height) / f32(params.dst_height);
219
220    let src_x = u32(f32(dst_x) * scale_x);
221    let src_y = u32(f32(dst_y) * scale_y);
222
223    let value = get_pixel(src_x, src_y);
224    output[dst_y * params.dst_width + dst_x] = value;
225}}
226"#,
227                common,
228                wg_x = wg_x,
229                wg_y = wg_y,
230            ),
231
232            ResamplingMethod::Bilinear => format!(
233                r#"
234{}
235
236@compute @workgroup_size({wg_x}, {wg_y})
237fn bilinear(@builtin(global_invocation_id) global_id: vec3<u32>) {{
238    let dst_x = global_id.x;
239    let dst_y = global_id.y;
240
241    if (dst_x >= params.dst_width || dst_y >= params.dst_height) {{
242        return;
243    }}
244
245    let scale_x = f32(params.src_width) / f32(params.dst_width);
246    let scale_y = f32(params.src_height) / f32(params.dst_height);
247
248    let src_x = f32(dst_x) * scale_x;
249    let src_y = f32(dst_y) * scale_y;
250
251    let x0 = u32(floor(src_x));
252    let y0 = u32(floor(src_y));
253    let x1 = min(x0 + 1u, params.src_width - 1u);
254    let y1 = min(y0 + 1u, params.src_height - 1u);
255
256    let tx = fract(src_x);
257    let ty = fract(src_y);
258
259    let v00 = get_pixel(x0, y0);
260    let v10 = get_pixel(x1, y0);
261    let v01 = get_pixel(x0, y1);
262    let v11 = get_pixel(x1, y1);
263
264    let v0 = lerp(v00, v10, tx);
265    let v1 = lerp(v01, v11, tx);
266    let value = lerp(v0, v1, ty);
267
268    output[dst_y * params.dst_width + dst_x] = value;
269}}
270"#,
271                common,
272                wg_x = wg_x,
273                wg_y = wg_y,
274            ),
275
276            ResamplingMethod::Bicubic => format!(
277                r#"
278{}
279
280fn cubic_interpolate(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 {{
281    let a = -0.5 * p0 + 1.5 * p1 - 1.5 * p2 + 0.5 * p3;
282    let b = p0 - 2.5 * p1 + 2.0 * p2 - 0.5 * p3;
283    let c = -0.5 * p0 + 0.5 * p2;
284    let d = p1;
285    return a * t * t * t + b * t * t + c * t + d;
286}}
287
288@compute @workgroup_size({wg_x}, {wg_y})
289fn bicubic(@builtin(global_invocation_id) global_id: vec3<u32>) {{
290    let dst_x = global_id.x;
291    let dst_y = global_id.y;
292
293    if (dst_x >= params.dst_width || dst_y >= params.dst_height) {{
294        return;
295    }}
296
297    let scale_x = f32(params.src_width) / f32(params.dst_width);
298    let scale_y = f32(params.src_height) / f32(params.dst_height);
299
300    let src_x = f32(dst_x) * scale_x;
301    let src_y = f32(dst_y) * scale_y;
302
303    let x_floor = floor(src_x);
304    let y_floor = floor(src_y);
305    let tx = fract(src_x);
306    let ty = fract(src_y);
307
308    // Sample 4x4 neighborhood
309    var cols: array<f32, 4>;
310    for (var j = 0; j < 4; j++) {{
311        let y = i32(y_floor) + j - 1;
312        var row: array<f32, 4>;
313        for (var i = 0; i < 4; i++) {{
314            let x = i32(x_floor) + i - 1;
315            if (x >= 0 && x < i32(params.src_width) && y >= 0 && y < i32(params.src_height)) {{
316                row[i] = get_pixel(u32(x), u32(y));
317            }} else {{
318                row[i] = 0.0;
319            }}
320        }}
321        cols[j] = cubic_interpolate(row[0], row[1], row[2], row[3], tx);
322    }}
323
324    let value = cubic_interpolate(cols[0], cols[1], cols[2], cols[3], ty);
325    output[dst_y * params.dst_width + dst_x] = value;
326}}
327"#,
328                common,
329                wg_x = wg_x,
330                wg_y = wg_y,
331            ),
332
333            ResamplingMethod::Lanczos { a } => {
334                // Pre-compute the loop trip count: 2*a samples per axis.
335                let two_a = 2 * a;
336                format!(
337                    r#"
338{common}
339
340// sinc(x) = sin(pi*x) / (pi*x), with sinc(0) = 1.
341fn sinc(x: f32) -> f32 {{
342    if (abs(x) < 1e-6) {{
343        return 1.0;
344    }}
345    let pi_x: f32 = 3.14159265358979323846 * x;
346    return sin(pi_x) / pi_x;
347}}
348
349// Lanczos weighting function with window radius a.
350// L(x) = sinc(x) * sinc(x/a)  for |x| < a, else 0.
351fn lanczos_weight(x: f32, a: f32) -> f32 {{
352    if (abs(x) >= a) {{
353        return 0.0;
354    }}
355    return sinc(x) * sinc(x / a);
356}}
357
358@compute @workgroup_size({wg_x}, {wg_y})
359fn lanczos(@builtin(global_invocation_id) global_id: vec3<u32>) {{
360    let dst_x = global_id.x;
361    let dst_y = global_id.y;
362
363    if (dst_x >= params.dst_width || dst_y >= params.dst_height) {{
364        return;
365    }}
366
367    let scale_x = f32(params.src_width) / f32(params.dst_width);
368    let scale_y = f32(params.src_height) / f32(params.dst_height);
369
370    // Map destination pixel centre to source coordinate space.
371    // The +0.5 / -0.5 shift aligns pixel centres correctly.
372    let src_cx = (f32(dst_x) + 0.5) * scale_x - 0.5;
373    let src_cy = (f32(dst_y) + 0.5) * scale_y - 0.5;
374
375    // Window radius injected at shader-build time.
376    let lanczos_a: f32 = {a}f;
377
378    var weight_sum: f32 = 0.0;
379    var value_sum: f32 = 0.0;
380
381    // Sample 2*a rows and 2*a columns centred on src_c(x,y).
382    // Row/col index starts at floor(src_c) - a + 1 and runs for 2*a steps.
383    let x_start: i32 = i32(floor(src_cx)) - i32({a}) + 1;
384    let y_start: i32 = i32(floor(src_cy)) - i32({a}) + 1;
385
386    for (var j: i32 = 0; j < {two_a}; j++) {{
387        let sy: i32 = y_start + j;
388        let wy: f32 = lanczos_weight(src_cy - f32(sy), lanczos_a);
389        if (wy == 0.0) {{ continue; }}
390
391        // Clamp-to-edge: keep source coordinate inside the image.
392        let clamped_y: u32 = u32(clamp(sy, 0, i32(params.src_height) - 1));
393
394        for (var i: i32 = 0; i < {two_a}; i++) {{
395            let sx: i32 = x_start + i;
396            let wx: f32 = lanczos_weight(src_cx - f32(sx), lanczos_a);
397            if (wx == 0.0) {{ continue; }}
398
399            let clamped_x: u32 = u32(clamp(sx, 0, i32(params.src_width) - 1));
400
401            let w: f32 = wx * wy;
402            weight_sum += w;
403            value_sum += w * get_pixel(clamped_x, clamped_y);
404        }}
405    }}
406
407    // Normalise to preserve overall brightness; guard against zero weight sum.
408    let result: f32 = select(0.0, value_sum / weight_sum, weight_sum > 1e-10);
409    output[dst_y * params.dst_width + dst_x] = result;
410}}
411"#,
412                    a = a,
413                    two_a = two_a,
414                    wg_x = wg_x,
415                    wg_y = wg_y,
416                )
417            }
418        }
419    }
420
421    /// Execute resampling on GPU buffer.
422    ///
423    /// # Errors
424    ///
425    /// Returns an error if buffer sizes are invalid or execution fails.
426    pub fn execute<T: Pod>(
427        &self,
428        input: &GpuBuffer<T>,
429        params: ResamplingParams,
430    ) -> GpuResult<GpuBuffer<T>> {
431        // Validate input size
432        let expected_input_size = (params.src_width as usize) * (params.src_height as usize);
433        if input.len() != expected_input_size {
434            return Err(GpuError::invalid_kernel_params(format!(
435                "Input buffer size mismatch: expected {}, got {}",
436                expected_input_size,
437                input.len()
438            )));
439        }
440
441        // Create output buffer
442        let output_size = (params.dst_width as usize) * (params.dst_height as usize);
443        let output = GpuBuffer::new(
444            &self.context,
445            output_size,
446            BufferUsages::STORAGE | BufferUsages::COPY_SRC,
447        )?;
448
449        // Create params buffer
450        let params_buffer = GpuBuffer::from_data(
451            &self.context,
452            &[params],
453            BufferUsages::UNIFORM | BufferUsages::COPY_DST,
454        )?;
455
456        // Create bind group
457        let bind_group = self
458            .context
459            .device()
460            .create_bind_group(&BindGroupDescriptor {
461                label: Some("ResamplingKernel BindGroup"),
462                layout: &self.bind_group_layout,
463                entries: &[
464                    BindGroupEntry {
465                        binding: 0,
466                        resource: input.buffer().as_entire_binding(),
467                    },
468                    BindGroupEntry {
469                        binding: 1,
470                        resource: params_buffer.buffer().as_entire_binding(),
471                    },
472                    BindGroupEntry {
473                        binding: 2,
474                        resource: output.buffer().as_entire_binding(),
475                    },
476                ],
477            });
478
479        // Execute kernel
480        let mut encoder = self
481            .context
482            .device()
483            .create_command_encoder(&CommandEncoderDescriptor {
484                label: Some("ResamplingKernel Encoder"),
485            });
486
487        {
488            let mut compute_pass = encoder.begin_compute_pass(&ComputePassDescriptor {
489                label: Some("ResamplingKernel Pass"),
490                timestamp_writes: None,
491            });
492
493            compute_pass.set_pipeline(&self.pipeline);
494            compute_pass.set_bind_group(0, &bind_group, &[]);
495
496            let workgroups_x =
497                (params.dst_width + self.workgroup_size.0 - 1) / self.workgroup_size.0;
498            let workgroups_y =
499                (params.dst_height + self.workgroup_size.1 - 1) / self.workgroup_size.1;
500
501            compute_pass.dispatch_workgroups(workgroups_x, workgroups_y, 1);
502        }
503
504        self.context.queue().submit(Some(encoder.finish()));
505
506        debug!(
507            "Resampled {}x{} to {}x{} using {:?}",
508            params.src_width, params.src_height, params.dst_width, params.dst_height, self.method
509        );
510
511        Ok(output)
512    }
513}
514
515/// Resize raster using GPU acceleration.
516///
517/// # Errors
518///
519/// Returns an error if GPU operations fail.
520pub fn resize<T: Pod>(
521    context: &GpuContext,
522    input: &GpuBuffer<T>,
523    src_width: u32,
524    src_height: u32,
525    dst_width: u32,
526    dst_height: u32,
527    method: ResamplingMethod,
528) -> GpuResult<GpuBuffer<T>> {
529    let kernel = ResamplingKernel::new(context, method)?;
530    let params = ResamplingParams::new(src_width, src_height, dst_width, dst_height);
531    kernel.execute(input, params)
532}
533
534/// Downscale raster by factor of 2 (fast).
535///
536/// # Errors
537///
538/// Returns an error if GPU operations fail.
539pub fn downscale_2x<T: Pod>(
540    context: &GpuContext,
541    input: &GpuBuffer<T>,
542    width: u32,
543    height: u32,
544) -> GpuResult<GpuBuffer<T>> {
545    resize(
546        context,
547        input,
548        width,
549        height,
550        width / 2,
551        height / 2,
552        ResamplingMethod::Bilinear,
553    )
554}
555
556/// Upscale raster by factor of 2.
557///
558/// # Errors
559///
560/// Returns an error if GPU operations fail.
561pub fn upscale_2x<T: Pod>(
562    context: &GpuContext,
563    input: &GpuBuffer<T>,
564    width: u32,
565    height: u32,
566    method: ResamplingMethod,
567) -> GpuResult<GpuBuffer<T>> {
568    resize(context, input, width, height, width * 2, height * 2, method)
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn test_resampling_params() {
577        let params = ResamplingParams::new(1024, 768, 512, 384);
578        let (scale_x, scale_y) = params.scale_factors();
579        assert!((scale_x - 2.0).abs() < 1e-5);
580        assert!((scale_y - 2.0).abs() < 1e-5);
581    }
582
583    #[test]
584    fn test_resampling_shader() {
585        let shader = ResamplingKernel::resampling_shader(ResamplingMethod::Bilinear, (16, 16));
586        assert!(shader.contains("@compute"));
587        assert!(shader.contains("bilinear"));
588        assert!(shader.contains("@workgroup_size(16, 16)"));
589    }
590
591    #[test]
592    fn test_resampling_shader_custom_workgroup() {
593        let shader = ResamplingKernel::resampling_shader(ResamplingMethod::NearestNeighbor, (8, 8));
594        assert!(shader.contains("@workgroup_size(8, 8)"));
595        assert!(shader.contains("nearest_neighbor"));
596    }
597
598    #[test]
599    fn test_resampling_shader_4x4_fallback() {
600        let shader = ResamplingKernel::resampling_shader(ResamplingMethod::Bilinear, (4, 4));
601        assert!(shader.contains("@workgroup_size(4, 4)"));
602    }
603
604    #[test]
605    fn test_lanczos_shader_a2() {
606        let shader =
607            ResamplingKernel::resampling_shader(ResamplingMethod::Lanczos { a: 2 }, (16, 16));
608        assert!(shader.contains("@compute"));
609        assert!(shader.contains("fn lanczos("));
610        assert!(shader.contains("fn sinc("));
611        assert!(shader.contains("fn lanczos_weight("));
612        // Loop trip count 2*a = 4 should appear in the shader.
613        assert!(shader.contains("4"));
614        // The window radius should be embedded as a float literal.
615        assert!(shader.contains("2f"));
616        assert!(shader.contains("@workgroup_size(16, 16)"));
617    }
618
619    #[test]
620    fn test_lanczos_shader_a3() {
621        let shader =
622            ResamplingKernel::resampling_shader(ResamplingMethod::Lanczos { a: 3 }, (16, 16));
623        assert!(shader.contains("fn lanczos("));
624        // Loop trip count 2*a = 6.
625        assert!(shader.contains("6"));
626        assert!(shader.contains("3f"));
627    }
628
629    #[test]
630    fn test_lanczos_shader_a2_custom_workgroup() {
631        let shader =
632            ResamplingKernel::resampling_shader(ResamplingMethod::Lanczos { a: 2 }, (8, 8));
633        assert!(shader.contains("@workgroup_size(8, 8)"));
634        assert!(shader.contains("fn lanczos("));
635    }
636
637    #[test]
638    fn test_lanczos_entry_point() {
639        assert_eq!(ResamplingMethod::Lanczos { a: 2 }.entry_point(), "lanczos");
640        assert_eq!(ResamplingMethod::Lanczos { a: 3 }.entry_point(), "lanczos");
641    }
642
643    #[test]
644    fn test_lanczos_equality() {
645        assert_eq!(
646            ResamplingMethod::Lanczos { a: 2 },
647            ResamplingMethod::Lanczos { a: 2 }
648        );
649        assert_ne!(
650            ResamplingMethod::Lanczos { a: 2 },
651            ResamplingMethod::Lanczos { a: 3 }
652        );
653        assert_ne!(
654            ResamplingMethod::Lanczos { a: 2 },
655            ResamplingMethod::Bilinear
656        );
657    }
658
659    #[tokio::test]
660    async fn test_resampling_kernel() {
661        if let Ok(context) = GpuContext::new().await {
662            if let Ok(_kernel) = ResamplingKernel::new(&context, ResamplingMethod::NearestNeighbor)
663            {
664                // Kernel created successfully
665            }
666        }
667    }
668
669    #[tokio::test]
670    async fn test_resize_operation() {
671        if let Ok(context) = GpuContext::new().await {
672            // Create a simple 4x4 input
673            let input_data: Vec<f32> = (0..16).map(|i| i as f32).collect();
674
675            if let Ok(input) = GpuBuffer::from_data(
676                &context,
677                &input_data,
678                BufferUsages::STORAGE | BufferUsages::COPY_SRC,
679            ) {
680                if let Ok(_output) = resize(
681                    &context,
682                    &input,
683                    4,
684                    4,
685                    2,
686                    2,
687                    ResamplingMethod::NearestNeighbor,
688                ) {
689                    // Successfully resized
690                }
691            }
692        }
693    }
694}