Skip to main content

viewport_lib/resources/volume/
gpu_marching_cubes.rs

1//! GPU marching cubes.
2//!
3//! Three-pass GPU compute pipeline for isosurface extraction:
4//!   1. Classify       computes case index and triangle count per cell.
5//!   2. Prefix sum     hierarchical exclusive scan to build triangle offsets.
6//!   3. Generate       interpolates vertex positions and normals into a vertex buffer.
7//!
8//! The output is drawn with a lightweight Phong render pipeline via `draw_indirect`.
9
10use bytemuck::{Pod, Zeroable};
11use wgpu::util::DeviceExt as _;
12
13use crate::{
14    geometry::marching_cubes::{TRI_TABLE, VolumeData},
15    renderer::GpuMarchingCubesJob,
16    resources::{DeviceResources, DualPipeline},
17};
18
19/// GPU marching-cubes pipelines, layouts, and static case tables.
20///
21/// The three compute passes (classify / prefix-sum / generate), the surface and
22/// wireframe render pipelines, and the shared case-count / case-table buffers.
23/// All device-shared and lazily built; `volumes` holds the per-item extraction
24/// state keyed by submission order.
25#[derive(Default)]
26pub(crate) struct McResources {
27    pub(crate) classify_pipeline: Option<wgpu::ComputePipeline>,
28    pub(crate) prefix_sum_pipeline: Option<wgpu::ComputePipeline>,
29    pub(crate) generate_pipeline: Option<wgpu::ComputePipeline>,
30    pub(crate) surface_pipeline: Option<DualPipeline>,
31    pub(crate) wireframe_pipeline: Option<DualPipeline>,
32    pub(crate) wireframe_render_bgl: Option<wgpu::BindGroupLayout>,
33    pub(crate) classify_bgl: Option<wgpu::BindGroupLayout>,
34    pub(crate) prefix_sum_bgl: Option<wgpu::BindGroupLayout>,
35    pub(crate) generate_bgl: Option<wgpu::BindGroupLayout>,
36    pub(crate) render_bgl: Option<wgpu::BindGroupLayout>,
37    pub(crate) case_count_buf: Option<wgpu::Buffer>,
38    pub(crate) case_table_buf: Option<wgpu::Buffer>,
39    pub(crate) volumes: Vec<McVolumeGpuData>,
40    /// Outline mask pipeline for MC surfaces (stride-24 vertex buffer, draw_indirect).
41    pub(crate) outline_mask_pipeline: Option<wgpu::RenderPipeline>,
42}
43
44// ---------------------------------------------------------------------------
45// Public API
46// ---------------------------------------------------------------------------
47
48crate::resources::handle::slot_handle! {
49    /// Handle to a volume scalar field uploaded for GPU marching cubes.
50    ///
51    /// Returned by [`DeviceResources::upload_volume_for_mc`]. Pass to
52    /// [`GpuMarchingCubesJob`] to select which volume to triangulate each frame.
53    ///
54    /// Carries the slot index plus the generation the slot had when the handle
55    /// was issued. A handle whose volume was removed (its slot freed and reused
56    /// by a later upload) resolves to nothing on lookup, so it cannot alias the
57    /// volume now in its slot.
58    pub struct McVolumeId;
59}
60
61// ---------------------------------------------------------------------------
62// GPU-internal types
63// ---------------------------------------------------------------------------
64
65/// GPU buffers for one Z-axis slab of an uploaded volume.
66///
67/// A slab covers `dims[2]` scalar Z-layers (`dims[2] - 1` cell layers).
68/// Adjacent slabs share exactly one scalar Z-layer at their boundary so MC
69/// edge interpolation produces no seams.
70pub(crate) struct McSlabGpuData {
71    pub scalar_buf: wgpu::Buffer,   // f32 per slab node; STORAGE | COPY_DST
72    pub counts_buf: wgpu::Buffer,   // u32 per slab cell; STORAGE
73    pub case_idx_buf: wgpu::Buffer, // u32 per slab cell; STORAGE
74    pub offsets_buf: wgpu::Buffer,  // u32 per slab cell; STORAGE
75    pub block_sums_buf: wgpu::Buffer, // u32 per slab block; STORAGE
76    pub vertex_buf: wgpu::Buffer,   // f32 * 6 per vertex; STORAGE | VERTEX
77    pub indirect_buf: wgpu::Buffer, // 4 u32; STORAGE | INDIRECT (surface draw)
78    pub wire_indirect_buf: wgpu::Buffer, // 4 u32; STORAGE | INDIRECT (wireframe draw)
79    pub dims: [u32; 3],             // [nx, ny, slab_nz] (scalar layers)
80    pub origin: [f32; 3],           // world origin; z is offset per slab
81    pub spacing: [f32; 3],
82    pub cell_count: u32,
83    pub block_count: u32,
84}
85
86/// Persistent GPU resources for one uploaded volume, split into Z-axis slabs.
87///
88/// Z-axis chunking keeps every allocation within `device.limits().max_buffer_size`
89/// regardless of volume size. The single-slab path is equivalent to the old layout.
90pub(crate) struct McVolumeGpuData {
91    pub slabs: Vec<McSlabGpuData>,
92    /// False after `free_mc_volume` is called; the emptied slot is reused lazily.
93    pub alive: bool,
94    /// Bumped each time the slot is freed, so a handle issued for an earlier
95    /// occupant no longer resolves once the slot is reused.
96    pub generation: u32,
97}
98
99impl McVolumeGpuData {
100    /// Resident GPU bytes across every slab buffer of this volume. Zero for a
101    /// freed slot (its slabs are dropped on free).
102    pub fn gpu_bytes(&self) -> u64 {
103        self.slabs
104            .iter()
105            .map(|s| {
106                s.scalar_buf.size()
107                    + s.counts_buf.size()
108                    + s.case_idx_buf.size()
109                    + s.offsets_buf.size()
110                    + s.block_sums_buf.size()
111                    + s.vertex_buf.size()
112                    + s.indirect_buf.size()
113                    + s.wire_indirect_buf.size()
114            })
115            .sum()
116    }
117}
118
119/// Per-frame data for one MC job, consumed by the render phase.
120pub(crate) struct McFrameData {
121    pub volume_idx: usize,
122    pub render_bg: wgpu::BindGroup,
123    /// True if this job was submitted with `appearance.wireframe = true`.
124    pub wireframe: bool,
125    /// Per-slab bind groups for the wireframe pipeline (binding 0 = vertex storage buffer).
126    pub wire_slab_bgs: Vec<wgpu::BindGroup>,
127}
128
129/// Per-selected MC job data for the outline mask pass.
130pub(crate) struct McOutlineItem {
131    /// Index into `mc_gpu_data` (frame-level array of processed MC jobs).
132    pub mc_gpu_idx: usize,
133    pub _uniform_buf: wgpu::Buffer,
134    pub mask_bind_group: wgpu::BindGroup,
135}
136
137// ---------------------------------------------------------------------------
138// Raw uniform buffer layouts (bytemuck-safe)
139// ---------------------------------------------------------------------------
140
141#[repr(C)]
142#[derive(Clone, Copy, Pod, Zeroable)]
143struct ClassifyParams {
144    nx: u32,
145    ny: u32,
146    nz: u32,
147    isovalue: f32,
148}
149
150#[repr(C)]
151#[derive(Clone, Copy, Pod, Zeroable)]
152struct PrefixSumParams {
153    cell_count: u32,
154    block_count: u32,
155    level: u32,
156    _pad: u32,
157}
158
159#[repr(C)]
160#[derive(Clone, Copy, Pod, Zeroable)]
161struct GenerateParams {
162    nx: u32,
163    ny: u32,
164    nz: u32,
165    isovalue: f32,
166    origin_x: f32,
167    origin_y: f32,
168    origin_z: f32,
169    _pad0: f32,
170    spacing_x: f32,
171    spacing_y: f32,
172    spacing_z: f32,
173    _pad1: f32,
174}
175
176#[repr(C)]
177#[derive(Clone, Copy, Pod, Zeroable)]
178struct McSurfaceRaw {
179    base_colour: [f32; 3],
180    roughness: f32,
181    unlit: u32,
182    opacity: f32,
183    /// Per-material ambient scalar from `Material::ambient`. Added to the
184    /// hemisphere ambient term so the MC shaded result matches the regular
185    /// mesh shader's Blinn-Phong path on materials that use the default
186    /// `ambient = 0.15`. Without this field the MC surface reads notably
187    /// darker on its shadowed side than an equivalent regular mesh.
188    ambient: f32,
189    _pad: u32,
190}
191
192// ---------------------------------------------------------------------------
193// Lookup table helpers
194// ---------------------------------------------------------------------------
195
196/// Triangle count per case: derived from TRI_TABLE by counting non-sentinel entries.
197fn case_triangle_count_table() -> [u32; 256] {
198    let mut out = [0u32; 256];
199    for (i, row) in TRI_TABLE.iter().enumerate() {
200        let mut count = 0u32;
201        let mut j = 0;
202        while j < 15 && row[j] >= 0 {
203            count += 1;
204            j += 3;
205        }
206        out[i] = count;
207    }
208    out
209}
210
211/// Flat TRI_TABLE for the GPU: 256 x 16 i32 values.
212fn case_table_flat() -> [i32; 256 * 16] {
213    let mut out = [-1i32; 256 * 16];
214    for (i, row) in TRI_TABLE.iter().enumerate() {
215        for (j, &v) in row.iter().enumerate() {
216            out[i * 16 + j] = v as i32;
217        }
218    }
219    out
220}
221
222// ---------------------------------------------------------------------------
223// Pipeline init and volume upload (impl DeviceResources)
224// ---------------------------------------------------------------------------
225
226impl DeviceResources {
227    /// Lazily create all GPU MC pipelines and shared lookup buffers.
228    ///
229    /// No-op if already initialised.
230    pub(crate) fn ensure_mc_pipelines(&mut self, device: &wgpu::Device) {
231        if self.mc.classify_pipeline.is_some() {
232            return;
233        }
234
235        // ----------------------------------------------------------------
236        // Shared lookup buffers (uploaded once).
237        // ----------------------------------------------------------------
238        let count_table = case_triangle_count_table();
239        let mc_case_count_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
240            label: Some("mc_case_count_buf"),
241            contents: bytemuck::cast_slice(&count_table),
242            usage: wgpu::BufferUsages::STORAGE,
243        });
244
245        let flat_table = case_table_flat();
246        let mc_case_table_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
247            label: Some("mc_case_table_buf"),
248            contents: bytemuck::cast_slice(&flat_table),
249            usage: wgpu::BufferUsages::STORAGE,
250        });
251
252        // ----------------------------------------------------------------
253        // Bind group layouts.
254        // ----------------------------------------------------------------
255
256        // Classify: 5 bindings (uniform + 2 read storage + 2 rw storage).
257        let classify_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
258            label: Some("mc_classify_bgl"),
259            entries: &[
260                bgl_uniform(0),
261                bgl_storage_ro(1),
262                bgl_storage_ro(2),
263                bgl_storage_rw(3),
264                bgl_storage_rw(4),
265            ],
266        });
267
268        // Prefix sum: 6 bindings (uniform + ro + 3 rw + wire_indirect_buf rw).
269        let prefix_sum_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
270            label: Some("mc_prefix_sum_bgl"),
271            entries: &[
272                bgl_uniform(0),
273                bgl_storage_ro(1),
274                bgl_storage_rw(2),
275                bgl_storage_rw(3),
276                bgl_storage_rw(4),
277                bgl_storage_rw(5), // wire_indirect_buf
278            ],
279        });
280
281        // Generate: 6 bindings (uniform + 3 ro + 2 rw [case_indices ro, vertex_buf rw]).
282        let generate_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
283            label: Some("mc_generate_bgl"),
284            entries: &[
285                bgl_uniform(0),
286                bgl_storage_ro(1),
287                bgl_storage_ro(2),
288                bgl_storage_ro(3),
289                bgl_storage_ro(4),
290                bgl_storage_rw(5),
291            ],
292        });
293
294        // Surface render: one per-draw material uniform.
295        let render_bgl = crate::resources::builders::uniform_bgl(
296            device,
297            "mc_render_bgl",
298            wgpu::ShaderStages::FRAGMENT,
299        );
300
301        // ----------------------------------------------------------------
302        // Compute pipelines.
303        // ----------------------------------------------------------------
304        let classify_shader = crate::resources::builders::wgsl_module(
305            device,
306            "mc_classify_shader",
307            crate::resources::builders::wgsl_source!("mc_classify"),
308        );
309        let classify_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
310            label: Some("mc_classify_layout"),
311            bind_group_layouts: &[&classify_bgl],
312            push_constant_ranges: &[],
313        });
314        let classify_pipeline = crate::resources::builders::compute_pipeline(
315            device,
316            "mc_classify_pipeline",
317            &classify_layout,
318            &classify_shader,
319            "main",
320        );
321
322        let prefix_sum_shader = crate::resources::builders::wgsl_module(
323            device,
324            "mc_prefix_sum_shader",
325            crate::resources::builders::wgsl_source!("mc_prefix_sum"),
326        );
327        let prefix_sum_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
328            label: Some("mc_prefix_sum_layout"),
329            bind_group_layouts: &[&prefix_sum_bgl],
330            push_constant_ranges: &[],
331        });
332        let prefix_sum_pipeline = crate::resources::builders::compute_pipeline(
333            device,
334            "mc_prefix_sum_pipeline",
335            &prefix_sum_layout,
336            &prefix_sum_shader,
337            "main",
338        );
339
340        let generate_shader = crate::resources::builders::wgsl_module(
341            device,
342            "mc_generate_shader",
343            crate::resources::builders::wgsl_source!("mc_generate"),
344        );
345        let generate_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
346            label: Some("mc_generate_layout"),
347            bind_group_layouts: &[&generate_bgl],
348            push_constant_ranges: &[],
349        });
350        let generate_pipeline = crate::resources::builders::compute_pipeline(
351            device,
352            "mc_generate_pipeline",
353            &generate_layout,
354            &generate_shader,
355            "main",
356        );
357
358        // ----------------------------------------------------------------
359        // Surface render pipeline.
360        // ----------------------------------------------------------------
361        let surface_shader = crate::resources::builders::wgsl_module(
362            device,
363            "mc_surface_shader",
364            crate::resources::builders::wgsl_source!("mc_surface"),
365        );
366        let surface_layout = crate::resources::builders::standard_scene_layout(
367            device,
368            "mc_surface_layout",
369            &self.camera_bind_group_layout,
370            &render_bgl,
371        );
372
373        let vertex_attrs = [
374            wgpu::VertexAttribute {
375                format: wgpu::VertexFormat::Float32x3,
376                offset: 0,
377                shader_location: 0,
378            },
379            wgpu::VertexAttribute {
380                format: wgpu::VertexFormat::Float32x3,
381                offset: 12,
382                shader_location: 1,
383            },
384        ];
385        let vertex_layout = wgpu::VertexBufferLayout {
386            array_stride: 24,
387            step_mode: wgpu::VertexStepMode::Vertex,
388            attributes: &vertex_attrs,
389        };
390
391        // ----------------------------------------------------------------
392        // Wireframe render pipeline.
393        // ----------------------------------------------------------------
394        let wireframe_render_bgl =
395            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
396                label: Some("mc_wireframe_render_bgl"),
397                entries: &[wgpu::BindGroupLayoutEntry {
398                    binding: 0,
399                    visibility: wgpu::ShaderStages::VERTEX,
400                    ty: wgpu::BindingType::Buffer {
401                        ty: wgpu::BufferBindingType::Storage { read_only: true },
402                        has_dynamic_offset: false,
403                        min_binding_size: None,
404                    },
405                    count: None,
406                }],
407            });
408
409        let wireframe_shader = crate::resources::builders::wgsl_module(
410            device,
411            "mc_wireframe_shader",
412            crate::resources::builders::wgsl_source!("mc_wireframe"),
413        );
414        let wireframe_layout = crate::resources::builders::standard_scene_layout(
415            device,
416            "mc_wireframe_layout",
417            &self.camera_bind_group_layout,
418            &wireframe_render_bgl,
419        );
420        // ----------------------------------------------------------------
421        // Commit all resources.
422        // ----------------------------------------------------------------
423        self.mc.case_count_buf = Some(mc_case_count_buf);
424        self.mc.case_table_buf = Some(mc_case_table_buf);
425        self.mc.classify_bgl = Some(classify_bgl);
426        self.mc.prefix_sum_bgl = Some(prefix_sum_bgl);
427        self.mc.generate_bgl = Some(generate_bgl);
428        self.mc.render_bgl = Some(render_bgl);
429        self.mc.classify_pipeline = Some(classify_pipeline);
430        self.mc.prefix_sum_pipeline = Some(prefix_sum_pipeline);
431        self.mc.generate_pipeline = Some(generate_pipeline);
432        self.mc.surface_pipeline = Some(crate::resources::builders::build_dual_pipeline(
433            device,
434            &crate::resources::builders::DualPipelineDesc {
435                label: "mc_surface_pipeline",
436                layout: &surface_layout,
437                shader: &surface_shader,
438                vertex_entry: "vs_main",
439                fragment_entry: "fs_main",
440                vertex_buffers: &[vertex_layout.clone()],
441                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
442                topology: wgpu::PrimitiveTopology::TriangleList,
443                cull_mode: None,
444                depth_write: true,
445                depth_compare: wgpu::CompareFunction::LessEqual,
446                sample_count: 1,
447                ldr_format: self.target_format,
448            },
449        ));
450        self.mc.wireframe_render_bgl = Some(wireframe_render_bgl);
451        self.mc.wireframe_pipeline = Some(crate::resources::builders::build_dual_pipeline(
452            device,
453            &crate::resources::builders::DualPipelineDesc {
454                label: "mc_wireframe_pipeline",
455                layout: &wireframe_layout,
456                shader: &wireframe_shader,
457                vertex_entry: "vs_main",
458                fragment_entry: "fs_main",
459                vertex_buffers: &[], // positions read from storage buffer
460                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
461                topology: wgpu::PrimitiveTopology::LineList,
462                cull_mode: None,
463                depth_write: true,
464                depth_compare: wgpu::CompareFunction::LessEqual,
465                sample_count: 1,
466                ldr_format: self.target_format,
467            },
468        ));
469    }
470
471    /// Upload a [`VolumeData`] to GPU, pre-allocating all intermediate and output
472    /// buffers for GPU marching cubes.
473    ///
474    /// The returned [`McVolumeId`] is stable until [`free_mc_volume`] is called.
475    ///
476    /// Returns `Err(ViewportError::McBufferTooLarge)` if any required buffer exceeds
477    /// the device's `max_buffer_size`; the caller should fall back to CPU isosurface
478    /// extraction.
479    pub fn upload_volume_for_mc(
480        &mut self,
481        device: &wgpu::Device,
482        queue: &wgpu::Queue,
483        vol: &VolumeData,
484    ) -> crate::ViewportResult<McVolumeId> {
485        let gpu_data = build_mc_volume_gpu_data(device, queue, vol)?;
486        Ok(self.insert_mc_volume_gpu_data(gpu_data))
487    }
488
489    /// Main-thread half of an async marching-cubes volume upload: insert
490    /// pre-built GPU data into the store and return its handle. Reuses a freed
491    /// slot when one is available, carrying that slot's current generation so a
492    /// stale handle to the previous occupant no longer resolves.
493    pub(crate) fn insert_mc_volume_gpu_data(
494        &mut self,
495        mut gpu_data: McVolumeGpuData,
496    ) -> McVolumeId {
497        if let Some(free_idx) = self.mc.volumes.iter().position(|v| !v.alive) {
498            gpu_data.generation = self.mc.volumes[free_idx].generation;
499            self.mc.volumes[free_idx] = gpu_data;
500            McVolumeId::new(free_idx as u32, self.mc.volumes[free_idx].generation)
501        } else {
502            let idx = self.mc.volumes.len();
503            self.mc.volumes.push(gpu_data);
504            McVolumeId::new(idx as u32, 0)
505        }
506    }
507
508    /// Look up a live volume by handle, validating the generation. Returns
509    /// `None` for a stale handle, a freed slot, or an out-of-range index.
510    pub(crate) fn mc_volume(&self, id: McVolumeId) -> Option<&McVolumeGpuData> {
511        let vol = self.mc.volumes.get(id.index as usize)?;
512        if vol.generation != id.generation || !vol.alive {
513            return None;
514        }
515        Some(vol)
516    }
517}
518
519/// CPU + GPU-buffer work for an MC volume upload, factored out so the same
520/// code can run on a worker thread for the async path.
521pub(crate) fn build_mc_volume_gpu_data(
522    device: &wgpu::Device,
523    queue: &wgpu::Queue,
524    vol: &VolumeData,
525) -> crate::ViewportResult<McVolumeGpuData> {
526    {
527        let [nx, ny, nz] = vol.dims;
528        // The vertex buffer is bound as both STORAGE (compute) and VERTEX (render).
529        // The binding limit for compute shaders is max_storage_buffer_binding_size, which
530        // is often half of max_buffer_size (e.g. 128 MiB vs 256 MiB). Use the smaller of
531        // the two so slab sizing respects both constraints.
532        let max_binding = device.limits().max_storage_buffer_binding_size as u64;
533        let max_buf = device.limits().max_buffer_size;
534        let max_limit = max_binding.min(max_buf);
535
536        // Worst-case vertex buffer bytes per Z-cell-layer:
537        // (nx-1)*(ny-1) cells x 5 triangles x 3 vertices x 24 bytes = cells_xy x 360.
538        // Compute how many Z-cell layers fit within the effective limit.
539        let cells_xy = (nx - 1) as u64 * (ny - 1) as u64;
540        let max_cells_per_slab = max_limit / (15 * 24);
541        let z_cells_per_slab = if cells_xy > 0 {
542            (max_cells_per_slab / cells_xy).min((nz - 1) as u64) as u32
543        } else {
544            nz - 1
545        };
546        if z_cells_per_slab == 0 {
547            // Even a single Z-layer of cells exceeds the effective binding limit.
548            return Err(crate::ViewportError::McBufferTooLarge {
549                buffer: "vertex_buf",
550                needed: cells_xy * 15 * 24,
551                limit: max_limit,
552            });
553        }
554
555        let nz_cells_total = nz - 1;
556        let slab_count = nz_cells_total.div_ceil(z_cells_per_slab);
557        let nodes_per_z = (nx * ny) as usize;
558
559        let mut slabs = Vec::with_capacity(slab_count as usize);
560
561        for s in 0..slab_count {
562            let z_cell_start = s * z_cells_per_slab;
563            let z_cell_end = (z_cell_start + z_cells_per_slab).min(nz_cells_total);
564            let slab_z_cells = z_cell_end - z_cell_start; // cell layers in this slab
565            let slab_nz = slab_z_cells + 1; // scalar layers in this slab
566
567            // slab_cell_count is bounded by max_cells_per_slab, which fits in u32
568            // at any realistic max_buffer_size value.
569            let slab_cell_count = (cells_xy * slab_z_cells as u64) as u32;
570            let slab_block_count = slab_cell_count.div_ceil(256);
571            let slab_cell_bytes = (slab_cell_count as u64) * 4;
572            let slab_block_bytes = (slab_block_count as u64) * 4;
573            // At most 15 vertices per cell (5 triangles x 3 vertices) x 24 bytes each.
574            let slab_vertex_bytes = (slab_cell_count as u64) * 15 * 24;
575
576            // Scalar data is x-fastest: index = x + y*nx + z*nx*ny.
577            // A Z-slab covering scalar layers z_cell_start..z_cell_start+slab_nz is
578            // a contiguous slice, no copying required.
579            let scalar_start = z_cell_start as usize * nodes_per_z;
580            let scalar_end = (z_cell_start + slab_nz) as usize * nodes_per_z;
581            let slab_origin_z = vol.origin[2] + z_cell_start as f32 * vol.spacing[2];
582
583            let scalar_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
584                label: Some("mc_scalar_buf"),
585                contents: bytemuck::cast_slice(&vol.data[scalar_start..scalar_end]),
586                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
587            });
588            let counts_buf = device.create_buffer(&wgpu::BufferDescriptor {
589                label: Some("mc_counts_buf"),
590                size: slab_cell_bytes,
591                usage: wgpu::BufferUsages::STORAGE,
592                mapped_at_creation: false,
593            });
594            let case_idx_buf = device.create_buffer(&wgpu::BufferDescriptor {
595                label: Some("mc_case_idx_buf"),
596                size: slab_cell_bytes,
597                usage: wgpu::BufferUsages::STORAGE,
598                mapped_at_creation: false,
599            });
600            let offsets_buf = device.create_buffer(&wgpu::BufferDescriptor {
601                label: Some("mc_offsets_buf"),
602                size: slab_cell_bytes,
603                usage: wgpu::BufferUsages::STORAGE,
604                mapped_at_creation: false,
605            });
606            let block_sums_buf = device.create_buffer(&wgpu::BufferDescriptor {
607                label: Some("mc_block_sums_buf"),
608                size: slab_block_bytes,
609                usage: wgpu::BufferUsages::STORAGE,
610                mapped_at_creation: false,
611            });
612            let vertex_buf = device.create_buffer(&wgpu::BufferDescriptor {
613                label: Some("mc_vertex_buf"),
614                size: slab_vertex_bytes,
615                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
616                mapped_at_creation: false,
617            });
618            let initial_indirect = bytemuck::cast_slice(&[0u32, 1u32, 0u32, 0u32]);
619            let indirect_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
620                label: Some("mc_indirect_buf"),
621                // Initial: 0 vertices, 1 instance, 0 first_vertex, 0 first_instance.
622                contents: initial_indirect,
623                usage: wgpu::BufferUsages::STORAGE
624                    | wgpu::BufferUsages::INDIRECT
625                    | wgpu::BufferUsages::COPY_DST,
626            });
627            let wire_indirect_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
628                label: Some("mc_wire_indirect_buf"),
629                contents: initial_indirect,
630                usage: wgpu::BufferUsages::STORAGE
631                    | wgpu::BufferUsages::INDIRECT
632                    | wgpu::BufferUsages::COPY_DST,
633            });
634
635            slabs.push(McSlabGpuData {
636                scalar_buf,
637                counts_buf,
638                case_idx_buf,
639                offsets_buf,
640                block_sums_buf,
641                vertex_buf,
642                indirect_buf,
643                wire_indirect_buf,
644                dims: [nx, ny, slab_nz],
645                origin: [vol.origin[0], vol.origin[1], slab_origin_z],
646                spacing: vol.spacing,
647                cell_count: slab_cell_count,
648                block_count: slab_block_count,
649            });
650        }
651
652        let _ = queue; // retained for potential future use (e.g. scalar updates)
653
654        Ok(McVolumeGpuData {
655            slabs,
656            alive: true,
657            generation: 0,
658        })
659    }
660}
661
662impl DeviceResources {
663    /// Start an asynchronous marching-cubes-ready volume upload.
664    ///
665    /// Returns a [`JobId`](crate::resources::JobId) immediately. Slab
666    /// sizing, scalar buffer allocation, and intermediate / output buffer
667    /// allocation run on a worker thread on cloned `Device` and `Queue`
668    /// handles. The apply step inserts the resulting GPU buffers into the
669    /// MC volume store; once `UploadStatus::Ready`, call
670    /// [`upload_result_volume_mc`](Self::upload_result_volume_mc) to take
671    /// the [`McVolumeId`].
672    ///
673    /// Ownership of `vol` transfers into the worker.
674    ///
675    /// # Errors
676    ///
677    /// The worker surfaces
678    /// [`ViewportError::McBufferTooLarge`](crate::error::ViewportError::McBufferTooLarge)
679    /// through [`UploadStatus::Failed`] when the device's
680    /// `max_storage_buffer_binding_size` cannot fit a single Z-cell layer.
681    pub fn begin_upload_volume_for_mc(
682        &mut self,
683        device: &wgpu::Device,
684        queue: &wgpu::Queue,
685        vol: VolumeData,
686    ) -> crate::resources::JobId {
687        let slot = crate::resources::ResultSlot::<McVolumeId>::new();
688        let slot_for_apply = slot.clone();
689        let device_for_worker = device.clone();
690        let queue_for_worker = queue.clone();
691
692        let id = {
693            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
694            runner.submit_cpu(move |progress| {
695                progress.set(0.1);
696                let gpu_data =
697                    build_mc_volume_gpu_data(&device_for_worker, &queue_for_worker, &vol)?;
698                progress.set(0.95);
699                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
700                    Box::new(move |resources: &mut DeviceResources| {
701                        let id = resources.insert_mc_volume_gpu_data(gpu_data);
702                        slot_for_apply.set(id);
703                    }),
704                ))
705            })
706        };
707
708        self.job_results
709            .volume_mc
710            .lock()
711            .expect("volume mc result map poisoned")
712            .insert(id, slot);
713        id
714    }
715
716    /// Take the [`McVolumeId`] produced by a completed
717    /// [`begin_upload_volume_for_mc`](Self::begin_upload_volume_for_mc) job.
718    pub fn upload_result_volume_mc(
719        &mut self,
720        id: crate::resources::JobId,
721    ) -> crate::error::ViewportResult<McVolumeId> {
722        let mut map = self
723            .job_results
724            .volume_mc
725            .lock()
726            .expect("volume mc result map poisoned");
727        let slot = match map.get(&id) {
728            Some(s) => s.clone(),
729            None => {
730                return Err(crate::error::ViewportError::JobResultMissing {
731                    reason: "unknown id or wrong upload type",
732                });
733            }
734        };
735        match slot.take() {
736            Some(vid) => {
737                map.remove(&id);
738                Ok(vid)
739            }
740            None => Err(crate::error::ViewportError::JobNotReady),
741        }
742    }
743
744    /// Free a MC volume: drop its slab GPU buffers to reclaim the memory now,
745    /// mark the slot free, and bump its generation so a stale handle no longer
746    /// resolves. The emptied slot is reused by a later upload. Dropping the
747    /// buffers drops this volume out of [`resident_bytes`](Self::resident_bytes)
748    /// immediately (wgpu defers the real GPU free until in-flight commands that
749    /// reference the buffers complete).
750    pub fn free_mc_volume(&mut self, id: McVolumeId) {
751        if let Some(v) = self.mc.volumes.get_mut(id.index as usize) {
752            if v.generation == id.generation && v.alive {
753                v.slabs.clear();
754                v.alive = false;
755                v.generation = v.generation.wrapping_add(1);
756            }
757        }
758    }
759
760    /// Total resident GPU bytes across every live MC volume.
761    pub(crate) fn mc_volume_resident_bytes(&self) -> u64 {
762        self.mc
763            .volumes
764            .iter()
765            .filter(|v| v.alive)
766            .map(|v| v.gpu_bytes())
767            .sum()
768    }
769
770    /// Lazily create the MC surface outline mask pipeline.
771    ///
772    /// Layout is `[camera_bind_group_layout, outline_bind_group_layout]`. The vertex
773    /// buffer matches the MC output format: stride 24 (position f32x3 at offset 0,
774    /// normal f32x3 at offset 12). Uses the existing `outline_mask.wgsl` shader since
775    /// only position is needed. No-op if already created.
776    pub(crate) fn ensure_mc_outline_mask_pipeline(&mut self, device: &wgpu::Device) {
777        if self.mc.outline_mask_pipeline.is_some() {
778            return;
779        }
780
781        let shader = crate::resources::builders::wgsl_module(
782            device,
783            "mc_outline_mask_shader",
784            crate::resources::builders::wgsl_source!("outline_mask"),
785        );
786
787        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
788            label: Some("mc_outline_mask_pipeline_layout"),
789            bind_group_layouts: &[
790                &self.camera_bind_group_layout,
791                &self.outline.bind_group_layout,
792            ],
793            push_constant_ranges: &[],
794        });
795
796        let vert_attrs = [wgpu::VertexAttribute {
797            offset: 0,
798            shader_location: 0,
799            format: wgpu::VertexFormat::Float32x3,
800        }];
801        let vert_layout = wgpu::VertexBufferLayout {
802            array_stride: 24, // position (12 bytes) + normal (12 bytes)
803            step_mode: wgpu::VertexStepMode::Vertex,
804            attributes: &vert_attrs,
805        };
806
807        // LessEqual matches the main marching-cubes pipeline so the mask marks
808        // the surface's own front pixels instead of rejecting them at equal depth.
809        self.mc.outline_mask_pipeline =
810            Some(crate::resources::builders::build_outline_mask_pipeline(
811                device,
812                "mc_outline_mask_pipeline",
813                &layout,
814                &shader,
815                wgpu::TextureFormat::R8Unorm,
816                &[vert_layout],
817                None,
818                true,
819                wgpu::CompareFunction::LessEqual,
820            ));
821    }
822
823    /// Dispatch all three compute passes for every pending MC job.
824    ///
825    /// Returns the per-frame render data to be stored in `ViewportRenderer.mc_gpu_data`.
826    pub(crate) fn run_mc_jobs(
827        &self,
828        device: &wgpu::Device,
829        queue: &wgpu::Queue,
830        jobs: &[GpuMarchingCubesJob],
831    ) -> Vec<McFrameData> {
832        if jobs.is_empty() {
833            return Vec::new();
834        }
835
836        let classify_pipeline = self.mc.classify_pipeline.as_ref().expect("mc pipelines");
837        let prefix_sum_pipeline = self.mc.prefix_sum_pipeline.as_ref().unwrap();
838        let generate_pipeline = self.mc.generate_pipeline.as_ref().unwrap();
839        let classify_bgl = self.mc.classify_bgl.as_ref().unwrap();
840        let prefix_sum_bgl = self.mc.prefix_sum_bgl.as_ref().unwrap();
841        let generate_bgl = self.mc.generate_bgl.as_ref().unwrap();
842        let render_bgl = self.mc.render_bgl.as_ref().unwrap();
843        let case_count_buf = self.mc.case_count_buf.as_ref().unwrap();
844        let case_table_buf = self.mc.case_table_buf.as_ref().unwrap();
845
846        let mut frame_data = Vec::with_capacity(jobs.len());
847        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
848            label: Some("mc_compute_encoder"),
849        });
850
851        for job in jobs {
852            let Some(vol) = self.mc_volume(job.volume_id) else {
853                continue;
854            };
855
856            // ----------------------------------------------------------
857            // Per-job surface material (one bind group shared by all slabs).
858            // ----------------------------------------------------------
859            let mat_raw = McSurfaceRaw {
860                base_colour: job.material.base_colour,
861                roughness: job.material.roughness,
862                unlit: job.settings.unlit as u32,
863                opacity: job.settings.opacity,
864                ambient: job.material.ambient,
865                _pad: 0,
866            };
867            let mat_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
868                label: Some("mc_surface_mat"),
869                contents: bytemuck::bytes_of(&mat_raw),
870                usage: wgpu::BufferUsages::UNIFORM,
871            });
872            let render_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
873                label: Some("mc_render_bg"),
874                layout: render_bgl,
875                entries: &[wgpu::BindGroupEntry {
876                    binding: 0,
877                    resource: mat_buf.as_entire_binding(),
878                }],
879            });
880
881            // Run all three compute passes for each slab independently.
882            for slab in &vol.slabs {
883                let cc = slab.cell_count;
884                let bc = slab.block_count;
885
886                // ----------------------------------------------------------
887                // Per-slab classify uniform.
888                // ----------------------------------------------------------
889                let classify_params = ClassifyParams {
890                    nx: slab.dims[0],
891                    ny: slab.dims[1],
892                    nz: slab.dims[2],
893                    isovalue: job.isovalue,
894                };
895                let classify_uniform =
896                    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
897                        label: Some("mc_classify_uniform"),
898                        contents: bytemuck::bytes_of(&classify_params),
899                        usage: wgpu::BufferUsages::UNIFORM,
900                    });
901
902                let classify_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
903                    label: Some("mc_classify_bg"),
904                    layout: classify_bgl,
905                    entries: &[
906                        wgpu::BindGroupEntry {
907                            binding: 0,
908                            resource: classify_uniform.as_entire_binding(),
909                        },
910                        wgpu::BindGroupEntry {
911                            binding: 1,
912                            resource: slab.scalar_buf.as_entire_binding(),
913                        },
914                        wgpu::BindGroupEntry {
915                            binding: 2,
916                            resource: case_count_buf.as_entire_binding(),
917                        },
918                        wgpu::BindGroupEntry {
919                            binding: 3,
920                            resource: slab.counts_buf.as_entire_binding(),
921                        },
922                        wgpu::BindGroupEntry {
923                            binding: 4,
924                            resource: slab.case_idx_buf.as_entire_binding(),
925                        },
926                    ],
927                });
928
929                // ----------------------------------------------------------
930                // Per-slab prefix-sum uniforms (one per level).
931                // ----------------------------------------------------------
932                let ps_uniforms: [wgpu::Buffer; 3] = std::array::from_fn(|level| {
933                    let params = PrefixSumParams {
934                        cell_count: cc,
935                        block_count: bc,
936                        level: level as u32,
937                        _pad: 0,
938                    };
939                    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
940                        label: Some("mc_ps_uniform"),
941                        contents: bytemuck::bytes_of(&params),
942                        usage: wgpu::BufferUsages::UNIFORM,
943                    })
944                });
945
946                let ps_bgs: [wgpu::BindGroup; 3] = std::array::from_fn(|level| {
947                    device.create_bind_group(&wgpu::BindGroupDescriptor {
948                        label: Some("mc_ps_bg"),
949                        layout: prefix_sum_bgl,
950                        entries: &[
951                            wgpu::BindGroupEntry {
952                                binding: 0,
953                                resource: ps_uniforms[level].as_entire_binding(),
954                            },
955                            wgpu::BindGroupEntry {
956                                binding: 1,
957                                resource: slab.counts_buf.as_entire_binding(),
958                            },
959                            wgpu::BindGroupEntry {
960                                binding: 2,
961                                resource: slab.offsets_buf.as_entire_binding(),
962                            },
963                            wgpu::BindGroupEntry {
964                                binding: 3,
965                                resource: slab.block_sums_buf.as_entire_binding(),
966                            },
967                            wgpu::BindGroupEntry {
968                                binding: 4,
969                                resource: slab.indirect_buf.as_entire_binding(),
970                            },
971                            wgpu::BindGroupEntry {
972                                binding: 5,
973                                resource: slab.wire_indirect_buf.as_entire_binding(),
974                            },
975                        ],
976                    })
977                });
978
979                // ----------------------------------------------------------
980                // Per-slab generate uniform (origin_z shifted by slab offset).
981                // ----------------------------------------------------------
982                let generate_params = GenerateParams {
983                    nx: slab.dims[0],
984                    ny: slab.dims[1],
985                    nz: slab.dims[2],
986                    isovalue: job.isovalue,
987                    origin_x: slab.origin[0],
988                    origin_y: slab.origin[1],
989                    origin_z: slab.origin[2],
990                    _pad0: 0.0,
991                    spacing_x: slab.spacing[0],
992                    spacing_y: slab.spacing[1],
993                    spacing_z: slab.spacing[2],
994                    _pad1: 0.0,
995                };
996                let generate_uniform =
997                    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
998                        label: Some("mc_generate_uniform"),
999                        contents: bytemuck::bytes_of(&generate_params),
1000                        usage: wgpu::BufferUsages::UNIFORM,
1001                    });
1002
1003                let generate_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1004                    label: Some("mc_generate_bg"),
1005                    layout: generate_bgl,
1006                    entries: &[
1007                        wgpu::BindGroupEntry {
1008                            binding: 0,
1009                            resource: generate_uniform.as_entire_binding(),
1010                        },
1011                        wgpu::BindGroupEntry {
1012                            binding: 1,
1013                            resource: slab.scalar_buf.as_entire_binding(),
1014                        },
1015                        wgpu::BindGroupEntry {
1016                            binding: 2,
1017                            resource: case_table_buf.as_entire_binding(),
1018                        },
1019                        wgpu::BindGroupEntry {
1020                            binding: 3,
1021                            resource: slab.offsets_buf.as_entire_binding(),
1022                        },
1023                        wgpu::BindGroupEntry {
1024                            binding: 4,
1025                            resource: slab.case_idx_buf.as_entire_binding(),
1026                        },
1027                        wgpu::BindGroupEntry {
1028                            binding: 5,
1029                            resource: slab.vertex_buf.as_entire_binding(),
1030                        },
1031                    ],
1032                });
1033
1034                // ----------------------------------------------------------
1035                // Pass 1: classify.
1036                // ----------------------------------------------------------
1037                {
1038                    let mut cp = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1039                        label: Some("mc_classify_pass"),
1040                        timestamp_writes: None,
1041                    });
1042                    cp.set_pipeline(classify_pipeline);
1043                    cp.set_bind_group(0, &classify_bg, &[]);
1044                    cp.dispatch_workgroups(cc.div_ceil(256), 1, 1);
1045                }
1046
1047                // ----------------------------------------------------------
1048                // Pass 2a: prefix sum level 0.
1049                // ----------------------------------------------------------
1050                {
1051                    let mut cp = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1052                        label: Some("mc_ps_level0_pass"),
1053                        timestamp_writes: None,
1054                    });
1055                    cp.set_pipeline(prefix_sum_pipeline);
1056                    cp.set_bind_group(0, &ps_bgs[0], &[]);
1057                    cp.dispatch_workgroups(bc, 1, 1);
1058                }
1059
1060                // ----------------------------------------------------------
1061                // Pass 2b: prefix sum level 1 (single workgroup, sequential).
1062                // ----------------------------------------------------------
1063                {
1064                    let mut cp = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1065                        label: Some("mc_ps_level1_pass"),
1066                        timestamp_writes: None,
1067                    });
1068                    cp.set_pipeline(prefix_sum_pipeline);
1069                    cp.set_bind_group(0, &ps_bgs[1], &[]);
1070                    cp.dispatch_workgroups(1, 1, 1);
1071                }
1072
1073                // ----------------------------------------------------------
1074                // Pass 2c: prefix sum level 2 (propagate block offsets).
1075                // ----------------------------------------------------------
1076                {
1077                    let mut cp = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1078                        label: Some("mc_ps_level2_pass"),
1079                        timestamp_writes: None,
1080                    });
1081                    cp.set_pipeline(prefix_sum_pipeline);
1082                    cp.set_bind_group(0, &ps_bgs[2], &[]);
1083                    cp.dispatch_workgroups(bc, 1, 1);
1084                }
1085
1086                // ----------------------------------------------------------
1087                // Pass 3: generate vertices.
1088                // ----------------------------------------------------------
1089                {
1090                    let mut cp = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1091                        label: Some("mc_generate_pass"),
1092                        timestamp_writes: None,
1093                    });
1094                    cp.set_pipeline(generate_pipeline);
1095                    cp.set_bind_group(0, &generate_bg, &[]);
1096                    cp.dispatch_workgroups(cc.div_ceil(256), 1, 1);
1097                }
1098            }
1099
1100            let wire_slab_bgs: Vec<wgpu::BindGroup> =
1101                if let Some(ref wire_bgl) = self.mc.wireframe_render_bgl {
1102                    vol.slabs
1103                        .iter()
1104                        .map(|slab| {
1105                            device.create_bind_group(&wgpu::BindGroupDescriptor {
1106                                label: Some("mc_wire_slab_bg"),
1107                                layout: wire_bgl,
1108                                entries: &[wgpu::BindGroupEntry {
1109                                    binding: 0,
1110                                    resource: slab.vertex_buf.as_entire_binding(),
1111                                }],
1112                            })
1113                        })
1114                        .collect()
1115                } else {
1116                    Vec::new()
1117                };
1118
1119            frame_data.push(McFrameData {
1120                volume_idx: job.volume_id.index(),
1121                render_bg,
1122                wireframe: job.settings.wireframe,
1123                wire_slab_bgs,
1124            });
1125        }
1126
1127        queue.submit(std::iter::once(encoder.finish()));
1128        frame_data
1129    }
1130}
1131
1132// ---------------------------------------------------------------------------
1133// Bind group layout entry helpers
1134// ---------------------------------------------------------------------------
1135
1136fn bgl_uniform(binding: u32) -> wgpu::BindGroupLayoutEntry {
1137    wgpu::BindGroupLayoutEntry {
1138        binding,
1139        visibility: wgpu::ShaderStages::COMPUTE,
1140        ty: wgpu::BindingType::Buffer {
1141            ty: wgpu::BufferBindingType::Uniform,
1142            has_dynamic_offset: false,
1143            min_binding_size: None,
1144        },
1145        count: None,
1146    }
1147}
1148
1149fn bgl_storage_ro(binding: u32) -> wgpu::BindGroupLayoutEntry {
1150    wgpu::BindGroupLayoutEntry {
1151        binding,
1152        visibility: wgpu::ShaderStages::COMPUTE,
1153        ty: wgpu::BindingType::Buffer {
1154            ty: wgpu::BufferBindingType::Storage { read_only: true },
1155            has_dynamic_offset: false,
1156            min_binding_size: None,
1157        },
1158        count: None,
1159    }
1160}
1161
1162fn bgl_storage_rw(binding: u32) -> wgpu::BindGroupLayoutEntry {
1163    wgpu::BindGroupLayoutEntry {
1164        binding,
1165        visibility: wgpu::ShaderStages::COMPUTE,
1166        ty: wgpu::BindingType::Buffer {
1167            ty: wgpu::BufferBindingType::Storage { read_only: false },
1168            has_dynamic_offset: false,
1169            min_binding_size: None,
1170        },
1171        count: None,
1172    }
1173}
1174
1175#[cfg(test)]
1176mod residency_tests {
1177    use crate::DeviceResources;
1178    use crate::geometry::marching_cubes::VolumeData;
1179
1180    fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
1181        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
1182        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1183            power_preference: wgpu::PowerPreference::LowPower,
1184            compatible_surface: None,
1185            force_fallback_adapter: false,
1186        }))
1187        .ok()?;
1188        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
1189    }
1190
1191    fn sample_volume() -> VolumeData {
1192        let dims = [4u32, 4, 4];
1193        let data = (0..(dims[0] * dims[1] * dims[2]))
1194            .map(|i| (i % 2) as f32)
1195            .collect();
1196        VolumeData {
1197            data,
1198            dims,
1199            origin: [0.0, 0.0, 0.0],
1200            spacing: [1.0, 1.0, 1.0],
1201        }
1202    }
1203
1204    #[test]
1205    fn stale_mc_volume_handle_does_not_alias_after_slot_reuse() {
1206        let Some((device, queue)) = try_make_device() else {
1207            eprintln!("skipping: no wgpu adapter available");
1208            return;
1209        };
1210        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1211
1212        let id1 = resources
1213            .upload_volume_for_mc(&device, &queue, &sample_volume())
1214            .unwrap();
1215        assert!(resources.mc_volume(id1).is_some());
1216        resources.free_mc_volume(id1);
1217        assert!(
1218            resources.mc_volume(id1).is_none(),
1219            "a removed handle must not resolve"
1220        );
1221
1222        // The next upload reuses the freed slot at a new generation.
1223        let id2 = resources
1224            .upload_volume_for_mc(&device, &queue, &sample_volume())
1225            .unwrap();
1226        assert_eq!(id1.index(), id2.index(), "the freed slot should be reused");
1227        assert_ne!(id1, id2, "the reused slot must carry a new generation");
1228        assert!(resources.mc_volume(id2).is_some());
1229        assert!(
1230            resources.mc_volume(id1).is_none(),
1231            "the stale handle must not alias the volume now occupying its slot"
1232        );
1233    }
1234
1235    #[test]
1236    fn free_mc_volume_reclaims_resident_bytes() {
1237        let Some((device, queue)) = try_make_device() else {
1238            eprintln!("skipping: no wgpu adapter available");
1239            return;
1240        };
1241        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1242
1243        let start = resources.resident_bytes().mc_volume_bytes;
1244        let id = resources
1245            .upload_volume_for_mc(&device, &queue, &sample_volume())
1246            .unwrap();
1247        let after_upload = resources.resident_bytes().mc_volume_bytes;
1248        assert!(
1249            after_upload > start,
1250            "uploading a volume must increase resident volume bytes"
1251        );
1252
1253        resources.free_mc_volume(id);
1254        assert_eq!(
1255            resources.resident_bytes().mc_volume_bytes,
1256            start,
1257            "freeing a volume must drop its slab buffers out of the resident total"
1258        );
1259    }
1260}