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