Skip to main content

viewport_lib/resources/scivis/
gaussian_splat.rs

1use super::*;
2use crate::renderer::ShDegree;
3
4/// Gaussian splat render pipeline, the radix-sort compute passes, their bind
5/// group layouts, and the slotted store of uploaded splat sets. All lazily
6/// built; `store` holds the uploaded sets keyed by id.
7pub(crate) struct GaussianSplatResources {
8    /// Gaussian splat render pipeline. None until first splat set is submitted.
9    pub(crate) pipeline: Option<DualPipeline>,
10    /// Bind group layout for group 1 of the render pipeline.
11    pub(crate) bgl: Option<wgpu::BindGroupLayout>,
12    /// Compute pipeline for per-splat view-space depth.
13    pub(crate) depth_pipeline: Option<wgpu::ComputePipeline>,
14    /// Compute pipeline that clears the sort histogram.
15    pub(crate) sort_clear_pipeline: Option<wgpu::ComputePipeline>,
16    /// Radix-sort histogram pass.
17    pub(crate) sort_histogram_pipeline: Option<wgpu::ComputePipeline>,
18    /// Radix-sort prefix-sum pass.
19    pub(crate) sort_prefix_pipeline: Option<wgpu::ComputePipeline>,
20    /// Radix-sort scatter pass.
21    pub(crate) sort_scatter_pipeline: Option<wgpu::ComputePipeline>,
22    /// Compute pipeline that initialises sort index values.
23    pub(crate) sort_init_pipeline: Option<wgpu::ComputePipeline>,
24    /// Bind group layout for the depth compute pass.
25    pub(crate) depth_bgl: Option<wgpu::BindGroupLayout>,
26    /// Bind group layout for the sort compute passes.
27    pub(crate) sort_bgl: Option<wgpu::BindGroupLayout>,
28}
29
30impl Default for GaussianSplatResources {
31    fn default() -> Self {
32        Self {
33            pipeline: None,
34            bgl: None,
35            depth_pipeline: None,
36            sort_clear_pipeline: None,
37            sort_histogram_pipeline: None,
38            sort_prefix_pipeline: None,
39            sort_scatter_pipeline: None,
40            sort_init_pipeline: None,
41            depth_bgl: None,
42            sort_bgl: None,
43        }
44    }
45}
46
47/// Check that a splat set is non-empty and its per-attribute vectors agree in
48/// length. Shared by the sync, async, and replace upload paths.
49fn validate_gaussian_splat_data(
50    data: &crate::renderer::GaussianSplatData,
51) -> crate::error::ViewportResult<()> {
52    if data.positions.is_empty() {
53        return Err(crate::error::ViewportError::InvalidGaussianSplatData {
54            reason: "empty splat list",
55        });
56    }
57    let n = data.positions.len();
58    if data.scales.len() != n || data.rotations.len() != n || data.opacities.len() != n {
59        return Err(crate::error::ViewportError::InvalidGaussianSplatData {
60            reason: "mismatched buffer lengths",
61        });
62    }
63    Ok(())
64}
65
66/// Build the persistent GPU buffers for a splat set and assemble the
67/// `GaussianSplatGpuSet`. Assumes `data` already passed
68/// [`validate_gaussian_splat_data`]. Shared by the sync `upload_gaussian_splat`,
69/// the async worker, and `replace_gaussian_splat` so all three produce identical
70/// resources.
71fn build_gaussian_splat_set(
72    device: &wgpu::Device,
73    queue: &wgpu::Queue,
74    data: &crate::renderer::GaussianSplatData,
75) -> GaussianSplatGpuSet {
76    let count = data.positions.len() as u32;
77
78    // Pad positions/scales/rotations to vec4 (w=1 / w=0 / raw).
79    let pos_data: Vec<[f32; 4]> = data
80        .positions
81        .iter()
82        .map(|p| [p[0], p[1], p[2], 1.0])
83        .collect();
84    let scale_data: Vec<[f32; 4]> = data
85        .scales
86        .iter()
87        .map(|s| [s[0], s[1], s[2], 0.0])
88        .collect();
89    let rotation_data: Vec<[f32; 4]> = data
90        .rotations
91        .iter()
92        .map(|r| [r[0], r[1], r[2], r[3]])
93        .collect();
94
95    let buf_size_pos = (pos_data.len() * std::mem::size_of::<[f32; 4]>()).max(16) as u64;
96    let buf_size_scale = (scale_data.len() * std::mem::size_of::<[f32; 4]>()).max(16) as u64;
97    let buf_size_rot = (rotation_data.len() * std::mem::size_of::<[f32; 4]>()).max(16) as u64;
98    let buf_size_opa = (data.opacities.len() * 4).max(4) as u64;
99    let buf_size_sh = (data.sh_coefficients.len() * 4).max(4) as u64;
100
101    let position_buf = device.create_buffer(&wgpu::BufferDescriptor {
102        label: Some("splat_position_buf"),
103        size: buf_size_pos,
104        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
105        mapped_at_creation: false,
106    });
107    queue.write_buffer(&position_buf, 0, bytemuck::cast_slice(&pos_data));
108
109    let scale_buf = device.create_buffer(&wgpu::BufferDescriptor {
110        label: Some("splat_scale_buf"),
111        size: buf_size_scale,
112        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
113        mapped_at_creation: false,
114    });
115    queue.write_buffer(&scale_buf, 0, bytemuck::cast_slice(&scale_data));
116
117    let rotation_buf = device.create_buffer(&wgpu::BufferDescriptor {
118        label: Some("splat_rotation_buf"),
119        size: buf_size_rot,
120        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
121        mapped_at_creation: false,
122    });
123    queue.write_buffer(&rotation_buf, 0, bytemuck::cast_slice(&rotation_data));
124
125    let opacity_buf = device.create_buffer(&wgpu::BufferDescriptor {
126        label: Some("splat_opacity_buf"),
127        size: buf_size_opa,
128        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
129        mapped_at_creation: false,
130    });
131    queue.write_buffer(&opacity_buf, 0, bytemuck::cast_slice(&data.opacities));
132
133    let sh_buf = device.create_buffer(&wgpu::BufferDescriptor {
134        label: Some("splat_sh_buf"),
135        size: buf_size_sh,
136        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
137        mapped_at_creation: false,
138    });
139    if !data.sh_coefficients.is_empty() {
140        queue.write_buffer(&sh_buf, 0, bytemuck::cast_slice(&data.sh_coefficients));
141    }
142
143    GaussianSplatGpuSet {
144        position_buf,
145        scale_buf,
146        rotation_buf,
147        opacity_buf,
148        sh_buf,
149        sh_degree: data.sh_degree,
150        count,
151        viewport_sort: Vec::new(),
152        cpu_positions: data.positions.clone(),
153        cpu_scales: data.scales.clone(),
154    }
155}
156
157// Per-viewport SplatUniform layout (must match gaussian_splat.wgsl).
158#[repr(C)]
159#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
160struct SplatUniform {
161    model: [[f32; 4]; 4],
162    viewport_w: f32,
163    viewport_h: f32,
164    sh_degree: u32,
165    count: u32,
166}
167
168// Depth compute uniform (must match gaussian_splat_sort.wgsl DepthUniform).
169#[repr(C)]
170#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
171struct DepthUniform {
172    model: [[f32; 4]; 4],
173    eye: [f32; 3],
174    count: u32,
175}
176
177// Sort pass uniform (must match gaussian_splat_sort.wgsl SortUniform).
178#[repr(C)]
179#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
180struct SortUniform {
181    shift: u32,
182    count: u32,
183    pass_num: u32,
184    _pad: u32,
185}
186
187impl DeviceResources {
188    /// Lazily create all Gaussian splat render and compute pipelines.
189    ///
190    /// No-op after first call. Called from prepare when gaussian_splats is non-empty.
191    pub(crate) fn ensure_gaussian_splat_pipelines(&mut self, device: &wgpu::Device) {
192        if self.gaussian_splat.bgl.is_some() {
193            return;
194        }
195
196        // ---------------------------------------------------------------
197        // Render pipeline
198        // ---------------------------------------------------------------
199
200        // Group 1 BGL: SplatUniform (b0), sorted_indices (b1), positions (b2),
201        //              scales (b3), rotations (b4), opacities (b5), sh_coefficients (b6).
202        let splat_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
203            label: Some("gaussian_splat_bgl"),
204            entries: &[
205                wgpu::BindGroupLayoutEntry {
206                    binding: 0,
207                    visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
208                    ty: wgpu::BindingType::Buffer {
209                        ty: wgpu::BufferBindingType::Uniform,
210                        has_dynamic_offset: false,
211                        min_binding_size: None,
212                    },
213                    count: None,
214                },
215                wgpu::BindGroupLayoutEntry {
216                    binding: 1,
217                    visibility: wgpu::ShaderStages::VERTEX,
218                    ty: wgpu::BindingType::Buffer {
219                        ty: wgpu::BufferBindingType::Storage { read_only: true },
220                        has_dynamic_offset: false,
221                        min_binding_size: None,
222                    },
223                    count: None,
224                },
225                wgpu::BindGroupLayoutEntry {
226                    binding: 2,
227                    visibility: wgpu::ShaderStages::VERTEX,
228                    ty: wgpu::BindingType::Buffer {
229                        ty: wgpu::BufferBindingType::Storage { read_only: true },
230                        has_dynamic_offset: false,
231                        min_binding_size: None,
232                    },
233                    count: None,
234                },
235                wgpu::BindGroupLayoutEntry {
236                    binding: 3,
237                    visibility: wgpu::ShaderStages::VERTEX,
238                    ty: wgpu::BindingType::Buffer {
239                        ty: wgpu::BufferBindingType::Storage { read_only: true },
240                        has_dynamic_offset: false,
241                        min_binding_size: None,
242                    },
243                    count: None,
244                },
245                wgpu::BindGroupLayoutEntry {
246                    binding: 4,
247                    visibility: wgpu::ShaderStages::VERTEX,
248                    ty: wgpu::BindingType::Buffer {
249                        ty: wgpu::BufferBindingType::Storage { read_only: true },
250                        has_dynamic_offset: false,
251                        min_binding_size: None,
252                    },
253                    count: None,
254                },
255                wgpu::BindGroupLayoutEntry {
256                    binding: 5,
257                    visibility: wgpu::ShaderStages::VERTEX,
258                    ty: wgpu::BindingType::Buffer {
259                        ty: wgpu::BufferBindingType::Storage { read_only: true },
260                        has_dynamic_offset: false,
261                        min_binding_size: None,
262                    },
263                    count: None,
264                },
265                wgpu::BindGroupLayoutEntry {
266                    binding: 6,
267                    visibility: wgpu::ShaderStages::VERTEX,
268                    ty: wgpu::BindingType::Buffer {
269                        ty: wgpu::BufferBindingType::Storage { read_only: true },
270                        has_dynamic_offset: false,
271                        min_binding_size: None,
272                    },
273                    count: None,
274                },
275            ],
276        });
277
278        let render_shader = crate::resources::builders::wgsl_module(
279            device,
280            "gaussian_splat_shader",
281            crate::resources::builders::wgsl_source!("gaussian_splat"),
282        );
283
284        let render_layout = crate::resources::builders::standard_scene_layout(
285            device,
286            "gaussian_splat_pipeline_layout",
287            &self.camera_bind_group_layout,
288            &splat_bgl,
289        );
290
291        // No MSAA for Gaussian splats (alpha blending requires single-sample).
292        let gaussian_splat_pipeline = crate::resources::builders::build_dual_pipeline(
293            device,
294            &crate::resources::builders::DualPipelineDesc {
295                label: "gaussian_splat_pipeline",
296                layout: &render_layout,
297                shader: &render_shader,
298                vertex_entry: "vs_main",
299                fragment_entry: "fs_main",
300                vertex_buffers: &[],
301                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
302                topology: wgpu::PrimitiveTopology::TriangleList,
303                cull_mode: None,
304                depth_write: false,
305                depth_compare: wgpu::CompareFunction::Less,
306                sample_count: 1,
307                ldr_format: self.target_format,
308            },
309        );
310
311        // ---------------------------------------------------------------
312        // Sort compute pipelines
313        // ---------------------------------------------------------------
314
315        let sort_shader = crate::resources::builders::wgsl_module(
316            device,
317            "gaussian_splat_sort_shader",
318            crate::resources::builders::wgsl_source!("gaussian_splat_sort"),
319        );
320
321        // Depth compute BGL: DepthUniform (b0), positions (b1), keys_ping_out (b2).
322        let depth_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
323            label: Some("gaussian_splat_depth_bgl"),
324            entries: &[
325                wgpu::BindGroupLayoutEntry {
326                    binding: 0,
327                    visibility: wgpu::ShaderStages::COMPUTE,
328                    ty: wgpu::BindingType::Buffer {
329                        ty: wgpu::BufferBindingType::Uniform,
330                        has_dynamic_offset: false,
331                        min_binding_size: None,
332                    },
333                    count: None,
334                },
335                wgpu::BindGroupLayoutEntry {
336                    binding: 1,
337                    visibility: wgpu::ShaderStages::COMPUTE,
338                    ty: wgpu::BindingType::Buffer {
339                        ty: wgpu::BufferBindingType::Storage { read_only: true },
340                        has_dynamic_offset: false,
341                        min_binding_size: None,
342                    },
343                    count: None,
344                },
345                wgpu::BindGroupLayoutEntry {
346                    binding: 2,
347                    visibility: wgpu::ShaderStages::COMPUTE,
348                    ty: wgpu::BindingType::Buffer {
349                        ty: wgpu::BufferBindingType::Storage { read_only: false },
350                        has_dynamic_offset: false,
351                        min_binding_size: None,
352                    },
353                    count: None,
354                },
355            ],
356        });
357
358        let depth_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
359            label: Some("gaussian_splat_depth_layout"),
360            bind_group_layouts: &[&depth_bgl],
361            push_constant_ranges: &[],
362        });
363
364        let gaussian_splat_depth_pipeline = crate::resources::builders::compute_pipeline(
365            device,
366            "gaussian_splat_depth_pipeline",
367            &depth_layout,
368            &sort_shader,
369            "compute_depths",
370        );
371
372        // Sort BGL: SortUniform (b0), keys_ping (b1), keys_pong (b2),
373        //           vals_ping (b3), vals_pong (b4), histogram (b5).
374        let sort_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
375            label: Some("gaussian_splat_sort_bgl"),
376            entries: &[
377                wgpu::BindGroupLayoutEntry {
378                    binding: 0,
379                    visibility: wgpu::ShaderStages::COMPUTE,
380                    ty: wgpu::BindingType::Buffer {
381                        ty: wgpu::BufferBindingType::Uniform,
382                        has_dynamic_offset: false,
383                        min_binding_size: None,
384                    },
385                    count: None,
386                },
387                wgpu::BindGroupLayoutEntry {
388                    binding: 1,
389                    visibility: wgpu::ShaderStages::COMPUTE,
390                    ty: wgpu::BindingType::Buffer {
391                        ty: wgpu::BufferBindingType::Storage { read_only: false },
392                        has_dynamic_offset: false,
393                        min_binding_size: None,
394                    },
395                    count: None,
396                },
397                wgpu::BindGroupLayoutEntry {
398                    binding: 2,
399                    visibility: wgpu::ShaderStages::COMPUTE,
400                    ty: wgpu::BindingType::Buffer {
401                        ty: wgpu::BufferBindingType::Storage { read_only: false },
402                        has_dynamic_offset: false,
403                        min_binding_size: None,
404                    },
405                    count: None,
406                },
407                wgpu::BindGroupLayoutEntry {
408                    binding: 3,
409                    visibility: wgpu::ShaderStages::COMPUTE,
410                    ty: wgpu::BindingType::Buffer {
411                        ty: wgpu::BufferBindingType::Storage { read_only: false },
412                        has_dynamic_offset: false,
413                        min_binding_size: None,
414                    },
415                    count: None,
416                },
417                wgpu::BindGroupLayoutEntry {
418                    binding: 4,
419                    visibility: wgpu::ShaderStages::COMPUTE,
420                    ty: wgpu::BindingType::Buffer {
421                        ty: wgpu::BufferBindingType::Storage { read_only: false },
422                        has_dynamic_offset: false,
423                        min_binding_size: None,
424                    },
425                    count: None,
426                },
427                wgpu::BindGroupLayoutEntry {
428                    binding: 5,
429                    visibility: wgpu::ShaderStages::COMPUTE,
430                    ty: wgpu::BindingType::Buffer {
431                        ty: wgpu::BufferBindingType::Storage { read_only: false },
432                        has_dynamic_offset: false,
433                        min_binding_size: None,
434                    },
435                    count: None,
436                },
437            ],
438        });
439
440        let sort_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
441            label: Some("gaussian_splat_sort_layout"),
442            bind_group_layouts: &[&sort_bgl],
443            push_constant_ranges: &[],
444        });
445
446        let gaussian_splat_sort_init_pipeline = crate::resources::builders::compute_pipeline(
447            device,
448            "gaussian_splat_sort_init_pipeline",
449            &sort_layout,
450            &sort_shader,
451            "init_indices",
452        );
453
454        let gaussian_splat_sort_clear_pipeline = crate::resources::builders::compute_pipeline(
455            device,
456            "gaussian_splat_sort_clear_pipeline",
457            &sort_layout,
458            &sort_shader,
459            "clear_histogram",
460        );
461
462        let gaussian_splat_sort_histogram_pipeline = crate::resources::builders::compute_pipeline(
463            device,
464            "gaussian_splat_sort_histogram_pipeline",
465            &sort_layout,
466            &sort_shader,
467            "histogram_pass",
468        );
469
470        let gaussian_splat_sort_prefix_pipeline = crate::resources::builders::compute_pipeline(
471            device,
472            "gaussian_splat_sort_prefix_pipeline",
473            &sort_layout,
474            &sort_shader,
475            "prefix_sum_pass",
476        );
477
478        let gaussian_splat_sort_scatter_pipeline = crate::resources::builders::compute_pipeline(
479            device,
480            "gaussian_splat_sort_scatter_pipeline",
481            &sort_layout,
482            &sort_shader,
483            "scatter_pass",
484        );
485
486        self.gaussian_splat.bgl = Some(splat_bgl);
487        self.gaussian_splat.pipeline = Some(gaussian_splat_pipeline);
488        self.gaussian_splat.depth_bgl = Some(depth_bgl);
489        self.gaussian_splat.depth_pipeline = Some(gaussian_splat_depth_pipeline);
490        self.gaussian_splat.sort_bgl = Some(sort_bgl);
491        self.gaussian_splat.sort_init_pipeline = Some(gaussian_splat_sort_init_pipeline);
492        self.gaussian_splat.sort_clear_pipeline = Some(gaussian_splat_sort_clear_pipeline);
493        self.gaussian_splat.sort_histogram_pipeline = Some(gaussian_splat_sort_histogram_pipeline);
494        self.gaussian_splat.sort_prefix_pipeline = Some(gaussian_splat_sort_prefix_pipeline);
495        self.gaussian_splat.sort_scatter_pipeline = Some(gaussian_splat_sort_scatter_pipeline);
496    }
497
498    /// Upload one Gaussian splat set to the GPU and return its handle.
499    ///
500    /// Call once per splat set at startup (or when the set changes). The returned
501    /// [`GaussianSplatId`] is stable until [`free_gaussian_splat`] is called.
502    ///
503    /// # Errors
504    ///
505    /// Returns [`ViewportError::InvalidGaussianSplatData`](crate::error::ViewportError::InvalidGaussianSplatData)
506    /// if `data.positions` is empty or if the lengths of `positions`, `scales`,
507    /// `rotations`, and `opacities` do not all match.
508    ///
509    /// # Examples
510    ///
511    /// ```no_run
512    /// # use viewport_lib::error::ViewportError;
513    /// # use viewport_lib::renderer::{GaussianSplatData, ViewportRenderer};
514    /// # fn demo(renderer: &mut ViewportRenderer, device: &wgpu::Device, queue: &wgpu::Queue) {
515    /// let result = renderer.upload_gaussian_splat(device, queue, &GaussianSplatData::default());
516    /// assert!(matches!(result, Err(ViewportError::InvalidGaussianSplatData { .. })));
517    /// # }
518    /// ```
519    pub fn upload_gaussian_splat(
520        &mut self,
521        device: &wgpu::Device,
522        queue: &wgpu::Queue,
523        data: &crate::renderer::GaussianSplatData,
524    ) -> crate::error::ViewportResult<crate::renderer::GaussianSplatId> {
525        validate_gaussian_splat_data(data)?;
526        let gpu_set = build_gaussian_splat_set(device, queue, data);
527        Ok(self.content.gaussian_splat_store.insert(gpu_set))
528    }
529
530    /// Replace the contents of an uploaded Gaussian splat set in place, keeping
531    /// the same [`GaussianSplatId`](crate::renderer::GaussianSplatId).
532    ///
533    /// Items holding the handle pick up the new splats on the next frame with no
534    /// reassignment. The generation check is the in-flight guard: a stale handle
535    /// (its slot freed and reused) returns
536    /// [`StaleHandle`](crate::error::ViewportError::StaleHandle) instead of
537    /// overwriting whatever now occupies the slot. Use this for content that
538    /// changes over time (a re-trained or streamed splat set).
539    ///
540    /// # Errors
541    ///
542    /// [`InvalidGaussianSplatData`](crate::error::ViewportError::InvalidGaussianSplatData)
543    /// when `data` is empty or its per-attribute vectors disagree in length, or
544    /// [`StaleHandle`](crate::error::ViewportError::StaleHandle) if `id` does not
545    /// resolve to a live set.
546    pub fn replace_gaussian_splat(
547        &mut self,
548        device: &wgpu::Device,
549        queue: &wgpu::Queue,
550        id: crate::renderer::GaussianSplatId,
551        data: &crate::renderer::GaussianSplatData,
552    ) -> crate::error::ViewportResult<()> {
553        validate_gaussian_splat_data(data)?;
554        let gpu_set = build_gaussian_splat_set(device, queue, data);
555        if self.content.gaussian_splat_store.replace(id, gpu_set) {
556            Ok(())
557        } else {
558            Err(crate::error::ViewportError::StaleHandle {
559                index: id.index() as usize,
560                count: self.content.gaussian_splat_store.slot_count(),
561            })
562        }
563    }
564
565    /// Remove an uploaded Gaussian splat set by handle.
566    pub fn free_gaussian_splat(&mut self, id: crate::renderer::GaussianSplatId) {
567        self.content.gaussian_splat_store.remove(id);
568    }
569
570    /// Start an asynchronous Gaussian splat upload.
571    ///
572    /// Returns a [`JobId`](crate::resources::JobId) immediately. Vec4
573    /// padding for positions / scales / rotations and storage buffer
574    /// creation + writes all run on a worker thread on a cloned `Device`
575    /// and `Queue`. The apply step inserts the prepared `GaussianSplatGpuSet`
576    /// into the store and surfaces the resulting [`GaussianSplatId`].
577    ///
578    /// # Errors
579    ///
580    /// Returns [`ViewportError::InvalidGaussianSplatData`] before any job
581    /// is submitted when `data.positions` is empty or the per-attribute
582    /// vectors disagree in length.
583    pub fn begin_upload_gaussian_splat(
584        &mut self,
585        device: &wgpu::Device,
586        queue: &wgpu::Queue,
587        data: crate::renderer::GaussianSplatData,
588    ) -> crate::error::ViewportResult<crate::resources::JobId> {
589        validate_gaussian_splat_data(&data)?;
590
591        let slot = crate::resources::ResultSlot::<crate::renderer::GaussianSplatId>::new();
592        let slot_for_apply = slot.clone();
593        let device_for_worker = device.clone();
594        let queue_for_worker = queue.clone();
595
596        let id = {
597            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
598            runner.submit_cpu(move |progress| {
599                progress.set(0.1);
600                let gpu_set =
601                    build_gaussian_splat_set(&device_for_worker, &queue_for_worker, &data);
602                progress.set(0.95);
603
604                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
605                    Box::new(move |resources: &mut DeviceResources| {
606                        let id = resources.content.gaussian_splat_store.insert(gpu_set);
607                        slot_for_apply.set(id);
608                    }),
609                ))
610            })
611        };
612
613        self.job_results
614            .gaussian_splat
615            .lock()
616            .expect("gaussian splat result map poisoned")
617            .insert(id, slot);
618        Ok(id)
619    }
620
621    /// Take the [`GaussianSplatId`](crate::renderer::GaussianSplatId) produced by a
622    /// completed [`begin_upload_gaussian_splat`](Self::begin_upload_gaussian_splat) job.
623    pub fn upload_result_gaussian_splat(
624        &mut self,
625        id: crate::resources::JobId,
626    ) -> crate::error::ViewportResult<crate::renderer::GaussianSplatId> {
627        let mut map = self
628            .job_results
629            .gaussian_splat
630            .lock()
631            .expect("gaussian splat result map poisoned");
632        let slot = match map.get(&id) {
633            Some(s) => s.clone(),
634            None => {
635                return Err(crate::error::ViewportError::JobResultMissing {
636                    reason: "unknown id or wrong upload type",
637                });
638            }
639        };
640        match slot.take() {
641            Some(splat_id) => {
642                map.remove(&id);
643                Ok(splat_id)
644            }
645            None => Err(crate::error::ViewportError::JobNotReady),
646        }
647    }
648
649    /// Ensure per-viewport sort buffers exist for (store_index, viewport_index).
650    ///
651    /// Also creates the render bind group for the render pipeline (group 1).
652    /// Called from run_gaussian_splat_sort before dispatching.
653    pub(crate) fn ensure_gaussian_splat_sort_buffers(
654        &mut self,
655        device: &wgpu::Device,
656        store_index: usize,
657        viewport_index: usize,
658    ) {
659        let set = match self.content.gaussian_splat_store.get_by_index(store_index) {
660            Some(s) => s,
661            None => return,
662        };
663        let count = set.count as usize;
664
665        // Grow the per-viewport vec if needed.
666        if viewport_index >= set.viewport_sort.len() || set.viewport_sort[viewport_index].is_none()
667        {
668            // We need mutable access - re-borrow.
669            let set_mut = self
670                .content
671                .gaussian_splat_store
672                .get_mut_by_index(store_index)
673                .unwrap();
674            while set_mut.viewport_sort.len() <= viewport_index {
675                set_mut.viewport_sort.push(None);
676            }
677
678            if set_mut.viewport_sort[viewport_index].is_none() {
679                let buf_size = (count * 4).max(4) as u64;
680
681                let depth_buf = device.create_buffer(&wgpu::BufferDescriptor {
682                    label: Some("splat_depth_buf"),
683                    size: buf_size,
684                    usage: wgpu::BufferUsages::STORAGE
685                        | wgpu::BufferUsages::COPY_DST
686                        | wgpu::BufferUsages::COPY_SRC,
687                    mapped_at_creation: false,
688                });
689                let sort_buf_usage = wgpu::BufferUsages::STORAGE
690                    | wgpu::BufferUsages::COPY_SRC
691                    | wgpu::BufferUsages::COPY_DST;
692                let keys_ping = device.create_buffer(&wgpu::BufferDescriptor {
693                    label: Some("splat_keys_ping"),
694                    size: buf_size,
695                    usage: sort_buf_usage,
696                    mapped_at_creation: false,
697                });
698                let keys_pong = device.create_buffer(&wgpu::BufferDescriptor {
699                    label: Some("splat_keys_pong"),
700                    size: buf_size,
701                    usage: sort_buf_usage,
702                    mapped_at_creation: false,
703                });
704                let vals_ping = device.create_buffer(&wgpu::BufferDescriptor {
705                    label: Some("splat_vals_ping"),
706                    size: buf_size,
707                    usage: sort_buf_usage,
708                    mapped_at_creation: false,
709                });
710                let vals_pong = device.create_buffer(&wgpu::BufferDescriptor {
711                    label: Some("splat_vals_pong"),
712                    size: buf_size,
713                    usage: sort_buf_usage,
714                    mapped_at_creation: false,
715                });
716                // Histogram: 256 x u32 atomic.
717                let histogram_buf = device.create_buffer(&wgpu::BufferDescriptor {
718                    label: Some("splat_histogram"),
719                    size: 256 * 4,
720                    usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
721                    mapped_at_creation: false,
722                });
723                // Per-viewport SplatUniform buffer.
724                let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
725                    label: Some("splat_uniform_buf"),
726                    size: std::mem::size_of::<SplatUniform>() as u64,
727                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
728                    mapped_at_creation: false,
729                });
730
731                // Build the render bind group (group 1). vals_ping holds sorted indices
732                // after 4 sort passes (even number of passes means result ends in ping).
733                let render_bg = {
734                    let bgl = self.gaussian_splat.bgl.as_ref().unwrap();
735                    let set_ref = self
736                        .content
737                        .gaussian_splat_store
738                        .get_by_index(store_index)
739                        .unwrap();
740                    device.create_bind_group(&wgpu::BindGroupDescriptor {
741                        label: Some("splat_render_bg"),
742                        layout: bgl,
743                        entries: &[
744                            wgpu::BindGroupEntry {
745                                binding: 0,
746                                resource: uniform_buf.as_entire_binding(),
747                            },
748                            wgpu::BindGroupEntry {
749                                binding: 1,
750                                resource: vals_ping.as_entire_binding(),
751                            },
752                            wgpu::BindGroupEntry {
753                                binding: 2,
754                                resource: set_ref.position_buf.as_entire_binding(),
755                            },
756                            wgpu::BindGroupEntry {
757                                binding: 3,
758                                resource: set_ref.scale_buf.as_entire_binding(),
759                            },
760                            wgpu::BindGroupEntry {
761                                binding: 4,
762                                resource: set_ref.rotation_buf.as_entire_binding(),
763                            },
764                            wgpu::BindGroupEntry {
765                                binding: 5,
766                                resource: set_ref.opacity_buf.as_entire_binding(),
767                            },
768                            wgpu::BindGroupEntry {
769                                binding: 6,
770                                resource: set_ref.sh_buf.as_entire_binding(),
771                            },
772                        ],
773                    })
774                };
775
776                let vp_sort = GaussianSplatViewportSort {
777                    depth_buf,
778                    keys_ping,
779                    keys_pong,
780                    vals_ping,
781                    vals_pong,
782                    histogram_buf,
783                    render_bg,
784                    last_eye: [f32::NAN; 3],
785                    uniform_buf,
786                };
787
788                let set_mut2 = self
789                    .content
790                    .gaussian_splat_store
791                    .get_mut_by_index(store_index)
792                    .unwrap();
793                set_mut2.viewport_sort[viewport_index] = Some(vp_sort);
794            }
795        }
796    }
797
798    /// Run the GPU depth compute + 4-pass radix sort for one splat set / viewport.
799    ///
800    /// Uploads updated SplatUniform (viewport dims, model, sh_degree), dispatches
801    /// the depth compute shader, then runs init_indices + 4 sort passes.
802    pub(crate) fn run_gaussian_splat_sort(
803        &mut self,
804        device: &wgpu::Device,
805        queue: &wgpu::Queue,
806        store_index: usize,
807        viewport_index: usize,
808        eye: [f32; 3],
809        model: [[f32; 4]; 4],
810        vp_w: f32,
811        vp_h: f32,
812        sh_degree: ShDegree,
813    ) {
814        // Ensure sort buffers and render BG exist.
815        self.ensure_gaussian_splat_sort_buffers(device, store_index, viewport_index);
816
817        let set = match self.content.gaussian_splat_store.get_by_index(store_index) {
818            Some(s) => s,
819            None => return,
820        };
821        let count = set.count;
822        if count == 0 {
823            return;
824        }
825
826        let vp_sort = match set.viewport_sort.get(viewport_index) {
827            Some(Some(s)) => s,
828            _ => return,
829        };
830
831        // Update the SplatUniform for this viewport.
832        let splat_uni = SplatUniform {
833            model,
834            viewport_w: vp_w,
835            viewport_h: vp_h,
836            sh_degree: match sh_degree {
837                ShDegree::Zero => 0,
838                ShDegree::One => 1,
839                ShDegree::Three => 3,
840            },
841            count,
842        };
843        queue.write_buffer(&vp_sort.uniform_buf, 0, bytemuck::bytes_of(&splat_uni));
844
845        // Upload depth uniform.
846        let depth_uni = DepthUniform { model, eye, count };
847        let depth_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
848            label: Some("splat_depth_uniform_tmp"),
849            size: std::mem::size_of::<DepthUniform>() as u64,
850            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
851            mapped_at_creation: false,
852        });
853        queue.write_buffer(&depth_uniform_buf, 0, bytemuck::bytes_of(&depth_uni));
854
855        // Build depth BG.
856        let depth_bg = {
857            let bgl = self.gaussian_splat.depth_bgl.as_ref().unwrap();
858            let set_ref = self
859                .content
860                .gaussian_splat_store
861                .get_by_index(store_index)
862                .unwrap();
863            let vp_sort_ref = set_ref.viewport_sort[viewport_index].as_ref().unwrap();
864            device.create_bind_group(&wgpu::BindGroupDescriptor {
865                label: Some("splat_depth_bg"),
866                layout: bgl,
867                entries: &[
868                    wgpu::BindGroupEntry {
869                        binding: 0,
870                        resource: depth_uniform_buf.as_entire_binding(),
871                    },
872                    wgpu::BindGroupEntry {
873                        binding: 1,
874                        resource: set_ref.position_buf.as_entire_binding(),
875                    },
876                    wgpu::BindGroupEntry {
877                        binding: 2,
878                        resource: vp_sort_ref.depth_buf.as_entire_binding(),
879                    },
880                ],
881            })
882        };
883
884        let workgroups = (count + 255) / 256;
885
886        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
887            label: Some("splat_sort_encoder"),
888        });
889
890        // --- Depth compute pass ---
891        {
892            let depth_pipeline = self.gaussian_splat.depth_pipeline.as_ref().unwrap();
893            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
894                label: Some("splat_depth_pass"),
895                timestamp_writes: None,
896            });
897            cpass.set_pipeline(depth_pipeline);
898            cpass.set_bind_group(0, &depth_bg, &[]);
899            cpass.dispatch_workgroups(workgroups, 1, 1);
900        }
901
902        // Copy depth keys into keys_ping (depth_buf -> keys_ping).
903        {
904            let set_ref = self
905                .content
906                .gaussian_splat_store
907                .get_by_index(store_index)
908                .unwrap();
909            let vp_sort_ref = set_ref.viewport_sort[viewport_index].as_ref().unwrap();
910            encoder.copy_buffer_to_buffer(
911                &vp_sort_ref.depth_buf,
912                0,
913                &vp_sort_ref.keys_ping,
914                0,
915                (count as u64) * 4,
916            );
917        }
918
919        // --- 4-pass radix sort ---
920        // Build per-pass sort uniforms (shift = 0, 8, 16, 24; pass_num = 0..3).
921        // We need sort_bgl and references to sort buffers for each pass.
922        let sort_bgl = self.gaussian_splat.sort_bgl.as_ref().unwrap();
923
924        for pass in 0u32..4u32 {
925            let sort_uni = SortUniform {
926                shift: pass * 8,
927                count,
928                pass_num: pass,
929                _pad: 0,
930            };
931            let sort_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
932                label: Some("splat_sort_uniform_tmp"),
933                size: std::mem::size_of::<SortUniform>() as u64,
934                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
935                mapped_at_creation: false,
936            });
937            queue.write_buffer(&sort_uniform_buf, 0, bytemuck::bytes_of(&sort_uni));
938
939            let set_ref = self
940                .content
941                .gaussian_splat_store
942                .get_by_index(store_index)
943                .unwrap();
944            let vp_sort_ref = set_ref.viewport_sort[viewport_index].as_ref().unwrap();
945
946            let sort_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
947                label: Some("splat_sort_bg"),
948                layout: sort_bgl,
949                entries: &[
950                    wgpu::BindGroupEntry {
951                        binding: 0,
952                        resource: sort_uniform_buf.as_entire_binding(),
953                    },
954                    wgpu::BindGroupEntry {
955                        binding: 1,
956                        resource: vp_sort_ref.keys_ping.as_entire_binding(),
957                    },
958                    wgpu::BindGroupEntry {
959                        binding: 2,
960                        resource: vp_sort_ref.keys_pong.as_entire_binding(),
961                    },
962                    wgpu::BindGroupEntry {
963                        binding: 3,
964                        resource: vp_sort_ref.vals_ping.as_entire_binding(),
965                    },
966                    wgpu::BindGroupEntry {
967                        binding: 4,
968                        resource: vp_sort_ref.vals_pong.as_entire_binding(),
969                    },
970                    wgpu::BindGroupEntry {
971                        binding: 5,
972                        resource: vp_sort_ref.histogram_buf.as_entire_binding(),
973                    },
974                ],
975            });
976
977            // If pass 0: run init_indices first.
978            if pass == 0 {
979                let init_pipeline = self.gaussian_splat.sort_init_pipeline.as_ref().unwrap();
980                let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
981                    label: Some("splat_init_pass"),
982                    timestamp_writes: None,
983                });
984                cpass.set_pipeline(init_pipeline);
985                cpass.set_bind_group(0, &sort_bg, &[]);
986                cpass.dispatch_workgroups(workgroups, 1, 1);
987            }
988
989            // Clear histogram.
990            {
991                let clear_pipeline = self.gaussian_splat.sort_clear_pipeline.as_ref().unwrap();
992                let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
993                    label: Some("splat_clear_hist"),
994                    timestamp_writes: None,
995                });
996                cpass.set_pipeline(clear_pipeline);
997                cpass.set_bind_group(0, &sort_bg, &[]);
998                cpass.dispatch_workgroups(1, 1, 1);
999            }
1000
1001            // Histogram pass.
1002            {
1003                let hist_pipeline = self
1004                    .gaussian_splat
1005                    .sort_histogram_pipeline
1006                    .as_ref()
1007                    .unwrap();
1008                let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1009                    label: Some("splat_hist_pass"),
1010                    timestamp_writes: None,
1011                });
1012                cpass.set_pipeline(hist_pipeline);
1013                cpass.set_bind_group(0, &sort_bg, &[]);
1014                cpass.dispatch_workgroups(workgroups, 1, 1);
1015            }
1016
1017            // Prefix sum.
1018            {
1019                let prefix_pipeline = self.gaussian_splat.sort_prefix_pipeline.as_ref().unwrap();
1020                let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1021                    label: Some("splat_prefix_pass"),
1022                    timestamp_writes: None,
1023                });
1024                cpass.set_pipeline(prefix_pipeline);
1025                cpass.set_bind_group(0, &sort_bg, &[]);
1026                cpass.dispatch_workgroups(1, 1, 1);
1027            }
1028
1029            // Scatter.
1030            {
1031                let scatter_pipeline = self.gaussian_splat.sort_scatter_pipeline.as_ref().unwrap();
1032                let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1033                    label: Some("splat_scatter_pass"),
1034                    timestamp_writes: None,
1035                });
1036                cpass.set_pipeline(scatter_pipeline);
1037                cpass.set_bind_group(0, &sort_bg, &[]);
1038                cpass.dispatch_workgroups(workgroups, 1, 1);
1039            }
1040        }
1041
1042        queue.submit(std::iter::once(encoder.finish()));
1043
1044        // Record eye so callers can skip re-sort if unchanged (optional optimisation).
1045        if let Some(set_mut) = self
1046            .content
1047            .gaussian_splat_store
1048            .get_mut_by_index(store_index)
1049        {
1050            if let Some(Some(vp_sort_mut)) = set_mut.viewport_sort.get_mut(viewport_index) {
1051                vp_sort_mut.last_eye = eye;
1052            }
1053        }
1054    }
1055}
1056
1057#[cfg(test)]
1058mod async_tests {
1059    use crate::DeviceResources;
1060    use crate::renderer::GaussianSplatData;
1061    use crate::resources::UploadStatus;
1062
1063    fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
1064        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
1065        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1066            power_preference: wgpu::PowerPreference::LowPower,
1067            compatible_surface: None,
1068            force_fallback_adapter: false,
1069        }))
1070        .ok()?;
1071        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
1072    }
1073
1074    fn sample_splats(n: usize) -> GaussianSplatData {
1075        let mut data = GaussianSplatData::default();
1076        data.positions = (0..n).map(|i| [i as f32, 0.0, 0.0]).collect();
1077        data.scales = vec![[0.1, 0.1, 0.1]; n];
1078        data.rotations = vec![[0.0, 0.0, 0.0, 1.0]; n];
1079        data.opacities = vec![0.5; n];
1080        data
1081    }
1082
1083    #[test]
1084    fn stale_splat_handle_does_not_alias_after_slot_reuse() {
1085        let Some((device, queue)) = try_make_device() else {
1086            eprintln!("skipping: no wgpu adapter available");
1087            return;
1088        };
1089        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1090
1091        // Upload a splat set, then remove it. The handle is now stale.
1092        let id1 = resources
1093            .upload_gaussian_splat(&device, &queue, &sample_splats(8))
1094            .unwrap();
1095        assert!(resources.content.gaussian_splat_store.get(id1).is_some());
1096        resources.free_gaussian_splat(id1);
1097        assert!(
1098            resources.content.gaussian_splat_store.get(id1).is_none(),
1099            "a removed handle must not resolve"
1100        );
1101
1102        // The next upload reuses the freed slot at a new generation.
1103        let id2 = resources
1104            .upload_gaussian_splat(&device, &queue, &sample_splats(4))
1105            .unwrap();
1106        assert_eq!(id1.index(), id2.index(), "the freed slot should be reused");
1107        assert_ne!(id1, id2, "the reused slot must carry a new generation");
1108        assert!(resources.content.gaussian_splat_store.get(id2).is_some());
1109        assert!(
1110            resources.content.gaussian_splat_store.get(id1).is_none(),
1111            "the stale handle must not alias the set now occupying its slot"
1112        );
1113    }
1114
1115    #[test]
1116    fn begin_upload_gaussian_splat_validates() {
1117        let Some((device, queue)) = try_make_device() else {
1118            eprintln!("skipping: no wgpu adapter available");
1119            return;
1120        };
1121        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1122        let err = resources
1123            .begin_upload_gaussian_splat(&device, &queue, GaussianSplatData::default())
1124            .unwrap_err();
1125        assert!(matches!(
1126            err,
1127            crate::error::ViewportError::InvalidGaussianSplatData { .. }
1128        ));
1129    }
1130
1131    #[test]
1132    fn begin_upload_gaussian_splat_drains_to_handle() {
1133        let Some((device, queue)) = try_make_device() else {
1134            eprintln!("skipping: no wgpu adapter available");
1135            return;
1136        };
1137        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1138        let job = resources
1139            .begin_upload_gaussian_splat(&device, &queue, sample_splats(8))
1140            .expect("job submitted");
1141        for _ in 0..200 {
1142            resources.process_uploads(&device, &queue);
1143            match resources.upload_status(job) {
1144                UploadStatus::Ready => break,
1145                UploadStatus::Failed(e) => panic!("upload failed: {e:?}"),
1146                UploadStatus::Pending { .. } => {
1147                    std::thread::sleep(std::time::Duration::from_millis(5));
1148                }
1149                UploadStatus::Unknown => panic!("job id disappeared"),
1150            }
1151        }
1152        let _id = resources.upload_result_gaussian_splat(job).expect("ready");
1153    }
1154
1155    #[test]
1156    fn replace_gaussian_splat_keeps_handle_and_updates_bytes() {
1157        let Some((device, queue)) = try_make_device() else {
1158            eprintln!("skipping: no wgpu adapter available");
1159            return;
1160        };
1161        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1162
1163        let id = resources
1164            .upload_gaussian_splat(&device, &queue, &sample_splats(8))
1165            .unwrap();
1166        let bytes_before = resources.resident_bytes().gaussian_splat_bytes;
1167        assert!(
1168            bytes_before > 0,
1169            "an uploaded set must count resident bytes"
1170        );
1171
1172        // Replacing with a smaller set keeps the handle valid and shrinks bytes.
1173        resources
1174            .replace_gaussian_splat(&device, &queue, id, &sample_splats(2))
1175            .expect("replace on a live handle succeeds");
1176        assert!(resources.content.gaussian_splat_store.get(id).is_some());
1177        let bytes_after = resources.resident_bytes().gaussian_splat_bytes;
1178        assert!(
1179            bytes_after < bytes_before,
1180            "replacing with fewer splats must reduce resident bytes"
1181        );
1182
1183        // A stale handle is rejected, not silently applied.
1184        resources.free_gaussian_splat(id);
1185        assert_eq!(resources.resident_bytes().gaussian_splat_bytes, 0);
1186        let err = resources
1187            .replace_gaussian_splat(&device, &queue, id, &sample_splats(2))
1188            .unwrap_err();
1189        assert!(matches!(
1190            err,
1191            crate::error::ViewportError::StaleHandle { .. }
1192        ));
1193    }
1194}
1195
1196/// Per-viewport sort buffers for one Gaussian splat set.
1197pub(crate) struct GaussianSplatViewportSort {
1198    /// u32 view-space depth keys (flipped for back-to-front), written by depth compute each frame.
1199    pub depth_buf: wgpu::Buffer,
1200    /// Ping/pong key buffers for radix sort.
1201    pub keys_ping: wgpu::Buffer,
1202    pub keys_pong: wgpu::Buffer,
1203    /// Ping/pong value (index) buffers for radix sort.
1204    pub vals_ping: wgpu::Buffer,
1205    pub vals_pong: wgpu::Buffer,
1206    /// 256-entry atomic histogram / prefix-sum scratch.
1207    pub histogram_buf: wgpu::Buffer,
1208    /// Render bind group (group 1). Contains sorted_indices, positions, scales, rotations,
1209    /// opacities, sh_coefficients, and the per-viewport SplatUniform.
1210    pub render_bg: wgpu::BindGroup,
1211    /// Eye position at last sort; skip re-sort when unchanged.
1212    pub last_eye: [f32; 3],
1213    /// Per-viewport uniform buffer holding SplatUniform (model, viewport dims, sh_degree, count).
1214    pub uniform_buf: wgpu::Buffer,
1215}
1216
1217/// Persistent GPU state for one uploaded Gaussian splat set.
1218pub(crate) struct GaussianSplatGpuSet {
1219    /// Positions as vec4<f32> (w=1), one per splat.
1220    pub position_buf: wgpu::Buffer,
1221    /// Scales as vec4<f32> (w=0), one per splat.
1222    pub scale_buf: wgpu::Buffer,
1223    /// Rotations as vec4<f32> [x,y,z,w], one per splat.
1224    pub rotation_buf: wgpu::Buffer,
1225    /// Opacities as f32, one per splat.
1226    pub opacity_buf: wgpu::Buffer,
1227    /// SH coefficients as f32, count = splat_count * sh_degree.coeff_count().
1228    pub sh_buf: wgpu::Buffer,
1229    /// SH degree for this set.
1230    pub sh_degree: crate::renderer::ShDegree,
1231    /// Number of splats.
1232    pub count: u32,
1233    /// Per-viewport sort buffers; index = viewport_index. Grown lazily.
1234    pub viewport_sort: Vec<Option<GaussianSplatViewportSort>>,
1235    /// CPU positions kept for potential picking (object-space).
1236    #[allow(dead_code)]
1237    pub cpu_positions: Vec<[f32; 3]>,
1238    /// CPU scales kept for potential picking.
1239    #[allow(dead_code)]
1240    pub cpu_scales: Vec<[f32; 3]>,
1241}
1242
1243impl GaussianSplatGpuSet {
1244    /// Resident GPU bytes for the persistent source buffers (position, scale,
1245    /// rotation, opacity, SH). Per-viewport sort scratch is derived and grows
1246    /// lazily, so it is not counted here.
1247    pub fn gpu_bytes(&self) -> u64 {
1248        self.position_buf.size()
1249            + self.scale_buf.size()
1250            + self.rotation_buf.size()
1251            + self.opacity_buf.size()
1252            + self.sh_buf.size()
1253    }
1254}
1255
1256/// Per-frame draw data produced in prepare_viewport_internal.
1257pub(crate) struct GaussianSplatDrawData {
1258    /// Index into gaussian_splat_store.
1259    pub store_index: usize,
1260    /// Viewport index that prepared this data.
1261    pub viewport_index: usize,
1262    /// Model matrix for this item.
1263    #[allow(dead_code)]
1264    pub model: [[f32; 4]; 4],
1265    /// Number of splats.
1266    pub count: u32,
1267    /// When true, skip the splat rasterization draw; a wireframe polyline overlay is rendered instead.
1268    pub wireframe: bool,
1269}
1270
1271/// Slotted store for Gaussian splat sets with generational handles.
1272///
1273/// A removed set leaves an empty slot that a later insert reuses. Each slot
1274/// carries a generation bumped on removal, and a [`GaussianSplatId`] captures
1275/// the generation it was issued against, so a stale handle resolves to `None`
1276/// rather than aliasing the set now in its slot. An entry's byte charge is its
1277/// [`GaussianSplatGpuSet::gpu_bytes`].
1278pub(crate) struct GaussianSplatStore {
1279    store:
1280        crate::resources::handle::SlotStore<GaussianSplatGpuSet, crate::renderer::GaussianSplatId>,
1281}
1282
1283impl GaussianSplatStore {
1284    pub fn new() -> Self {
1285        Self {
1286            store: crate::resources::handle::SlotStore::default(),
1287        }
1288    }
1289
1290    pub fn insert(&mut self, set: GaussianSplatGpuSet) -> crate::renderer::GaussianSplatId {
1291        let bytes = set.gpu_bytes();
1292        self.store.insert(set, bytes)
1293    }
1294
1295    /// Swap the set in `id`'s slot for `set`, keeping the slot generation so the
1296    /// handle stays valid. Returns `true` on success, `false` for a stale handle
1297    /// or an empty slot.
1298    pub fn replace(
1299        &mut self,
1300        id: crate::renderer::GaussianSplatId,
1301        set: GaussianSplatGpuSet,
1302    ) -> bool {
1303        let bytes = set.gpu_bytes();
1304        self.store.replace(id, set, bytes).is_some()
1305    }
1306
1307    /// Total resident GPU bytes across every live splat set.
1308    pub fn allocated_bytes(&self) -> u64 {
1309        self.store.allocated_bytes()
1310    }
1311
1312    /// Look up a set by handle, validating the generation. Returns `None` for a
1313    /// stale handle, an empty slot, or an out-of-range index.
1314    pub fn get(&self, id: crate::renderer::GaussianSplatId) -> Option<&GaussianSplatGpuSet> {
1315        self.store.get(id)
1316    }
1317
1318    /// Look up a set by raw slot index, without a generation check. For the
1319    /// per-frame draw path, where the index was already validated through
1320    /// [`get`](Self::get) earlier in the same frame.
1321    pub fn get_by_index(&self, idx: usize) -> Option<&GaussianSplatGpuSet> {
1322        self.store.get_by_index(idx)
1323    }
1324
1325    /// Mutable raw-index lookup, same contract as [`get_by_index`](Self::get_by_index).
1326    pub fn get_mut_by_index(&mut self, idx: usize) -> Option<&mut GaussianSplatGpuSet> {
1327        self.store.get_mut_by_index(idx)
1328    }
1329
1330    /// Total number of slots (occupied plus free). Reported in stale-handle
1331    /// errors to show how many slots exist.
1332    pub fn slot_count(&self) -> usize {
1333        self.store.slot_count()
1334    }
1335
1336    /// Remove a set by handle, bumping the slot generation and freeing the slot.
1337    /// Returns `true` if a set was removed, `false` for a stale handle or an
1338    /// already-empty slot.
1339    pub fn remove(&mut self, id: crate::renderer::GaussianSplatId) -> bool {
1340        self.store.remove(id).is_some()
1341    }
1342}