Skip to main content

viewport_lib/resources/gpu/
clustered.rs

1//! Clustered-shading GPU resources.
2//!
3//! The cluster grid partitions screen space into `X_TILES * Y_TILES * Z_SLICES`
4//! view-frustum cells. Each frame the build compute pass tags each cell with
5//! the list of lights whose volume of influence intersects it; lit pipelines
6//! then read just their cell's slice of the global index list instead of
7//! scanning every active light per fragment.
8//!
9//! Bindings 14, 15, and 16 of the camera bind group expose the grid uniform,
10//! the per-cell offsets, and the global index list to every lit pipeline. The
11//! build pass uses a separate compute bind group with read-write access.
12
13use wgpu::util::DeviceExt;
14
15/// X (screen-tile) count of the cluster grid. Aligns with 16:9 aspect framing.
16pub const CLUSTER_X_TILES: u32 = 16;
17/// Y (screen-tile) count of the cluster grid.
18pub const CLUSTER_Y_TILES: u32 = 9;
19/// Z (depth-slice) count. Log-uniform from near to far in the build pass.
20pub const CLUSTER_Z_SLICES: u32 = 24;
21/// Total cluster cell count (`16 * 9 * 24 = 3456`).
22pub const CLUSTER_COUNT: u32 = CLUSTER_X_TILES * CLUSTER_Y_TILES * CLUSTER_Z_SLICES;
23/// Maximum total light-index references shared across all clusters. At 4 bytes
24/// per index this caps the global list at 128 KB. The build pass drops the
25/// low-importance tail for any clusters that would push past this cap.
26pub const MAX_LIGHT_INDICES: u32 = 32 * 1024;
27/// Below this active-light count the build pass is skipped and the fragment
28/// shader iterates the full light array directly. Straight iteration is
29/// cheaper than cluster lookup overhead for small light counts.
30pub const SMALL_N_THRESHOLD: u32 = 16;
31
32/// Per-frame cluster grid metadata uniform.
33///
34/// Bound at group 0 binding 14. The fragment shader reads `dimensions` and
35/// `depth` to map a view-space fragment to a cluster index. The same uniform
36/// drives the build compute pass.
37///
38/// Layout is 64 bytes, 16-byte aligned: four `vec4` worth of state with the
39/// fields documented on each `pub` member below.
40#[repr(C)]
41#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
42pub struct ClusterGridUniform {
43    /// (x_tiles, y_tiles, z_slices, total_count).
44    pub dimensions: [u32; 4],
45    /// (near, far, log(far/near), active_light_count).
46    pub depth: [f32; 4],
47    /// (screen_w, screen_h, fallback_mode, _pad). `fallback_mode != 0` signals
48    /// the small-N fallback path to the shader, which then iterates the full
49    /// light array instead of the cluster list.
50    pub screen: [f32; 4],
51    /// (tan_half_fov_x, tan_half_fov_y, _pad, _pad). Used by the build pass
52    /// to compute per-cluster view-space AABBs from screen-tile NDC bounds.
53    pub proj_scale: [f32; 4],
54    /// World-to-view matrix. Lets the fragment shader compute a view-space
55    /// position without growing each consumer's per-shader `Camera` struct.
56    pub view: [[f32; 4]; 4],
57}
58
59impl Default for ClusterGridUniform {
60    fn default() -> Self {
61        Self {
62            dimensions: [
63                CLUSTER_X_TILES,
64                CLUSTER_Y_TILES,
65                CLUSTER_Z_SLICES,
66                CLUSTER_COUNT,
67            ],
68            depth: [0.1, 1000.0, (1000.0_f32 / 0.1_f32).ln(), 0.0],
69            screen: [1.0, 1.0, 1.0, 0.0],
70            proj_scale: [1.0, 1.0, 0.0, 0.0],
71            view: glam::Mat4::IDENTITY.to_cols_array_2d(),
72        }
73    }
74}
75
76/// Per-frame, per-light view-space data consumed by the cluster build pass.
77///
78/// Indices into this buffer match indices into `light_storage_buf` one-to-one,
79/// so the `u32` written by the build pass into the global index list is also
80/// a valid index into the per-fragment light array.
81///
82/// Layout is 48 bytes, 16-byte aligned. Field semantics are documented on
83/// each member below.
84#[repr(C)]
85#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
86pub struct ActiveLightView {
87    /// (view_pos.xyz, range). Directional lights leave xyz = 0, range = inf.
88    pub view_pos_range: [f32; 4],
89    /// (light_type, _pad, _pad, _pad). 0=directional, 1=point, 2=spot.
90    pub type_pad: [u32; 4],
91    /// (view_spot_dir.xyz, cos_outer_angle). Unused for non-spot lights.
92    pub spot_data: [f32; 4],
93}
94
95/// Uniform written by the host to drive the no-op clear compute pass.
96#[repr(C)]
97#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
98struct ClearParams {
99    cluster_count: u32,
100    index_count: u32,
101    _pad0: u32,
102    _pad1: u32,
103}
104
105/// GPU-side cluster-cell layout. 8 bytes per cluster; matches WGSL struct
106/// `ClusterCell { offset: u32, count: u32 }`.
107/// Per-frame diagnostics produced by reading back the cluster cell array.
108///
109/// Useful for verifying that the build pass is doing meaningful work and for
110/// choosing grid dimensions in tuning. Pulled by the host on demand via
111/// `ViewportRenderer::cluster_stats`; the readback is skipped when no
112/// consumer asks for it.
113#[derive(Debug, Clone, Copy, Default)]
114pub struct ClusterStats {
115    /// Total cluster cells in the grid (constant per build).
116    pub total_cells: u32,
117    /// Cells with at least one punctual (point or spot) light assigned.
118    pub non_empty_cells: u32,
119    /// Maximum punctual count across all cells.
120    pub max_punctual: u32,
121    /// Median punctual count across cells with at least one punctual.
122    pub median_punctual: u32,
123    /// 99th-percentile punctual count across cells with at least one
124    /// punctual.
125    pub p99_punctual: u32,
126    /// Mean punctual count across non-empty cells.
127    pub mean_punctual: f32,
128    /// Sum of `cell.count` across all cells : how much of the global light
129    /// index list the build pass actually used this frame.
130    pub total_index_slots_used: u32,
131    /// Capacity of the global light index list.
132    pub max_index_slots: u32,
133    /// Active light count after the CPU frustum cull.
134    pub active_light_count: u32,
135    /// True if the frame ran the small-N or force-fallback path. In that
136    /// case the cell stats are stale (last build) but `active_light_count`
137    /// is still meaningful.
138    pub fallback_active: bool,
139}
140
141/// One cell in the clustered light grid. Points into the global light index
142/// list and records how many lights of each kind affect the cluster.
143#[repr(C)]
144#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
145pub struct ClusterCell {
146    /// Offset into the global light index list at which this cluster's light
147    /// indices start.
148    pub offset: u32,
149    /// Number of light indices owned by this cluster. Includes directionals,
150    /// since the fragment shader iterates this many slots out of the global
151    /// index list.
152    pub count: u32,
153    /// Subset of `count` covering point and spot lights only. The debug
154    /// overlay reads this so the ever-present directional fill doesn't drown
155    /// out the per-cluster light density signal.
156    pub punctual_count: u32,
157    /// Pad to 16 bytes.
158    pub _pad: u32,
159}
160
161/// All clustered-shading state owned by `DeviceResources`.
162pub struct ClusteredResources {
163    /// `ClusterGridUniform` uniform buffer (group 0 binding 14).
164    pub grid_uniform_buf: wgpu::Buffer,
165    /// Cluster cell storage (group 0 binding 15, read-only fragment).
166    pub cluster_grid_buf: wgpu::Buffer,
167    /// Global light index list (group 0 binding 16, read-only fragment).
168    pub light_index_buf: wgpu::Buffer,
169    /// View-space data for the active (post-cull) light set, uploaded each
170    /// frame and consumed by the build pass.
171    pub active_lights_buf: wgpu::Buffer,
172    /// Single u32 atomic counter used by the build pass to reserve contiguous
173    /// regions of `light_index_buf`. Reset to zero each frame by the clear.
174    pub global_offset_buf: wgpu::Buffer,
175    /// CPU-readable staging buffer that mirrors `cluster_grid_buf`. Populated
176    /// only when the host calls `read_stats`.
177    stats_staging_buf: wgpu::Buffer,
178    /// Bind group for the cluster-clear compute pass.
179    clear_bind_group: wgpu::BindGroup,
180    /// Compute pipeline that zeroes both storage buffers each frame.
181    clear_pipeline: wgpu::ComputePipeline,
182    /// Bind group for the cluster-build compute pass.
183    build_bind_group: wgpu::BindGroup,
184    /// Compute pipeline that intersects each cluster with the active lights.
185    build_pipeline: wgpu::ComputePipeline,
186    /// Uniform buffer for the clear pass parameters (constants for now).
187    #[allow(dead_code)]
188    clear_params_buf: wgpu::Buffer,
189}
190
191impl ClusteredResources {
192    /// Allocate the cluster grid uniform, the cluster-cell storage, the global
193    /// light index list, and the clear / build compute pipelines.
194    pub fn new(device: &wgpu::Device) -> Self {
195        let grid_uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
196            label: Some("cluster_grid_uniform_buf"),
197            contents: bytemuck::cast_slice(&[ClusterGridUniform::default()]),
198            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
199        });
200
201        let cluster_grid_bytes = (CLUSTER_COUNT as u64) * std::mem::size_of::<ClusterCell>() as u64;
202        let cluster_grid_buf = device.create_buffer(&wgpu::BufferDescriptor {
203            label: Some("cluster_grid_buf"),
204            size: cluster_grid_bytes,
205            usage: wgpu::BufferUsages::STORAGE
206                | wgpu::BufferUsages::COPY_DST
207                | wgpu::BufferUsages::COPY_SRC,
208            mapped_at_creation: false,
209        });
210
211        let light_index_bytes = (MAX_LIGHT_INDICES as u64) * 4;
212        let light_index_buf = device.create_buffer(&wgpu::BufferDescriptor {
213            label: Some("cluster_light_index_buf"),
214            size: light_index_bytes,
215            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
216            mapped_at_creation: false,
217        });
218
219        let active_lights_bytes = (crate::resources::MAX_SCENE_LIGHTS as u64)
220            * std::mem::size_of::<ActiveLightView>() as u64;
221        let active_lights_buf = device.create_buffer(&wgpu::BufferDescriptor {
222            label: Some("cluster_active_lights_buf"),
223            size: active_lights_bytes,
224            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
225            mapped_at_creation: false,
226        });
227
228        let global_offset_buf = device.create_buffer(&wgpu::BufferDescriptor {
229            label: Some("cluster_global_offset_buf"),
230            size: 4,
231            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
232            mapped_at_creation: false,
233        });
234
235        let stats_staging_buf = device.create_buffer(&wgpu::BufferDescriptor {
236            label: Some("cluster_stats_staging_buf"),
237            size: cluster_grid_bytes,
238            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
239            mapped_at_creation: false,
240        });
241
242        let clear_params_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
243            label: Some("cluster_clear_params_buf"),
244            contents: bytemuck::cast_slice(&[ClearParams {
245                cluster_count: CLUSTER_COUNT,
246                index_count: MAX_LIGHT_INDICES,
247                _pad0: 0,
248                _pad1: 0,
249            }]),
250            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
251        });
252
253        let storage_entry = |binding: u32, read_only: bool| wgpu::BindGroupLayoutEntry {
254            binding,
255            visibility: wgpu::ShaderStages::COMPUTE,
256            ty: wgpu::BindingType::Buffer {
257                ty: wgpu::BufferBindingType::Storage { read_only },
258                has_dynamic_offset: false,
259                min_binding_size: None,
260            },
261            count: None,
262        };
263        let uniform_entry = |binding: u32| wgpu::BindGroupLayoutEntry {
264            binding,
265            visibility: wgpu::ShaderStages::COMPUTE,
266            ty: wgpu::BindingType::Buffer {
267                ty: wgpu::BufferBindingType::Uniform,
268                has_dynamic_offset: false,
269                min_binding_size: None,
270            },
271            count: None,
272        };
273
274        let clear_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
275            label: Some("cluster_clear_bgl"),
276            entries: &[
277                storage_entry(0, false), // cluster_grid
278                storage_entry(1, false), // light_indices
279                storage_entry(2, false), // global_offset_counter
280                uniform_entry(3),        // ClearParams
281            ],
282        });
283
284        let clear_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
285            label: Some("cluster_clear_bind_group"),
286            layout: &clear_bgl,
287            entries: &[
288                wgpu::BindGroupEntry {
289                    binding: 0,
290                    resource: cluster_grid_buf.as_entire_binding(),
291                },
292                wgpu::BindGroupEntry {
293                    binding: 1,
294                    resource: light_index_buf.as_entire_binding(),
295                },
296                wgpu::BindGroupEntry {
297                    binding: 2,
298                    resource: global_offset_buf.as_entire_binding(),
299                },
300                wgpu::BindGroupEntry {
301                    binding: 3,
302                    resource: clear_params_buf.as_entire_binding(),
303                },
304            ],
305        });
306
307        let clear_shader = crate::resources::builders::wgsl_module(
308            device,
309            "cluster_clear_shader",
310            crate::resources::builders::wgsl_source!("cluster_clear"),
311        );
312        let clear_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
313            label: Some("cluster_clear_pipeline_layout"),
314            bind_group_layouts: &[&clear_bgl],
315            push_constant_ranges: &[],
316        });
317        let clear_pipeline = crate::resources::builders::compute_pipeline(
318            device,
319            "cluster_clear_pipeline",
320            &clear_layout,
321            &clear_shader,
322            "main",
323        );
324
325        // Build pass : intersects each cluster's view-space AABB with the
326        // active-light set and writes the per-cluster light index ranges.
327        let build_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
328            label: Some("cluster_build_bgl"),
329            entries: &[
330                storage_entry(0, false), // cluster_grid
331                storage_entry(1, false), // light_indices
332                storage_entry(2, false), // global_offset_counter
333                uniform_entry(3),        // GridUniform
334                storage_entry(4, true),  // active_lights
335            ],
336        });
337        let build_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
338            label: Some("cluster_build_bind_group"),
339            layout: &build_bgl,
340            entries: &[
341                wgpu::BindGroupEntry {
342                    binding: 0,
343                    resource: cluster_grid_buf.as_entire_binding(),
344                },
345                wgpu::BindGroupEntry {
346                    binding: 1,
347                    resource: light_index_buf.as_entire_binding(),
348                },
349                wgpu::BindGroupEntry {
350                    binding: 2,
351                    resource: global_offset_buf.as_entire_binding(),
352                },
353                wgpu::BindGroupEntry {
354                    binding: 3,
355                    resource: grid_uniform_buf.as_entire_binding(),
356                },
357                wgpu::BindGroupEntry {
358                    binding: 4,
359                    resource: active_lights_buf.as_entire_binding(),
360                },
361            ],
362        });
363        let build_shader = crate::resources::builders::wgsl_module(
364            device,
365            "cluster_build_shader",
366            crate::resources::builders::wgsl_source!("cluster_build"),
367        );
368        let build_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
369            label: Some("cluster_build_pipeline_layout"),
370            bind_group_layouts: &[&build_bgl],
371            push_constant_ranges: &[],
372        });
373        let build_pipeline = crate::resources::builders::compute_pipeline(
374            device,
375            "cluster_build_pipeline",
376            &build_layout,
377            &build_shader,
378            "main",
379        );
380
381        Self {
382            grid_uniform_buf,
383            cluster_grid_buf,
384            light_index_buf,
385            active_lights_buf,
386            global_offset_buf,
387            stats_staging_buf,
388            clear_bind_group,
389            clear_pipeline,
390            build_bind_group,
391            build_pipeline,
392            clear_params_buf,
393        }
394    }
395
396    /// Copy `cluster_grid_buf` to host-readable memory, map it, and compute
397    /// `ClusterStats`. Blocks on a device poll while the GPU finishes the
398    /// copy, so this should be called sparingly : it's a debug-path readback
399    /// behind a host-controlled toggle, not a per-frame operation.
400    pub fn read_stats(
401        &self,
402        device: &wgpu::Device,
403        queue: &wgpu::Queue,
404        active_light_count: u32,
405        fallback_active: bool,
406    ) -> ClusterStats {
407        let bytes = (CLUSTER_COUNT as u64) * std::mem::size_of::<ClusterCell>() as u64;
408        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
409            label: Some("cluster_stats_copy_encoder"),
410        });
411        encoder.copy_buffer_to_buffer(&self.cluster_grid_buf, 0, &self.stats_staging_buf, 0, bytes);
412        queue.submit(std::iter::once(encoder.finish()));
413
414        let slice = self.stats_staging_buf.slice(..);
415        slice.map_async(wgpu::MapMode::Read, |_| {});
416        let _ = device.poll(wgpu::PollType::Wait {
417            submission_index: None,
418            timeout: Some(std::time::Duration::from_secs(5)),
419        });
420
421        let stats = {
422            let data = slice.get_mapped_range();
423            let cells: &[ClusterCell] = bytemuck::cast_slice(&data);
424            compute_stats(cells, active_light_count, fallback_active)
425        };
426        self.stats_staging_buf.unmap();
427        stats
428    }
429
430    /// Update the per-frame `ClusterGridUniform` (screen size, near/far, fallback mode).
431    pub fn write_grid_uniform(&self, queue: &wgpu::Queue, uniform: &ClusterGridUniform) {
432        queue.write_buffer(&self.grid_uniform_buf, 0, bytemuck::cast_slice(&[*uniform]));
433    }
434
435    /// Upload the active-lights view-space data for the build pass. Truncates
436    /// silently if the slice is larger than `MAX_SCENE_LIGHTS`.
437    pub fn write_active_lights(&self, queue: &wgpu::Queue, lights: &[ActiveLightView]) {
438        if lights.is_empty() {
439            return;
440        }
441        let n = lights.len().min(crate::resources::MAX_SCENE_LIGHTS);
442        queue.write_buffer(
443            &self.active_lights_buf,
444            0,
445            bytemuck::cast_slice(&lights[..n]),
446        );
447    }
448
449    /// Encode the per-frame clear + build dispatches. Always runs the clear so
450    /// the cluster grid and global reservation counter return to a known zero
451    /// state; the build is skipped when no active lights survive the CPU cull.
452    pub fn dispatch_frame(&self, encoder: &mut wgpu::CommandEncoder, active_light_count: u32) {
453        {
454            let clear_workgroups = MAX_LIGHT_INDICES.max(CLUSTER_COUNT).div_ceil(64);
455            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
456                label: Some("cluster_clear_pass"),
457                timestamp_writes: None,
458            });
459            pass.set_pipeline(&self.clear_pipeline);
460            pass.set_bind_group(0, &self.clear_bind_group, &[]);
461            pass.dispatch_workgroups(clear_workgroups, 1, 1);
462        }
463        if active_light_count == 0 {
464            return;
465        }
466        {
467            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
468                label: Some("cluster_build_pass"),
469                timestamp_writes: None,
470            });
471            pass.set_pipeline(&self.build_pipeline);
472            pass.set_bind_group(0, &self.build_bind_group, &[]);
473            // One workgroup per cluster cell.
474            pass.dispatch_workgroups(CLUSTER_COUNT, 1, 1);
475        }
476    }
477}
478
479/// Build a `ClusterStats` snapshot from a host-visible copy of the cluster
480/// cell array. Empty cells (`punctual_count == 0`) are excluded from the
481/// median, p99, and mean so they don't drag the signal toward zero on
482/// sparsely-populated grids.
483fn compute_stats(
484    cells: &[ClusterCell],
485    active_light_count: u32,
486    fallback_active: bool,
487) -> ClusterStats {
488    let total_cells = cells.len() as u32;
489    let mut total_index_slots_used: u32 = 0;
490    let mut punctuals: Vec<u32> = Vec::with_capacity(cells.len());
491    let mut max_punctual: u32 = 0;
492    let mut non_empty: u32 = 0;
493    for c in cells {
494        total_index_slots_used = total_index_slots_used.saturating_add(c.count);
495        if c.punctual_count > 0 {
496            non_empty += 1;
497            punctuals.push(c.punctual_count);
498            if c.punctual_count > max_punctual {
499                max_punctual = c.punctual_count;
500            }
501        }
502    }
503    punctuals.sort_unstable();
504
505    let median = if punctuals.is_empty() {
506        0
507    } else {
508        punctuals[punctuals.len() / 2]
509    };
510    let p99 = if punctuals.is_empty() {
511        0
512    } else {
513        let idx = ((punctuals.len() as f32) * 0.99) as usize;
514        punctuals[idx.min(punctuals.len() - 1)]
515    };
516    let mean = if punctuals.is_empty() {
517        0.0
518    } else {
519        let sum: u64 = punctuals.iter().map(|&v| v as u64).sum();
520        (sum as f32) / (punctuals.len() as f32)
521    };
522
523    ClusterStats {
524        total_cells,
525        non_empty_cells: non_empty,
526        max_punctual,
527        median_punctual: median,
528        p99_punctual: p99,
529        mean_punctual: mean,
530        total_index_slots_used,
531        max_index_slots: MAX_LIGHT_INDICES,
532        active_light_count,
533        fallback_active,
534    }
535}