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}