Skip to main content

oxigdal_gpu/
texture_resample.rs

1//! Texture-based resampling using wgpu hardware samplers.
2//!
3//! This module provides a `TextureResampler` that performs raster resampling
4//! using a `wgpu::Sampler` and a `texture_2d<f32>` binding rather than a flat
5//! `array<f32>` storage buffer.  The hardware sampler handles filtering in
6//! a single texel-fetch instruction, which is dramatically faster than
7//! evaluating a bilinear/bicubic interpolation in WGSL.
8//!
9//! # When to use this path
10//!
11//! | Resampling method        | Hardware-sampled?  | Recommendation                |
12//! |--------------------------|--------------------|-------------------------------|
13//! | `NearestNeighbor`        | yes (Nearest)      | use this module               |
14//! | `Bilinear`               | yes (Linear)       | use this module               |
15//! | `Bicubic`                | fallback (Linear)  | use this module if speed > Q  |
16//! | `Lanczos { a }`          | no                 | use the compute-buffer path   |
17//!
18//! For high-quality bicubic and Lanczos resampling, prefer the compute-buffer
19//! path in `kernels::resampling` which implements the full kernel mathematics.
20//!
21//! # Workflow
22//!
23//! 1. Upload source data as a sampleable `wgpu::Texture` with
24//!    [`new_input_texture_r32float`].
25//! 2. Construct a [`TextureResampler`] with the desired filter mode and
26//!    destination format.
27//! 3. Create a destination storage texture with
28//!    [`crate::storage_texture::new_storage_texture`].
29//! 4. Dispatch the resampler with [`TextureResampler::dispatch`].
30//! 5. Optionally download the result with
31//!    [`crate::storage_texture::read_texture_to_vec_f32`].
32
33use crate::context::GpuContext;
34use crate::error::{GpuError, GpuResult};
35use crate::kernels::resampling::ResamplingMethod;
36use crate::storage_texture::StorageTextureBinding;
37use std::sync::Arc;
38use tracing::{debug, trace};
39
40// ─────────────────────────────────────────────────────────────────────────────
41// Public filter mode enum
42// ─────────────────────────────────────────────────────────────────────────────
43
44/// Hardware-sampler filtering mode for [`TextureResampler`].
45///
46/// Maps directly to [`wgpu::FilterMode`]; both the magnification and
47/// minification filters are configured identically.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum TextureFilterMethod {
50    /// Nearest-neighbour sampling — returns the texel closest to the sample
51    /// coordinate.  Produces blocky output but is exact for integer scale
52    /// factors.
53    Nearest,
54    /// Bilinear filtering — returns a weighted average of the four texels
55    /// closest to the sample coordinate.  This is the primary use case for the
56    /// hardware sampler path.
57    Linear,
58}
59
60impl TextureFilterMethod {
61    /// Returns the wgpu equivalent of this filter mode.
62    ///
63    /// The same value is used for both magnification and minification filters
64    /// in [`TextureResampler::new`].
65    pub fn wgpu_filter(&self) -> wgpu::FilterMode {
66        match self {
67            Self::Nearest => wgpu::FilterMode::Nearest,
68            Self::Linear => wgpu::FilterMode::Linear,
69        }
70    }
71}
72
73/// Maps a [`ResamplingMethod`] to the hardware-sampler filter that best
74/// approximates it.
75///
76/// - `NearestNeighbor` → `Some(Nearest)`
77/// - `Bilinear`        → `Some(Linear)`
78/// - `Bicubic`         → `Some(Linear)`  (lossy fallback)
79/// - `Lanczos { .. }`  → `None`          (use the compute-buffer path)
80///
81/// Returns `None` to signal that the caller must dispatch the compute-buffer
82/// resampler in [`crate::kernels::resampling`] instead.
83pub fn texture_filter_for_resampling(method: ResamplingMethod) -> Option<TextureFilterMethod> {
84    match method {
85        ResamplingMethod::NearestNeighbor => Some(TextureFilterMethod::Nearest),
86        ResamplingMethod::Bilinear => Some(TextureFilterMethod::Linear),
87        // Bicubic has no hardware sampler — fall back to bilinear for the
88        // texture path; callers wanting true bicubic should use the compute
89        // buffer kernel.
90        ResamplingMethod::Bicubic => Some(TextureFilterMethod::Linear),
91        // Lanczos is intentionally not supported by the texture path; the
92        // mathematical kernel requires a full convolution loop.
93        ResamplingMethod::Lanczos { .. } => None,
94    }
95}
96
97// ─────────────────────────────────────────────────────────────────────────────
98// WGSL shader source
99// ─────────────────────────────────────────────────────────────────────────────
100
101/// WGSL format string for a destination storage texture format.
102///
103/// Returns the default `rgba32float` if the format is not recognised so the
104/// shader text is always well-formed.  Caller-side validation in
105/// [`TextureResampler::new`] rejects unsupported formats earlier.
106fn dst_format_wgsl(fmt: wgpu::TextureFormat) -> &'static str {
107    match fmt {
108        wgpu::TextureFormat::Rgba32Float => "rgba32float",
109        wgpu::TextureFormat::R32Float => "r32float",
110        wgpu::TextureFormat::Rgba8Unorm => "rgba8unorm",
111        _ => "rgba32float",
112    }
113}
114
115/// Returns `true` if `format` is one of the destination formats supported by
116/// the texture-resample shader.
117fn is_supported_dst_format(format: wgpu::TextureFormat) -> bool {
118    matches!(
119        format,
120        wgpu::TextureFormat::Rgba32Float
121            | wgpu::TextureFormat::R32Float
122            | wgpu::TextureFormat::Rgba8Unorm,
123    )
124}
125
126/// Generate WGSL source for a texture-based resampling compute shader.
127///
128/// The shader binds:
129/// - `binding 0`: a sampleable `texture_2d<f32>` (the source raster).
130/// - `binding 1`: a `sampler` configured with the requested filter mode.
131/// - `binding 2`: a write-only `texture_storage_2d<{dst_fmt}, write>` (the
132///   destination raster).
133///
134/// Each invocation maps its destination pixel `(gid.x, gid.y)` to a normalised
135/// `uv` coordinate at the pixel centre, samples the source texture with
136/// `textureSampleLevel(..., 0.0)`, and writes the result to the storage
137/// texture.
138///
139/// The `filter` parameter is **not** baked into the shader text — the wgpu
140/// `Sampler` binding determines the actual filtering behaviour.  The parameter
141/// is accepted here for documentation purposes and to enable future shader
142/// specialisations.
143pub fn make_texture_resample_shader_source(
144    filter: TextureFilterMethod,
145    dst_fmt: wgpu::TextureFormat,
146) -> String {
147    // Suppress unused-variable warning while keeping the parameter as a
148    // public-API hook for future specialisations (e.g., manual filtering
149    // in WGSL when hardware filtering is not available for a given format).
150    let _ = filter;
151
152    let dst_fmt_str = dst_format_wgsl(dst_fmt);
153
154    format!(
155        r#"
156@group(0) @binding(0) var src_tex: texture_2d<f32>;
157@group(0) @binding(1) var src_samp: sampler;
158@group(0) @binding(2) var dst_tex: texture_storage_2d<{dst_fmt_str}, write>;
159
160@compute @workgroup_size(16, 16)
161fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
162    let dst_dims = textureDimensions(dst_tex);
163    if (gid.x >= dst_dims.x || gid.y >= dst_dims.y) {{
164        return;
165    }}
166
167    // Sample at the destination pixel centre: (i+0.5)/N maps the discrete
168    // texel index to the [0, 1] normalised UV coordinate used by the sampler.
169    let uv = vec2<f32>(
170        (f32(gid.x) + 0.5) / f32(dst_dims.x),
171        (f32(gid.y) + 0.5) / f32(dst_dims.y),
172    );
173
174    let val = textureSampleLevel(src_tex, src_samp, uv, 0.0);
175    textureStore(dst_tex, vec2<i32>(i32(gid.x), i32(gid.y)), val);
176}}
177"#,
178        dst_fmt_str = dst_fmt_str
179    )
180}
181
182// ─────────────────────────────────────────────────────────────────────────────
183// TextureResampler
184// ─────────────────────────────────────────────────────────────────────────────
185
186/// A compiled compute kernel that resamples a sampleable source texture into a
187/// destination storage texture using a hardware sampler.
188///
189/// Constructed once per `(method, dst_format)` pair via [`TextureResampler::new`]
190/// and reused across many dispatches.
191pub struct TextureResampler {
192    pipeline: Arc<wgpu::ComputePipeline>,
193    bind_group_layout: wgpu::BindGroupLayout,
194    sampler: wgpu::Sampler,
195    method: TextureFilterMethod,
196    dst_format: wgpu::TextureFormat,
197}
198
199impl TextureResampler {
200    /// Construct a new texture resampler.
201    ///
202    /// # Parameters
203    ///
204    /// - `ctx` — the GPU context that owns the device and queue.
205    /// - `method` — the filter mode (Nearest or Linear) applied by the sampler.
206    /// - `dst_format` — the storage texture format the kernel will write to.
207    ///   Must be one of `Rgba32Float`, `R32Float`, or `Rgba8Unorm`.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`GpuError::UnsupportedFormat`] when `dst_format` is not one of
212    /// the supported destination formats.
213    pub fn new(
214        ctx: &GpuContext,
215        method: TextureFilterMethod,
216        dst_format: wgpu::TextureFormat,
217    ) -> GpuResult<Self> {
218        if !is_supported_dst_format(dst_format) {
219            return Err(GpuError::UnsupportedFormat(format!(
220                "texture_resample dst_format {dst_format:?} not supported (expected Rgba32Float, R32Float, or Rgba8Unorm)"
221            )));
222        }
223
224        let sampler = ctx.device().create_sampler(&wgpu::SamplerDescriptor {
225            label: Some("texture_resample_sampler"),
226            address_mode_u: wgpu::AddressMode::ClampToEdge,
227            address_mode_v: wgpu::AddressMode::ClampToEdge,
228            address_mode_w: wgpu::AddressMode::ClampToEdge,
229            mag_filter: method.wgpu_filter(),
230            min_filter: method.wgpu_filter(),
231            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
232            ..Default::default()
233        });
234
235        let bind_group_layout =
236            ctx.device()
237                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
238                    label: Some("texture_resample_bgl"),
239                    entries: &[
240                        // binding 0: sampleable source texture
241                        wgpu::BindGroupLayoutEntry {
242                            binding: 0,
243                            visibility: wgpu::ShaderStages::COMPUTE,
244                            ty: wgpu::BindingType::Texture {
245                                sample_type: wgpu::TextureSampleType::Float { filterable: true },
246                                view_dimension: wgpu::TextureViewDimension::D2,
247                                multisampled: false,
248                            },
249                            count: None,
250                        },
251                        // binding 1: filtering sampler
252                        wgpu::BindGroupLayoutEntry {
253                            binding: 1,
254                            visibility: wgpu::ShaderStages::COMPUTE,
255                            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
256                            count: None,
257                        },
258                        // binding 2: destination storage texture (write-only)
259                        wgpu::BindGroupLayoutEntry {
260                            binding: 2,
261                            visibility: wgpu::ShaderStages::COMPUTE,
262                            ty: wgpu::BindingType::StorageTexture {
263                                access: wgpu::StorageTextureAccess::WriteOnly,
264                                format: dst_format,
265                                view_dimension: wgpu::TextureViewDimension::D2,
266                            },
267                            count: None,
268                        },
269                    ],
270                });
271
272        let shader_src = make_texture_resample_shader_source(method, dst_format);
273        let module = ctx
274            .device()
275            .create_shader_module(wgpu::ShaderModuleDescriptor {
276                label: Some("texture_resample_shader"),
277                source: wgpu::ShaderSource::Wgsl(shader_src.into()),
278            });
279
280        let pipeline_layout =
281            ctx.device()
282                .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
283                    label: Some("texture_resample_pl"),
284                    bind_group_layouts: &[Some(&bind_group_layout)],
285                    immediate_size: 0,
286                });
287
288        let pipeline = ctx
289            .device()
290            .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
291                label: Some("texture_resample_pipeline"),
292                layout: Some(&pipeline_layout),
293                module: &module,
294                entry_point: Some("main"),
295                compilation_options: wgpu::PipelineCompilationOptions::default(),
296                cache: None,
297            });
298
299        debug!(
300            "Created TextureResampler method={:?} dst_format={:?}",
301            method, dst_format
302        );
303
304        Ok(Self {
305            pipeline: Arc::new(pipeline),
306            bind_group_layout,
307            sampler,
308            method,
309            dst_format,
310        })
311    }
312
313    /// The filter mode this resampler was constructed with.
314    pub fn method(&self) -> TextureFilterMethod {
315        self.method
316    }
317
318    /// The destination storage-texture format this resampler writes to.
319    pub fn dst_format(&self) -> wgpu::TextureFormat {
320        self.dst_format
321    }
322
323    /// Dispatch the compute kernel, reading from `src_texture` and writing to
324    /// `dst_texture`.
325    ///
326    /// # Errors
327    ///
328    /// - Returns [`GpuError::UnsupportedFormat`] when the destination texture
329    ///   format does not match the resampler's `dst_format`.
330    /// - Returns the underlying error if the device has been lost.
331    pub fn dispatch(
332        &self,
333        ctx: &GpuContext,
334        src_texture: &wgpu::Texture,
335        dst_texture: &StorageTextureBinding,
336    ) -> GpuResult<()> {
337        ctx.check_device_lost()?;
338
339        if dst_texture.format != self.dst_format {
340            return Err(GpuError::UnsupportedFormat(format!(
341                "destination texture format {:?} does not match resampler dst_format {:?}",
342                dst_texture.format, self.dst_format
343            )));
344        }
345
346        let src_view = src_texture.create_view(&wgpu::TextureViewDescriptor::default());
347
348        let bind_group = ctx.device().create_bind_group(&wgpu::BindGroupDescriptor {
349            label: Some("texture_resample_bind_group"),
350            layout: &self.bind_group_layout,
351            entries: &[
352                wgpu::BindGroupEntry {
353                    binding: 0,
354                    resource: wgpu::BindingResource::TextureView(&src_view),
355                },
356                wgpu::BindGroupEntry {
357                    binding: 1,
358                    resource: wgpu::BindingResource::Sampler(&self.sampler),
359                },
360                wgpu::BindGroupEntry {
361                    binding: 2,
362                    resource: wgpu::BindingResource::TextureView(&dst_texture.view),
363                },
364            ],
365        });
366
367        let wg_x = dst_texture.width.div_ceil(16);
368        let wg_y = dst_texture.height.div_ceil(16);
369
370        let mut encoder = ctx
371            .device()
372            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
373                label: Some("texture_resample_encoder"),
374            });
375
376        {
377            let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
378                label: Some("texture_resample_pass"),
379                timestamp_writes: None,
380            });
381            compute_pass.set_pipeline(&self.pipeline);
382            compute_pass.set_bind_group(0, &bind_group, &[]);
383            compute_pass.dispatch_workgroups(wg_x, wg_y, 1);
384        }
385
386        ctx.queue().submit(std::iter::once(encoder.finish()));
387
388        trace!(
389            "Dispatched texture_resample {}×{} → {}×{} workgroups",
390            dst_texture.width, dst_texture.height, wg_x, wg_y
391        );
392
393        Ok(())
394    }
395}
396
397// ─────────────────────────────────────────────────────────────────────────────
398// Input-texture upload helper
399// ─────────────────────────────────────────────────────────────────────────────
400
401/// Create a sampleable R32Float `wgpu::Texture` and upload `data` into it.
402///
403/// The texture is allocated with `TEXTURE_BINDING | COPY_DST` usage so it can
404/// be bound as a `texture_2d<f32>` in a compute shader and have its contents
405/// updated via `queue.write_texture`.
406///
407/// # Errors
408///
409/// Returns [`GpuError::ExecutionFailed`] when `data.len()` does not equal
410/// `width × height`.
411pub fn new_input_texture_r32float(
412    ctx: &GpuContext,
413    width: u32,
414    height: u32,
415    data: &[f32],
416) -> GpuResult<wgpu::Texture> {
417    let expected = (width as usize) * (height as usize);
418    if data.len() != expected {
419        return Err(GpuError::execution_failed(format!(
420            "new_input_texture_r32float: data length {} != width*height {expected}",
421            data.len()
422        )));
423    }
424
425    let texture = ctx.device().create_texture(&wgpu::TextureDescriptor {
426        label: Some("texture_resample_input"),
427        size: wgpu::Extent3d {
428            width,
429            height,
430            depth_or_array_layers: 1,
431        },
432        mip_level_count: 1,
433        sample_count: 1,
434        dimension: wgpu::TextureDimension::D2,
435        format: wgpu::TextureFormat::R32Float,
436        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
437        view_formats: &[],
438    });
439
440    let bytes: &[u8] = bytemuck::cast_slice(data);
441    ctx.queue().write_texture(
442        wgpu::TexelCopyTextureInfo {
443            texture: &texture,
444            mip_level: 0,
445            origin: wgpu::Origin3d::ZERO,
446            aspect: wgpu::TextureAspect::All,
447        },
448        bytes,
449        wgpu::TexelCopyBufferLayout {
450            offset: 0,
451            bytes_per_row: Some(width * 4),
452            rows_per_image: Some(height),
453        },
454        wgpu::Extent3d {
455            width,
456            height,
457            depth_or_array_layers: 1,
458        },
459    );
460
461    Ok(texture)
462}
463
464// ─────────────────────────────────────────────────────────────────────────────
465// Unit tests (pure-Rust, no GPU required)
466// ─────────────────────────────────────────────────────────────────────────────
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_texture_filter_method_wgpu_filter_nearest() {
474        assert_eq!(
475            TextureFilterMethod::Nearest.wgpu_filter(),
476            wgpu::FilterMode::Nearest
477        );
478    }
479
480    #[test]
481    fn test_texture_filter_method_wgpu_filter_linear() {
482        assert_eq!(
483            TextureFilterMethod::Linear.wgpu_filter(),
484            wgpu::FilterMode::Linear
485        );
486    }
487
488    #[test]
489    fn test_dst_format_wgsl_rgba32float() {
490        assert_eq!(
491            dst_format_wgsl(wgpu::TextureFormat::Rgba32Float),
492            "rgba32float"
493        );
494    }
495
496    #[test]
497    fn test_dst_format_wgsl_r32float() {
498        assert_eq!(dst_format_wgsl(wgpu::TextureFormat::R32Float), "r32float");
499    }
500
501    #[test]
502    fn test_dst_format_wgsl_rgba8unorm() {
503        assert_eq!(
504            dst_format_wgsl(wgpu::TextureFormat::Rgba8Unorm),
505            "rgba8unorm"
506        );
507    }
508
509    #[test]
510    fn test_dst_format_wgsl_unsupported_defaults_to_rgba32float() {
511        assert_eq!(
512            dst_format_wgsl(wgpu::TextureFormat::Depth32Float),
513            "rgba32float"
514        );
515    }
516
517    #[test]
518    fn test_is_supported_dst_format_accepted() {
519        assert!(is_supported_dst_format(wgpu::TextureFormat::Rgba32Float));
520        assert!(is_supported_dst_format(wgpu::TextureFormat::R32Float));
521        assert!(is_supported_dst_format(wgpu::TextureFormat::Rgba8Unorm));
522    }
523
524    #[test]
525    fn test_is_supported_dst_format_rejected() {
526        assert!(!is_supported_dst_format(wgpu::TextureFormat::Depth32Float));
527        assert!(!is_supported_dst_format(wgpu::TextureFormat::Rgba16Float));
528    }
529}