Skip to main content

oxigdal_gpu/
storage_texture.rs

1//! Storage-texture output kernel support for OxiGDAL GPU.
2//!
3//! This module enables compute shaders to write their output directly to a
4//! 2-D storage texture (WGSL `texture_storage_2d<format, write>`), bypassing
5//! the round-trip to a CPU-visible staging buffer.  The resulting texture lives
6//! on the GPU and can be consumed by subsequent render passes, image-processing
7//! pipelines, or downloaded via [`read_texture_to_vec_f32`] when the host
8//! needs the data.
9//!
10//! # Typical workflow
11//!
12//! 1. Create a storage texture with [`new_storage_texture`].
13//! 2. Build a compute kernel from WGSL source with [`build_storage_texture_kernel`].
14//! 3. Upload input data as a `GpuBuffer` using the standard buffer API.
15//! 4. Execute the kernel with [`StorageTextureKernel::dispatch_to_texture`].
16//! 5. Optionally download results with [`read_texture_to_vec_f32`].
17//!
18//! # Supported formats
19//!
20//! | [`wgpu::TextureFormat`]  | WGSL format string |
21//! |--------------------------|--------------------|
22//! | `Rgba32Float`            | `rgba32float`      |
23//! | `Rgba8Unorm`             | `rgba8unorm`       |
24//! | `R32Float`               | `r32float`         |
25//!
26//! Other formats are rejected by [`new_storage_texture`] with
27//! [`GpuError::UnsupportedFormat`].
28
29use crate::context::GpuContext;
30use crate::error::{GpuError, GpuResult};
31use crate::pipeline_cache::PipelineCacheKey;
32use std::sync::Arc;
33use tracing::{debug, trace};
34
35// ─────────────────────────────────────────────────────────────────────────────
36// Public types
37// ─────────────────────────────────────────────────────────────────────────────
38
39/// A 2-D GPU texture created with `STORAGE_BINDING | COPY_SRC` usage.
40///
41/// Created by [`new_storage_texture`] and consumed by
42/// [`StorageTextureKernel::dispatch_to_texture`].  The texture lives entirely
43/// on the GPU; use [`read_texture_to_vec_f32`] to copy data back to the host.
44pub struct StorageTextureBinding {
45    /// The underlying wgpu texture.
46    pub texture: wgpu::Texture,
47    /// Default view over the entire texture (all mip-levels, all layers).
48    pub view: wgpu::TextureView,
49    /// Pixel format of this texture.
50    pub format: wgpu::TextureFormat,
51    /// Width in pixels.
52    pub width: u32,
53    /// Height in pixels.
54    pub height: u32,
55}
56
57/// A compiled compute kernel that writes its output to a storage texture.
58///
59/// Constructed by [`build_storage_texture_kernel`].  A single kernel can be
60/// dispatched to multiple different textures of compatible format.
61pub struct StorageTextureKernel {
62    pipeline: Arc<wgpu::ComputePipeline>,
63    bind_group_layout: wgpu::BindGroupLayout,
64}
65
66// ─────────────────────────────────────────────────────────────────────────────
67// Format helpers
68// ─────────────────────────────────────────────────────────────────────────────
69
70/// Returns the WGSL storage-texture format string for a given
71/// [`wgpu::TextureFormat`], or `None` if the format is not supported as a
72/// storage texture by this crate.
73fn wgsl_storage_format_str(format: wgpu::TextureFormat) -> Option<&'static str> {
74    match format {
75        wgpu::TextureFormat::Rgba32Float => Some("rgba32float"),
76        wgpu::TextureFormat::Rgba8Unorm => Some("rgba8unorm"),
77        wgpu::TextureFormat::R32Float => Some("r32float"),
78        _ => None,
79    }
80}
81
82/// Returns `true` if `format` is accepted by [`new_storage_texture`].
83pub fn is_supported_storage_format(format: wgpu::TextureFormat) -> bool {
84    wgsl_storage_format_str(format).is_some()
85}
86
87/// Number of bytes per texel for the supported storage formats.
88///
89/// Returns `None` for unsupported formats.
90fn bytes_per_texel(format: wgpu::TextureFormat) -> Option<u64> {
91    match format {
92        wgpu::TextureFormat::Rgba32Float => Some(16), // 4 × f32
93        wgpu::TextureFormat::Rgba8Unorm => Some(4),   // 4 × u8
94        wgpu::TextureFormat::R32Float => Some(4),     // 1 × f32
95        _ => None,
96    }
97}
98
99// ─────────────────────────────────────────────────────────────────────────────
100// Shader source generation
101// ─────────────────────────────────────────────────────────────────────────────
102
103/// Generate default WGSL source for a kernel that copies a flat `f32` input
104/// buffer into a 2-D storage texture.
105///
106/// The generated shader:
107/// - Reads a flat `array<f32>` from binding 0 (storage, read).
108/// - Writes to a `texture_storage_2d<{fmt}, write>` at binding 1.
109/// - Dispatches 16×16 workgroups; out-of-bounds invocations are discarded.
110///
111/// For `Rgba32Float` and `Rgba8Unorm` the input scalar `val` is replicated to
112/// all four channels; for `R32Float` only the first channel is written.
113///
114/// This is the **default** shader used by [`build_storage_texture_kernel`]
115/// when no custom WGSL source is provided.  Callers may supply their own WGSL
116/// to [`build_storage_texture_kernel`] for arbitrary kernel logic.
117pub fn make_storage_texture_shader_source(format: wgpu::TextureFormat) -> String {
118    let fmt_str = wgsl_storage_format_str(format).unwrap_or("rgba32float"); // safe fallback — caller should use supported formats
119
120    // `textureStore` for `rgba8unorm` and `rgba32float` takes `vec4<f32>`;
121    // for `r32float` it also takes `vec4<f32>` (padded) per WGSL spec.
122    format!(
123        r#"
124@group(0) @binding(0) var<storage, read> input: array<f32>;
125@group(0) @binding(1) var output: texture_storage_2d<{fmt}, write>;
126
127@compute @workgroup_size(16, 16)
128fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
129    let dims = textureDimensions(output);
130    if gid.x >= dims.x || gid.y >= dims.y {{ return; }}
131    let idx = gid.y * dims.x + gid.x;
132    let val = input[idx];
133    textureStore(output, vec2<i32>(i32(gid.x), i32(gid.y)), vec4<f32>(val, val, val, 1.0));
134}}
135"#,
136        fmt = fmt_str
137    )
138}
139
140// ─────────────────────────────────────────────────────────────────────────────
141// StorageTextureBinding constructor
142// ─────────────────────────────────────────────────────────────────────────────
143
144/// Create a new 2-D storage texture on the GPU.
145///
146/// The texture is allocated with `STORAGE_BINDING | COPY_SRC` usage so it can
147/// be written by a compute shader and subsequently copied to a staging buffer
148/// for host readback.
149///
150/// # Errors
151///
152/// Returns [`GpuError::UnsupportedFormat`] when `format` is not one of the
153/// formats supported as storage textures:
154/// `Rgba32Float`, `Rgba8Unorm`, or `R32Float`.
155///
156/// # Example
157///
158/// ```rust,no_run
159/// use oxigdal_gpu::{GpuContext, storage_texture::new_storage_texture};
160///
161/// # async fn ex() -> oxigdal_gpu::GpuResult<()> {
162/// let ctx = GpuContext::new().await?;
163/// let tex = new_storage_texture(&ctx, 512, 512, wgpu::TextureFormat::Rgba32Float)?;
164/// assert_eq!(tex.width, 512);
165/// assert_eq!(tex.height, 512);
166/// # Ok(())
167/// # }
168/// ```
169pub fn new_storage_texture(
170    ctx: &GpuContext,
171    width: u32,
172    height: u32,
173    format: wgpu::TextureFormat,
174) -> GpuResult<StorageTextureBinding> {
175    if !is_supported_storage_format(format) {
176        return Err(GpuError::UnsupportedFormat(format!("{format:?}")));
177    }
178
179    let texture = ctx.device().create_texture(&wgpu::TextureDescriptor {
180        label: Some("oxigdal_storage_texture"),
181        size: wgpu::Extent3d {
182            width,
183            height,
184            depth_or_array_layers: 1,
185        },
186        mip_level_count: 1,
187        sample_count: 1,
188        dimension: wgpu::TextureDimension::D2,
189        format,
190        usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
191        view_formats: &[],
192    });
193
194    let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
195
196    debug!(
197        "Created storage texture {}×{} format={format:?}",
198        width, height
199    );
200
201    Ok(StorageTextureBinding {
202        texture,
203        view,
204        format,
205        width,
206        height,
207    })
208}
209
210// ─────────────────────────────────────────────────────────────────────────────
211// Build the bind group layout
212// ─────────────────────────────────────────────────────────────────────────────
213
214/// Build the bind-group layout expected by storage-texture kernels.
215///
216/// Layout:
217/// - **binding 0**: `storage` buffer (read-only) — the input data.
218/// - **binding 1**: `texture_storage_2d` (write-only) — the output texture.
219///
220/// The format supplied must be the same format used for the actual texture.
221fn make_bind_group_layout(
222    device: &wgpu::Device,
223    format: wgpu::TextureFormat,
224) -> wgpu::BindGroupLayout {
225    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
226        label: Some("storage_texture_bgl"),
227        entries: &[
228            // binding 0: input storage buffer (read)
229            wgpu::BindGroupLayoutEntry {
230                binding: 0,
231                visibility: wgpu::ShaderStages::COMPUTE,
232                ty: wgpu::BindingType::Buffer {
233                    ty: wgpu::BufferBindingType::Storage { read_only: true },
234                    has_dynamic_offset: false,
235                    min_binding_size: None,
236                },
237                count: None,
238            },
239            // binding 1: storage texture (write)
240            wgpu::BindGroupLayoutEntry {
241                binding: 1,
242                visibility: wgpu::ShaderStages::COMPUTE,
243                ty: wgpu::BindingType::StorageTexture {
244                    access: wgpu::StorageTextureAccess::WriteOnly,
245                    format,
246                    view_dimension: wgpu::TextureViewDimension::D2,
247                },
248                count: None,
249            },
250        ],
251    })
252}
253
254// ─────────────────────────────────────────────────────────────────────────────
255// Build the compute kernel
256// ─────────────────────────────────────────────────────────────────────────────
257
258/// Compile a WGSL compute shader into a [`StorageTextureKernel`].
259///
260/// The shader is looked up in the context's [`crate::pipeline_cache::PipelineCache`]
261/// by the FNV-1a hash of `wgsl_source`, `entry_point`, and the output texture
262/// format.  On a cache hit the existing compiled pipeline is returned
263/// immediately; on a miss the shader is compiled and cached for future calls.
264///
265/// # Parameters
266///
267/// - `ctx` — the active GPU context.
268/// - `wgsl_source` — full WGSL compute shader text.  Use
269///   [`make_storage_texture_shader_source`] to generate a default copy kernel.
270/// - `entry_point` — the `@compute` function name, usually `"main"`.
271/// - `output_format` — the format of the storage texture this kernel will
272///   write to.  Must be one of the formats accepted by [`new_storage_texture`].
273///
274/// # Errors
275///
276/// Returns [`GpuError::UnsupportedFormat`] if `output_format` is unsupported,
277/// or [`GpuError::PipelineCreation`] if shader compilation fails.
278pub fn build_storage_texture_kernel(
279    ctx: &GpuContext,
280    wgsl_source: &str,
281    entry_point: &str,
282    output_format: wgpu::TextureFormat,
283) -> GpuResult<StorageTextureKernel> {
284    if !is_supported_storage_format(output_format) {
285        return Err(GpuError::UnsupportedFormat(format!("{output_format:?}")));
286    }
287
288    let bind_group_layout = make_bind_group_layout(ctx.device(), output_format);
289
290    let pipeline_layout = ctx
291        .device()
292        .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
293            label: Some("storage_texture_pipeline_layout"),
294            bind_group_layouts: &[Some(&bind_group_layout)],
295            immediate_size: 0,
296        });
297
298    // Key for the pipeline cache: hash(wgsl_source) + entry_point + format tag.
299    let layout_tag = format!("r-tex:{output_format:?}");
300    let cache_key = PipelineCacheKey::new(wgsl_source, entry_point, &layout_tag);
301
302    // Try the shared pipeline cache first.
303    let pipeline = {
304        let cache_result = ctx
305            .pipeline_cache()
306            .lock()
307            .map_err(|_| GpuError::internal("pipeline cache mutex poisoned"));
308
309        match cache_result {
310            Ok(mut guard) => {
311                let source_snapshot = wgsl_source.to_owned();
312                let device = ctx.device();
313                let entry_owned = entry_point.to_owned();
314                let layout_ref = &pipeline_layout;
315
316                guard.get_or_insert_with(cache_key.clone(), || {
317                    compile_compute_pipeline(device, &source_snapshot, &entry_owned, layout_ref)
318                        .map_err(|e| GpuError::pipeline_creation(e))
319                })?
320            }
321            Err(_) => {
322                // Cache unavailable — compile directly without caching.
323                tracing::warn!(
324                    "Pipeline cache mutex poisoned; compiling storage_texture pipeline without cache"
325                );
326                Arc::new(
327                    compile_compute_pipeline(
328                        ctx.device(),
329                        wgsl_source,
330                        entry_point,
331                        &pipeline_layout,
332                    )
333                    .map_err(GpuError::pipeline_creation)?,
334                )
335            }
336        }
337    };
338
339    trace!("StorageTextureKernel built, cache_key={cache_key}");
340
341    Ok(StorageTextureKernel {
342        pipeline,
343        bind_group_layout,
344    })
345}
346
347/// Compile a `wgpu::ComputePipeline` from WGSL source.
348///
349/// Returns the raw pipeline (not wrapped in `Arc`) so the caller can decide
350/// whether to cache it.
351fn compile_compute_pipeline(
352    device: &wgpu::Device,
353    wgsl_source: &str,
354    entry_point: &str,
355    pipeline_layout: &wgpu::PipelineLayout,
356) -> Result<wgpu::ComputePipeline, String> {
357    let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
358        label: Some("storage_texture_shader"),
359        source: wgpu::ShaderSource::Wgsl(wgsl_source.into()),
360    });
361
362    Ok(
363        device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
364            label: Some("storage_texture_pipeline"),
365            layout: Some(pipeline_layout),
366            module: &shader_module,
367            entry_point: Some(entry_point),
368            compilation_options: wgpu::PipelineCompilationOptions::default(),
369            cache: None,
370        }),
371    )
372}
373
374// ─────────────────────────────────────────────────────────────────────────────
375// StorageTextureKernel methods
376// ─────────────────────────────────────────────────────────────────────────────
377
378impl StorageTextureKernel {
379    /// Dispatch the compute kernel, writing output directly into `texture`.
380    ///
381    /// The input data must already be resident on the GPU as `input_buffer`.
382    /// The kernel is dispatched with `ceil(width / 16) × ceil(height / 16) × 1`
383    /// workgroups, matching the default 16×16 workgroup size in
384    /// [`make_storage_texture_shader_source`].
385    ///
386    /// # Key benefit
387    ///
388    /// The results are **not** copied back to CPU memory.  This avoids the
389    /// expensive PCIe transfer and is useful when the texture will be consumed
390    /// by subsequent GPU operations (e.g., compositing, rendering).
391    ///
392    /// # Errors
393    ///
394    /// Returns an error if bind-group creation fails or the device is lost.
395    pub fn dispatch_to_texture<T: bytemuck::Pod>(
396        &self,
397        ctx: &GpuContext,
398        input_buffer: &crate::buffer::GpuBuffer<T>,
399        texture: &StorageTextureBinding,
400    ) -> GpuResult<()> {
401        ctx.check_device_lost()?;
402
403        let bind_group = ctx.device().create_bind_group(&wgpu::BindGroupDescriptor {
404            label: Some("storage_texture_bind_group"),
405            layout: &self.bind_group_layout,
406            entries: &[
407                wgpu::BindGroupEntry {
408                    binding: 0,
409                    resource: input_buffer.buffer().as_entire_binding(),
410                },
411                wgpu::BindGroupEntry {
412                    binding: 1,
413                    resource: wgpu::BindingResource::TextureView(&texture.view),
414                },
415            ],
416        });
417
418        let wg_x = dispatch_count(texture.width, 16);
419        let wg_y = dispatch_count(texture.height, 16);
420
421        let mut encoder = ctx
422            .device()
423            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
424                label: Some("storage_texture_dispatch_encoder"),
425            });
426
427        {
428            let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
429                label: Some("storage_texture_compute_pass"),
430                timestamp_writes: None,
431            });
432            compute_pass.set_pipeline(&self.pipeline);
433            compute_pass.set_bind_group(0, &bind_group, &[]);
434            compute_pass.dispatch_workgroups(wg_x, wg_y, 1);
435        }
436
437        ctx.queue().submit(std::iter::once(encoder.finish()));
438
439        trace!(
440            "Dispatched storage_texture kernel {}×{} → {}×{} workgroups",
441            texture.width, texture.height, wg_x, wg_y
442        );
443
444        Ok(())
445    }
446}
447
448// ─────────────────────────────────────────────────────────────────────────────
449// Texture readback
450// ─────────────────────────────────────────────────────────────────────────────
451
452/// Copy a storage texture to host memory, returning a `Vec<f32>`.
453///
454/// For `Rgba32Float` the returned vector contains `width × height × 4` values
455/// (R, G, B, A interleaved).  For `Rgba8Unorm` and `R32Float` the byte layout
456/// is first converted to `f32` accordingly:
457///
458/// | Format           | Conversion               | Vec length          |
459/// |------------------|--------------------------|---------------------|
460/// | `Rgba32Float`    | raw bytes cast to `f32`  | `w × h × 4`        |
461/// | `Rgba8Unorm`     | u8 → f32 (÷ 255)        | `w × h × 4`        |
462/// | `R32Float`       | raw bytes cast to `f32`  | `w × h`             |
463///
464/// # Errors
465///
466/// - [`GpuError::UnsupportedFormat`] — texture format is not supported.
467/// - [`GpuError::BufferMapping`] — failed to map the staging buffer.
468pub fn read_texture_to_vec_f32(
469    ctx: &GpuContext,
470    texture: &StorageTextureBinding,
471) -> GpuResult<Vec<f32>> {
472    ctx.check_device_lost()?;
473
474    let bpp = bytes_per_texel(texture.format)
475        .ok_or_else(|| GpuError::UnsupportedFormat(format!("{:?}", texture.format)))?;
476
477    // wgpu requires the bytes-per-row to be a multiple of COPY_BYTES_PER_ROW_ALIGNMENT (256).
478    let bytes_per_row_unaligned = (texture.width as u64) * bpp;
479    let alignment = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as u64;
480    let bytes_per_row_aligned = (bytes_per_row_unaligned + alignment - 1) / alignment * alignment;
481
482    let staging_size = bytes_per_row_aligned * (texture.height as u64);
483
484    let staging_buffer = ctx.device().create_buffer(&wgpu::BufferDescriptor {
485        label: Some("storage_texture_staging"),
486        size: staging_size,
487        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
488        mapped_at_creation: false,
489    });
490
491    // Encode texture → buffer copy.
492    let mut encoder = ctx
493        .device()
494        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
495            label: Some("storage_texture_readback_encoder"),
496        });
497
498    encoder.copy_texture_to_buffer(
499        wgpu::TexelCopyTextureInfo {
500            texture: &texture.texture,
501            mip_level: 0,
502            origin: wgpu::Origin3d::ZERO,
503            aspect: wgpu::TextureAspect::All,
504        },
505        wgpu::TexelCopyBufferInfo {
506            buffer: &staging_buffer,
507            layout: wgpu::TexelCopyBufferLayout {
508                offset: 0,
509                bytes_per_row: Some(bytes_per_row_aligned as u32),
510                rows_per_image: Some(texture.height),
511            },
512        },
513        wgpu::Extent3d {
514            width: texture.width,
515            height: texture.height,
516            depth_or_array_layers: 1,
517        },
518    );
519
520    ctx.queue().submit(std::iter::once(encoder.finish()));
521
522    // Map and read the staging buffer (blocking via pollster).
523    let result = read_staging_buffer_blocking(ctx, &staging_buffer, staging_size)?;
524
525    // Decode bytes → f32 depending on format.
526    let floats = decode_texture_bytes(
527        &result,
528        texture.format,
529        texture.width,
530        texture.height,
531        bytes_per_row_aligned,
532    );
533
534    Ok(floats)
535}
536
537/// Block until the staging buffer is mapped and return its bytes.
538fn read_staging_buffer_blocking(
539    ctx: &GpuContext,
540    staging_buffer: &wgpu::Buffer,
541    staging_size: u64,
542) -> GpuResult<Vec<u8>> {
543    use std::sync::{Arc as StdArc, Mutex as StdMutex};
544
545    let result: StdArc<StdMutex<Option<Result<(), wgpu::BufferAsyncError>>>> =
546        StdArc::new(StdMutex::new(None));
547    let result_clone = StdArc::clone(&result);
548
549    staging_buffer
550        .slice(..)
551        .map_async(wgpu::MapMode::Read, move |r| {
552            let mut guard = result_clone.lock().unwrap_or_else(|e| e.into_inner());
553            *guard = Some(r);
554        });
555
556    // Poll until the mapping is complete.
557    let _poll_handle = ctx.spawn_poll_task();
558    loop {
559        {
560            let guard = result.lock().unwrap_or_else(|e| e.into_inner());
561            if guard.is_some() {
562                break;
563            }
564        }
565        std::thread::sleep(std::time::Duration::from_millis(1));
566    }
567
568    let map_result = result
569        .lock()
570        .unwrap_or_else(|e| e.into_inner())
571        .take()
572        .ok_or_else(|| GpuError::buffer_mapping("mapping never completed"))?;
573
574    map_result.map_err(|e| GpuError::buffer_mapping(format!("map_async failed: {e}")))?;
575
576    let view = staging_buffer.slice(..).get_mapped_range();
577    let bytes = view[..staging_size as usize].to_vec();
578    drop(view);
579    staging_buffer.unmap();
580
581    Ok(bytes)
582}
583
584/// Decode raw texture bytes into `f32` values according to the texture format.
585fn decode_texture_bytes(
586    raw: &[u8],
587    format: wgpu::TextureFormat,
588    width: u32,
589    height: u32,
590    bytes_per_row_aligned: u64,
591) -> Vec<f32> {
592    let bpp = bytes_per_texel(format).unwrap_or(4);
593    let bytes_per_row_unaligned = (width as u64) * bpp;
594
595    match format {
596        wgpu::TextureFormat::Rgba32Float => {
597            // 4 × f32 per texel; must strip padding rows.
598            let mut out = Vec::with_capacity((width * height * 4) as usize);
599            for row in 0..height as usize {
600                let row_start = row * bytes_per_row_aligned as usize;
601                let row_end = row_start + bytes_per_row_unaligned as usize;
602                let row_bytes = &raw[row_start..row_end];
603                // Cast &[u8] → &[f32] via bytemuck.
604                let row_floats: &[f32] = bytemuck::cast_slice(row_bytes);
605                out.extend_from_slice(row_floats);
606            }
607            out
608        }
609        wgpu::TextureFormat::Rgba8Unorm => {
610            // 4 × u8 per texel; convert to f32 by dividing by 255.
611            let mut out = Vec::with_capacity((width * height * 4) as usize);
612            for row in 0..height as usize {
613                let row_start = row * bytes_per_row_aligned as usize;
614                let row_end = row_start + bytes_per_row_unaligned as usize;
615                for &byte in &raw[row_start..row_end] {
616                    out.push(byte as f32 / 255.0);
617                }
618            }
619            out
620        }
621        wgpu::TextureFormat::R32Float => {
622            // 1 × f32 per texel.
623            let mut out = Vec::with_capacity((width * height) as usize);
624            for row in 0..height as usize {
625                let row_start = row * bytes_per_row_aligned as usize;
626                let row_end = row_start + bytes_per_row_unaligned as usize;
627                let row_floats: &[f32] = bytemuck::cast_slice(&raw[row_start..row_end]);
628                out.extend_from_slice(row_floats);
629            }
630            out
631        }
632        // Unreachable: we validated the format at the top of read_texture_to_vec_f32.
633        _ => vec![],
634    }
635}
636
637// ─────────────────────────────────────────────────────────────────────────────
638// Utility
639// ─────────────────────────────────────────────────────────────────────────────
640
641/// Ceiling division — equivalent to `(n + d - 1) / d` for u32.
642#[inline]
643fn dispatch_count(n: u32, d: u32) -> u32 {
644    n.div_ceil(d)
645}
646
647// ─────────────────────────────────────────────────────────────────────────────
648// Unit tests (pure-Rust, no GPU required)
649// ─────────────────────────────────────────────────────────────────────────────
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn test_wgsl_format_str_rgba32float() {
657        assert_eq!(
658            wgsl_storage_format_str(wgpu::TextureFormat::Rgba32Float),
659            Some("rgba32float")
660        );
661    }
662
663    #[test]
664    fn test_wgsl_format_str_rgba8unorm() {
665        assert_eq!(
666            wgsl_storage_format_str(wgpu::TextureFormat::Rgba8Unorm),
667            Some("rgba8unorm")
668        );
669    }
670
671    #[test]
672    fn test_wgsl_format_str_r32float() {
673        assert_eq!(
674            wgsl_storage_format_str(wgpu::TextureFormat::R32Float),
675            Some("r32float")
676        );
677    }
678
679    #[test]
680    fn test_wgsl_format_str_unsupported_returns_none() {
681        assert_eq!(
682            wgsl_storage_format_str(wgpu::TextureFormat::Depth32Float),
683            None
684        );
685    }
686
687    #[test]
688    fn test_is_supported_storage_format_accepted() {
689        assert!(is_supported_storage_format(
690            wgpu::TextureFormat::Rgba32Float
691        ));
692        assert!(is_supported_storage_format(wgpu::TextureFormat::Rgba8Unorm));
693        assert!(is_supported_storage_format(wgpu::TextureFormat::R32Float));
694    }
695
696    #[test]
697    fn test_is_supported_storage_format_rejected() {
698        assert!(!is_supported_storage_format(
699            wgpu::TextureFormat::Depth32Float
700        ));
701        assert!(!is_supported_storage_format(
702            wgpu::TextureFormat::Rgba16Float
703        ));
704    }
705
706    #[test]
707    fn test_dispatch_count_exact_multiple() {
708        assert_eq!(dispatch_count(64, 16), 4);
709    }
710
711    #[test]
712    fn test_dispatch_count_rounds_up() {
713        assert_eq!(dispatch_count(65, 16), 5);
714        assert_eq!(dispatch_count(1, 16), 1);
715        assert_eq!(dispatch_count(16, 16), 1);
716        assert_eq!(dispatch_count(17, 16), 2);
717    }
718
719    #[test]
720    fn test_bytes_per_texel_rgba32float() {
721        assert_eq!(bytes_per_texel(wgpu::TextureFormat::Rgba32Float), Some(16));
722    }
723
724    #[test]
725    fn test_bytes_per_texel_rgba8unorm() {
726        assert_eq!(bytes_per_texel(wgpu::TextureFormat::Rgba8Unorm), Some(4));
727    }
728
729    #[test]
730    fn test_bytes_per_texel_r32float() {
731        assert_eq!(bytes_per_texel(wgpu::TextureFormat::R32Float), Some(4));
732    }
733
734    #[test]
735    fn test_bytes_per_texel_unsupported_returns_none() {
736        assert_eq!(bytes_per_texel(wgpu::TextureFormat::Depth32Float), None);
737    }
738}