Skip to main content

viewport_lib/resources/
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 `ViewportGpuResources`.
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 = device.create_shader_module(wgpu::ShaderModuleDescriptor {
308            label: Some("cluster_clear_shader"),
309            source: wgpu::ShaderSource::Wgsl(
310                include_str!(concat!(env!("OUT_DIR"), "/cluster_clear.wgsl")).into(),
311            ),
312        });
313        let clear_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
314            label: Some("cluster_clear_pipeline_layout"),
315            bind_group_layouts: &[&clear_bgl],
316            push_constant_ranges: &[],
317        });
318        let clear_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
319            label: Some("cluster_clear_pipeline"),
320            layout: Some(&clear_layout),
321            module: &clear_shader,
322            entry_point: Some("main"),
323            compilation_options: wgpu::PipelineCompilationOptions::default(),
324            cache: None,
325        });
326
327        // Build pass : intersects each cluster's view-space AABB with the
328        // active-light set and writes the per-cluster light index ranges.
329        let build_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
330            label: Some("cluster_build_bgl"),
331            entries: &[
332                storage_entry(0, false), // cluster_grid
333                storage_entry(1, false), // light_indices
334                storage_entry(2, false), // global_offset_counter
335                uniform_entry(3),        // GridUniform
336                storage_entry(4, true),  // active_lights
337            ],
338        });
339        let build_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
340            label: Some("cluster_build_bind_group"),
341            layout: &build_bgl,
342            entries: &[
343                wgpu::BindGroupEntry {
344                    binding: 0,
345                    resource: cluster_grid_buf.as_entire_binding(),
346                },
347                wgpu::BindGroupEntry {
348                    binding: 1,
349                    resource: light_index_buf.as_entire_binding(),
350                },
351                wgpu::BindGroupEntry {
352                    binding: 2,
353                    resource: global_offset_buf.as_entire_binding(),
354                },
355                wgpu::BindGroupEntry {
356                    binding: 3,
357                    resource: grid_uniform_buf.as_entire_binding(),
358                },
359                wgpu::BindGroupEntry {
360                    binding: 4,
361                    resource: active_lights_buf.as_entire_binding(),
362                },
363            ],
364        });
365        let build_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
366            label: Some("cluster_build_shader"),
367            source: wgpu::ShaderSource::Wgsl(
368                include_str!(concat!(env!("OUT_DIR"), "/cluster_build.wgsl")).into(),
369            ),
370        });
371        let build_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
372            label: Some("cluster_build_pipeline_layout"),
373            bind_group_layouts: &[&build_bgl],
374            push_constant_ranges: &[],
375        });
376        let build_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
377            label: Some("cluster_build_pipeline"),
378            layout: Some(&build_layout),
379            module: &build_shader,
380            entry_point: Some("main"),
381            compilation_options: wgpu::PipelineCompilationOptions::default(),
382            cache: None,
383        });
384
385        Self {
386            grid_uniform_buf,
387            cluster_grid_buf,
388            light_index_buf,
389            active_lights_buf,
390            global_offset_buf,
391            stats_staging_buf,
392            clear_bind_group,
393            clear_pipeline,
394            build_bind_group,
395            build_pipeline,
396            clear_params_buf,
397        }
398    }
399
400    /// Copy `cluster_grid_buf` to host-readable memory, map it, and compute
401    /// `ClusterStats`. Blocks on a device poll while the GPU finishes the
402    /// copy, so this should be called sparingly : it's a debug-path readback
403    /// behind a host-controlled toggle, not a per-frame operation.
404    pub fn read_stats(
405        &self,
406        device: &wgpu::Device,
407        queue: &wgpu::Queue,
408        active_light_count: u32,
409        fallback_active: bool,
410    ) -> ClusterStats {
411        let bytes = (CLUSTER_COUNT as u64) * std::mem::size_of::<ClusterCell>() as u64;
412        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
413            label: Some("cluster_stats_copy_encoder"),
414        });
415        encoder.copy_buffer_to_buffer(&self.cluster_grid_buf, 0, &self.stats_staging_buf, 0, bytes);
416        queue.submit(std::iter::once(encoder.finish()));
417
418        let slice = self.stats_staging_buf.slice(..);
419        slice.map_async(wgpu::MapMode::Read, |_| {});
420        let _ = device.poll(wgpu::PollType::Wait {
421            submission_index: None,
422            timeout: Some(std::time::Duration::from_secs(5)),
423        });
424
425        let stats = {
426            let data = slice.get_mapped_range();
427            let cells: &[ClusterCell] = bytemuck::cast_slice(&data);
428            compute_stats(cells, active_light_count, fallback_active)
429        };
430        self.stats_staging_buf.unmap();
431        stats
432    }
433
434    /// Update the per-frame `ClusterGridUniform` (screen size, near/far, fallback mode).
435    pub fn write_grid_uniform(&self, queue: &wgpu::Queue, uniform: &ClusterGridUniform) {
436        queue.write_buffer(&self.grid_uniform_buf, 0, bytemuck::cast_slice(&[*uniform]));
437    }
438
439    /// Upload the active-lights view-space data for the build pass. Truncates
440    /// silently if the slice is larger than `MAX_SCENE_LIGHTS`.
441    pub fn write_active_lights(&self, queue: &wgpu::Queue, lights: &[ActiveLightView]) {
442        if lights.is_empty() {
443            return;
444        }
445        let n = lights.len().min(crate::resources::MAX_SCENE_LIGHTS);
446        queue.write_buffer(
447            &self.active_lights_buf,
448            0,
449            bytemuck::cast_slice(&lights[..n]),
450        );
451    }
452
453    /// Encode the per-frame clear + build dispatches. Always runs the clear so
454    /// the cluster grid and global reservation counter return to a known zero
455    /// state; the build is skipped when no active lights survive the CPU cull.
456    pub fn dispatch_frame(&self, encoder: &mut wgpu::CommandEncoder, active_light_count: u32) {
457        {
458            let clear_workgroups = MAX_LIGHT_INDICES.max(CLUSTER_COUNT).div_ceil(64);
459            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
460                label: Some("cluster_clear_pass"),
461                timestamp_writes: None,
462            });
463            pass.set_pipeline(&self.clear_pipeline);
464            pass.set_bind_group(0, &self.clear_bind_group, &[]);
465            pass.dispatch_workgroups(clear_workgroups, 1, 1);
466        }
467        if active_light_count == 0 {
468            return;
469        }
470        {
471            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
472                label: Some("cluster_build_pass"),
473                timestamp_writes: None,
474            });
475            pass.set_pipeline(&self.build_pipeline);
476            pass.set_bind_group(0, &self.build_bind_group, &[]);
477            // One workgroup per cluster cell.
478            pass.dispatch_workgroups(CLUSTER_COUNT, 1, 1);
479        }
480    }
481}
482
483/// Build a `ClusterStats` snapshot from a host-visible copy of the cluster
484/// cell array. Empty cells (`punctual_count == 0`) are excluded from the
485/// median, p99, and mean so they don't drag the signal toward zero on
486/// sparsely-populated grids.
487fn compute_stats(
488    cells: &[ClusterCell],
489    active_light_count: u32,
490    fallback_active: bool,
491) -> ClusterStats {
492    let total_cells = cells.len() as u32;
493    let mut total_index_slots_used: u32 = 0;
494    let mut punctuals: Vec<u32> = Vec::with_capacity(cells.len());
495    let mut max_punctual: u32 = 0;
496    let mut non_empty: u32 = 0;
497    for c in cells {
498        total_index_slots_used = total_index_slots_used.saturating_add(c.count);
499        if c.punctual_count > 0 {
500            non_empty += 1;
501            punctuals.push(c.punctual_count);
502            if c.punctual_count > max_punctual {
503                max_punctual = c.punctual_count;
504            }
505        }
506    }
507    punctuals.sort_unstable();
508
509    let median = if punctuals.is_empty() {
510        0
511    } else {
512        punctuals[punctuals.len() / 2]
513    };
514    let p99 = if punctuals.is_empty() {
515        0
516    } else {
517        let idx = ((punctuals.len() as f32) * 0.99) as usize;
518        punctuals[idx.min(punctuals.len() - 1)]
519    };
520    let mean = if punctuals.is_empty() {
521        0.0
522    } else {
523        let sum: u64 = punctuals.iter().map(|&v| v as u64).sum();
524        (sum as f32) / (punctuals.len() as f32)
525    };
526
527    ClusterStats {
528        total_cells,
529        non_empty_cells: non_empty,
530        max_punctual,
531        median_punctual: median,
532        p99_punctual: p99,
533        mean_punctual: mean,
534        total_index_slots_used,
535        max_index_slots: MAX_LIGHT_INDICES,
536        active_light_count,
537        fallback_active,
538    }
539}