unlit_wgpu/light.rs
1//! The frame's lights, packed for the shaders and clustered on the CPU.
2//!
3//! A forward renderer shades a fragment from the lights that can reach it, and
4//! forward clustered rendering answers "which lights reach this fragment" by
5//! cutting the view into a grid of cells — tiles in screen space, logarithmic
6//! slices in depth — and giving every cell the indices of the lights that touch
7//! it. A fragment then reads exactly one cell.
8//!
9//! The clustering here is plain CPU work over the packed records, producing the
10//! `u32` cell array and the [`LightGpu`] array a draw binds. It needs no compute
11//! shader, which is what lets the same code serve WebGL2: a device with no
12//! compute passes emits the very same bytes. The arrays are uploaded like every
13//! other frame-wide array — a storage buffer where the device has those, texels
14//! where it does not.
15//!
16//! # The grid
17//!
18//! Screen space is cut into cells of about [`ClusterConfig::cell_pixels`] pixels,
19//! and depth into [`ClusterConfig::z_slices`] slices. Perspective slices are
20//! equal in `ln(depth)` after the first slice's fixed boundary; orthographic
21//! ones are linear. Both mappings are monotonic and shared by the CPU assignment and the
22//! fragment lookup; that shared mapping is the one correctness requirement,
23//! because it is what keeps a light and the fragments it lights in one cell.
24//!
25//! # The cell record
26//!
27//! A cell is [`CELL_WORDS`] `u32`s: the count of lights in it, then their
28//! indices, then padding to a whole texel. The indices point into the frame's
29//! [`LightGpu`] array, so a cell costs no light data of its own.
30//!
31//! Lights are ranked by [`PackedLight::importance`] before assignment, so a cell
32//! that overflows drops its weakest light and the first entries of every cell
33//! are its strongest.
34//!
35//! # A light at infinity is not in the grid
36//!
37//! A directional light reaches every fragment, so assigning it to every cell
38//! would spend a slot of each on the same record. Directional lights and the
39//! frame's ambient term live in [`LightingUniform`] instead — one frame-wide
40//! uniform every lit fragment reads before the cell's own lights — and the grid
41//! holds only bounded ones. The uniform carries at most
42//! [`MAX_DIRECTIONAL_LIGHTS`] of them; the rest of a frame's directional lights
43//! are dropped, weakest first, the way a full cell drops its weakest light.
44
45use glam::{Mat4, UVec4, Vec2, Vec3, Vec4};
46
47/// How many `u32`s one cell occupies: its count, its light indices, and the
48/// padding that rounds the record to a whole `Rgba32Float` texel.
49pub const CELL_WORDS: usize = 16;
50
51/// How many lights one cell holds: the record minus its count word.
52pub const LIGHTS_PER_CELL: usize = CELL_WORDS - 1;
53
54/// How many corners a quad light's polygon has.
55///
56/// A horizon clip can add one more, so whoever stores a clipped polygon sizes
57/// its array at one past this.
58pub const QUAD_LIGHT_CORNERS: usize = 4;
59
60/// How many directional lights one frame's [`LightingUniform`] carries.
61///
62/// A directional light reaches every fragment, so the frame-wide uniform holds
63/// them beside the ambient term rather than the grid spending a slot in every
64/// cell on each. A frame with more than this many keeps the strongest ones and
65/// drops the rest; the shader reads every slot and an unused one holds no
66/// colour, so it adds nothing.
67pub const MAX_DIRECTIONAL_LIGHTS: usize = 4;
68
69/// One light as the shaders read it.
70///
71/// Mirrors `light.wesl::LightGpu`; the layout is checked at compile time against
72/// the WGSL storage-buffer alignment rules.
73///
74/// Every bounded kind shares the record, so one array holds the frame's point,
75/// spot and quad lights and a cell needs no more than an index. Which lanes
76/// matter is decided by [`Self::kind`], and the lanes a kind does not use are
77/// zero. A directional light reaches every fragment, so it is not in the grid at
78/// all: it goes in [`LightingUniform`] instead.
79#[repr(C)]
80#[derive(
81 Clone,
82 Copy,
83 Debug,
84 PartialEq,
85 zerocopy_derive::FromBytes,
86 zerocopy_derive::Immutable,
87 zerocopy_derive::IntoBytes,
88 zerocopy_derive::KnownLayout,
89 const_shader_layout::ShaderLayout,
90)]
91pub struct LightGpu {
92 /// `xyz` = world-space position; `w` = the radius clustering culls by, which
93 /// is also the light's range.
94 ///
95 /// The radius bounds every fragment the light can reach, so a cell whose
96 /// sphere it touches is given the light.
97 pub position_radius: Vec4,
98 /// `xyz` = the direction the light shines in, world space; `w` = the cosine
99 /// of a spot light's outer half-angle.
100 ///
101 /// Every light shines along the `-Z` of its own transform, so this is that
102 /// transform applied to the axis.
103 pub direction_outer: Vec4,
104 /// `rgb` = the light's linear colour; `w` = its dimensionless intensity.
105 ///
106 /// Intensity carries no unit: the shader modulates a surface's base colour by
107 /// colour times intensity and normalises nothing, so a caller picks numbers
108 /// that look right rather than physical values.
109 pub color_intensity: Vec4,
110 /// `x` = the cosine of a spot light's inner half-angle; `y` = the reciprocal
111 /// of the gap between the inner and outer cosines, which makes the falloff a
112 /// single multiply. `zw` are zero for every other kind.
113 pub cones: Vec4,
114 /// `x` = [`LightKind`] as `u32`; `y` = the first of the light's polygon
115 /// vertices in the frame's vertex pool; `z` = how many vertices it has;
116 /// `w` is zero.
117 ///
118 /// The polygon vertices live in one frame-wide `vec4` array rather than in
119 /// the record, because how many a light owns varies while every record is the
120 /// same size.
121 pub kind: UVec4,
122}
123
124/// Which of the light record's lanes a light uses.
125///
126/// The discriminants are what the shader compares [`LightGpu::kind`]`x` against,
127/// so they are part of the shader interface rather than an implementation
128/// detail.
129#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
130#[repr(u32)]
131pub enum LightKind {
132 /// A light at infinity: one direction, no falloff, no bounds.
133 Directional = 0,
134 /// A light at a point, fading with distance inside its range.
135 Point = 1,
136 /// A point light narrowed to a cone.
137 Spot = 2,
138 /// A convex planar polygon radiating from one side of its plane.
139 Quad = 3,
140}
141
142/// The sphere clustering culls a light by.
143#[derive(Clone, Copy, Debug, PartialEq)]
144pub struct BoundingSphere {
145 /// World-space center.
146 pub center: Vec3,
147 /// How far from the center the light can still be seen.
148 pub radius: f32,
149}
150
151/// A light packed for the shader, with the facts clustering needs.
152///
153/// Only bounded lights are packed: a light at infinity goes in
154/// [`LightingUniform`] instead, so every record here has a sphere.
155#[derive(Clone, Copy, Debug, PartialEq)]
156pub struct PackedLight {
157 /// The record the shader reads.
158 pub gpu: LightGpu,
159 /// The world-space sphere every fragment the light reaches lies in.
160 pub bounds: BoundingSphere,
161 /// What a full cell ranks by: higher survives.
162 pub importance: f32,
163}
164
165/// How a frame's view is divided into cells.
166#[derive(Clone, Copy, Debug, PartialEq)]
167pub struct ClusterConfig {
168 /// The target pixels one cell covers along x and y.
169 pub cell_pixels: u32,
170 /// How many slices the view's depth is cut into.
171 pub z_slices: u32,
172 /// The most cells the grid may hold, whatever the target size.
173 ///
174 /// A finer grid costs only memory and CPU time — a fragment reads one cell
175 /// however fine the grid is — but the whole grid is uploaded every frame, so
176 /// it is bounded.
177 pub max_clusters: u32,
178 /// The end of the first depth slice.
179 ///
180 /// This is a scene-scale choice, not the camera's near plane. The first
181 /// slice absorbs everything nearer; the remaining slices divide the rest
182 /// of the range logarithmically under a perspective projection.
183 pub first_slice_depth: f32,
184 /// The farthest depth the slices span.
185 pub far: f32,
186}
187
188impl ClusterConfig {
189 /// Cells of about 128 pixels, 8 depth slices, at most 4096 cells. The first
190 /// slice ends at 5 world units and the range ends at 1000.
191 ///
192 /// Both ends are fixed so the grid does not depend on which lights happen
193 /// to be present: a light-only range cannot match the visible surfaces it
194 /// did not measure. A light nearer than the first slice's end lands in that
195 /// slice, which is where a fragment that near looks it up too.
196 pub const DEFAULT: Self = Self {
197 cell_pixels: 128,
198 z_slices: 8,
199 max_clusters: 4096,
200 first_slice_depth: 5.0,
201 far: 1000.0,
202 };
203
204 /// The grid this configuration gives a `size`-pixel target.
205 ///
206 /// The x and y counts come from the target size, then shrink together until
207 /// the grid fits [`Self::max_clusters`], so the cells stay roughly square and
208 /// the grid follows the target's aspect. Depth keeps its slice count, which
209 /// is what a caller sets for the depth range it cares about.
210 pub fn dims(&self, size: (u32, u32)) -> [u32; 3] {
211 let cell_pixels = self.cell_pixels.max(1);
212 let max_clusters = self.max_clusters.max(1);
213 let z = self.z_slices.max(1).min(max_clusters);
214 let budget = (max_clusters / z).max(1);
215 let x = size.0.div_ceil(cell_pixels).max(1);
216 let y = size.1.div_ceil(cell_pixels).max(1);
217 if u64::from(x) * u64::from(y) <= u64::from(budget) {
218 return [x, y, z];
219 }
220 // Shrink to the budget in one step rather than a cell at a time, which
221 // would take as many passes as the grid is wide. The counts keep the
222 // target's aspect, so the cells stay roughly square.
223 let aspect = x as f32 / y as f32;
224 let y = ((budget as f32 / aspect).sqrt().floor() as u32)
225 .max(1)
226 .min(y);
227 let x = (budget / y).min(x).max(1);
228 [x, y, z]
229 }
230
231 /// The uniform the frame's shaders read for a `size`-pixel target.
232 pub fn uniform(&self, size: (u32, u32), perspective: bool) -> ClusterUniform {
233 let dims = self.dims(size);
234 let first = self.first_slice_depth.max(f32::MIN_POSITIVE);
235 let far = self.far.max(first);
236 let depth = if perspective {
237 let ln_first = first.ln();
238 Vec4::new(
239 1.0 / (far.ln() - ln_first).max(f32::MIN_POSITIVE),
240 ln_first,
241 (dims[2] - 1) as f32,
242 first,
243 )
244 } else {
245 Vec4::new(
246 1.0 / (far - first).max(f32::MIN_POSITIVE),
247 first,
248 dims[2] as f32,
249 first,
250 )
251 };
252 ClusterUniform {
253 dims: UVec4::new(dims[0], dims[1], dims[2], LIGHTS_PER_CELL as u32),
254 depth,
255 perspective: UVec4::new(u32::from(perspective), 0, 0, 0),
256 }
257 }
258}
259
260/// The clustering state a frame's shaders read.
261///
262/// Mirrors `light.wesl::ClusterUniform`; the layout is checked at compile time
263/// against the WGSL uniform address-space rules.
264///
265/// The grid dimensions arrive here rather than as shader constants because the
266/// grid follows the target's pixel size, which changes at run time.
267#[repr(C)]
268#[derive(
269 Clone,
270 Copy,
271 Debug,
272 PartialEq,
273 zerocopy_derive::FromBytes,
274 zerocopy_derive::Immutable,
275 zerocopy_derive::IntoBytes,
276 zerocopy_derive::KnownLayout,
277 const_shader_layout::ShaderLayoutCompat,
278)]
279pub struct ClusterUniform {
280 /// `xyz` = the grid's dimensions; `w` = [`LIGHTS_PER_CELL`], the most lights
281 /// one cell holds.
282 pub dims: UVec4,
283 /// Perspective: `x` = `1 / ln(far / first)`, `y` = `ln(first)`, `z` = one
284 /// less than the slice count, `w` = `first`. Orthographic: `x` = the
285 /// reciprocal linear range, `y` = `first`, `z` = the slice count.
286 pub depth: Vec4,
287 /// `x` is nonzero for a perspective projection, zero for an orthographic
288 /// one. The remaining lanes reserve the uniform's 16-byte tail padding.
289 pub perspective: UVec4,
290}
291
292/// One directional light, as the frame-wide uniform carries it.
293///
294/// Mirrors `light.wesl::DirectionalGpu`. A directional light has no position and
295/// no falloff, so its record is only the two facts it needs; it is not a
296/// [`LightGpu`], which every bounded kind shares.
297#[repr(C)]
298#[derive(
299 Clone,
300 Copy,
301 Debug,
302 Default,
303 PartialEq,
304 zerocopy_derive::FromBytes,
305 zerocopy_derive::Immutable,
306 zerocopy_derive::IntoBytes,
307 zerocopy_derive::KnownLayout,
308 const_shader_layout::ShaderLayoutCompat,
309 const_shader_layout::ShaderLayoutCompatArrayElement,
310)]
311pub struct DirectionalGpu {
312 /// `xyz` = the direction the light shines in, world space; `w` is zero.
313 pub direction: Vec4,
314 /// `rgb` = the light's linear colour; `w` = its dimensionless intensity.
315 ///
316 /// An unused slot of [`LightingUniform::directionals`] leaves this at zero,
317 /// which makes the slot contribute nothing however the shader reads it.
318 pub color_intensity: Vec4,
319}
320
321/// The frame-wide lighting every lit fragment reads before its cell's lights.
322///
323/// Mirrors `light.wesl::LightingUniform`; the layout is checked at compile time
324/// against the WGSL uniform address-space rules.
325///
326/// It carries what belongs to the whole frame rather than to a place in it: the
327/// ambient term, and the directional lights, which reach every fragment. The
328/// grid holds only bounded lights, so a cell's slots all name something that
329/// could have been absent from that cell.
330#[repr(C)]
331#[derive(
332 Clone,
333 Copy,
334 Debug,
335 PartialEq,
336 zerocopy_derive::FromBytes,
337 zerocopy_derive::Immutable,
338 zerocopy_derive::IntoBytes,
339 zerocopy_derive::KnownLayout,
340 const_shader_layout::ShaderLayoutCompat,
341)]
342pub struct LightingUniform {
343 /// The frame's directional lights, strongest first; unused slots are zero.
344 pub directionals: [DirectionalGpu; MAX_DIRECTIONAL_LIGHTS],
345 /// `rgb` = the linear colour every surface receives with no light on it;
346 /// `w` is zero.
347 pub ambient: Vec4,
348}
349
350/// One frame's clustered light assignment, in storage the caller reuses.
351#[derive(Clone, Debug, Default, PartialEq, Eq)]
352pub struct LightClusters {
353 /// The cell records, `dims.x * dims.y * dims.z * CELL_WORDS` long.
354 pub cells: Vec<u32>,
355 /// The grid the records were written for.
356 pub dims: [u32; 3],
357}
358
359impl LightClusters {
360 /// Assign `lights` to the cells of `cfg`'s grid for this frame's camera.
361 ///
362 /// `clip_from_view` and `view_from_world` are the camera's two transforms:
363 /// depth and the screen extent come from them, so both are needed. `size` is
364 /// the target's pixel size.
365 ///
366 /// `lights` is ranked by importance in place, so the caller passes them in
367 /// any order and the weakest are what a full cell drops.
368 pub fn update(
369 &mut self,
370 cfg: &ClusterConfig,
371 clip_from_view: &Mat4,
372 view_from_world: &Mat4,
373 size: (u32, u32),
374 lights: &mut [PackedLight],
375 ) {
376 let dims = cfg.dims(size);
377 let cells = dims[0] as usize * dims[1] as usize * dims[2] as usize;
378 self.dims = dims;
379 self.cells.clear();
380 self.cells.resize(cells * CELL_WORDS, 0);
381
382 // Strongest first, so a full cell keeps what matters; the stable sort
383 // leaves the caller's order among equals, which keeps a snapshot
384 // deterministic when two lights rank the same.
385 lights.sort_by(|a, b| b.importance.total_cmp(&a.importance));
386
387 let perspective = clip_from_view.row(3).truncate() != Vec3::ZERO;
388 let uniform = cfg.uniform(size, perspective);
389 let last_slice = (dims[2] - 1) as f32;
390 let slice_of = |depth: f32| -> u32 { depth_slice(depth, &uniform).min(last_slice as u32) };
391 let full = ((0, 0), (dims[0] - 1, dims[1] - 1));
392
393 for (index, light) in lights.iter().enumerate() {
394 let index = index as u32;
395 let center = view_from_world.transform_point3(light.bounds.center);
396 let depth = -center.z;
397 let radius = light.bounds.radius.max(0.0);
398 // A fragment beyond either end of the configured range still reads
399 // the nearest slice. Culling a light at that same end would leave
400 // the fragment looking in a cell the light was never assigned to.
401 if depth + radius <= 0.0 {
402 continue;
403 }
404 let z0 = slice_of(depth - radius);
405 let z1 = slice_of(depth + radius);
406 let Some((lo, hi)) = screen_cells(
407 clip_from_view,
408 perspective,
409 center,
410 depth,
411 radius,
412 dims,
413 full,
414 ) else {
415 continue;
416 };
417 for z in z0..=z1 {
418 for y in lo.1..=hi.1 {
419 let row = (z * dims[1] + y) * dims[0];
420 for x in lo.0..=hi.0 {
421 insert(&mut self.cells, (row + x) as usize, index);
422 }
423 }
424 }
425 }
426 }
427}
428
429/// The depth slice containing `depth`, mirrored by `cluster_depth_slice` in
430/// the built-in shader.
431///
432/// Slice zero absorbs everything before the first boundary. Under a perspective
433/// projection the remaining slices are equal in logarithmic depth; under an
434/// orthographic one they are equal in linear depth.
435pub fn depth_slice(depth: f32, cluster: &ClusterUniform) -> u32 {
436 let last = cluster.dims.z - 1;
437 if depth < cluster.depth.w {
438 return 0;
439 }
440 let coordinate = if cluster.perspective.x != 0 {
441 (depth.ln() - cluster.depth.y) * cluster.depth.x * cluster.depth.z + 1.0
442 } else {
443 (depth - cluster.depth.y) * cluster.depth.x * cluster.depth.z
444 };
445 coordinate.floor().clamp(0.0, last as f32) as u32
446}
447
448/// Append `index` to the cell at `cell`, dropping it when the cell is full.
449fn insert(cells: &mut [u32], cell: usize, index: u32) {
450 let record = &mut cells[cell * CELL_WORDS..(cell + 1) * CELL_WORDS];
451 let count = record[0] as usize;
452 if count < LIGHTS_PER_CELL {
453 record[count + 1] = index;
454 record[0] += 1;
455 }
456}
457
458/// The x and y cells a light's sphere covers, as inclusive ranges.
459///
460/// A perspective sphere is bounded by its two tangent lines on each screen
461/// axis. Those tangents are asymmetric around the projected center for an
462/// off-axis sphere, so a radius around that center can miss its outer edge.
463/// An orthographic projection has no such asymmetry, and is simply scaled.
464fn screen_cells(
465 clip_from_view: &Mat4,
466 perspective: bool,
467 center: Vec3,
468 depth: f32,
469 radius: f32,
470 dims: [u32; 3],
471 full: ((u32, u32), (u32, u32)),
472) -> Option<((u32, u32), (u32, u32))> {
473 let clip_center = *clip_from_view * center.extend(1.0);
474 let w = clip_center.w;
475 if !w.is_finite() || w <= 0.0 {
476 return Some(full);
477 }
478 let ndc_center = Vec2::new(clip_center.x / w, clip_center.y / w);
479 // The rows say how far one world unit moves the projection on each axis,
480 // which is what bounds a sphere when the projection shears: the diagonal
481 // alone would ignore the shear and cut the sphere's edge off.
482 let scale = Vec2::new(
483 clip_from_view.row(0).truncate().length(),
484 clip_from_view.row(1).truncate().length(),
485 );
486 let (low, high) = if perspective {
487 // The eye is inside the sphere, so no tangents bound what it covers.
488 if depth <= radius {
489 return Some(full);
490 }
491 // The two tangents from the eye to the sphere are the extreme
492 // directions on each screen axis: `(axis * depth -+ radius * s) /
493 // (depth^2 - radius^2)`, where `s` is the tangent length. The pair is
494 // already ordered, because the radius term is non-negative, so it does
495 // not depend on which side of the axis the center is on -- a sign taken
496 // from the axis would swap the ends for every light left of or below
497 // the camera and leave its cells empty.
498 let depth_squared = depth * depth - radius * radius;
499 let tangent = |axis: f32, projection_scale: f32| {
500 let radial_part = (axis * axis + depth_squared).sqrt() * radius;
501 let center_part = axis * depth;
502 Vec2::new(center_part - radial_part, center_part + radial_part)
503 * (projection_scale / depth_squared)
504 };
505 let x = tangent(center.x, scale.x);
506 let y = tangent(center.y, scale.y);
507 (Vec2::new(x.x, y.x), Vec2::new(x.y, y.y))
508 } else {
509 let ndc_radius = scale * radius;
510 (ndc_center - ndc_radius, ndc_center + ndc_radius)
511 };
512 if !low.is_finite() || !high.is_finite() {
513 return Some(full);
514 }
515 if high.x < -1.0 || low.x > 1.0 || high.y < -1.0 || low.y > 1.0 {
516 return None;
517 }
518 let cell = |ndc: f32, count: u32| -> u32 {
519 let scaled = (ndc * 0.5 + 0.5) * count as f32;
520 scaled.floor().clamp(0.0, (count - 1) as f32) as u32
521 };
522 Some((
523 (cell(low.x, dims[0]), cell(low.y, dims[1])),
524 (cell(high.x, dims[0]), cell(high.y, dims[1])),
525 ))
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 fn point(position: Vec3, radius: f32, importance: f32) -> PackedLight {
533 let gpu = LightGpu {
534 position_radius: position.extend(radius),
535 direction_outer: Vec4::ZERO,
536 color_intensity: Vec4::ONE,
537 cones: Vec4::ZERO,
538 kind: UVec4::new(LightKind::Point as u32, 0, 0, 0),
539 };
540 PackedLight {
541 gpu,
542 bounds: BoundingSphere {
543 center: position,
544 radius,
545 },
546 importance,
547 }
548 }
549
550 fn view(distance: f32) -> Mat4 {
551 glam::camera::rh::view::look_at_mat4(Vec3::ZERO, Vec3::NEG_Z * distance, Vec3::Y)
552 }
553
554 #[test]
555 fn dims_follow_the_target_size() {
556 let cfg = ClusterConfig::DEFAULT;
557 assert_eq!(cfg.dims((1280, 720)), [10, 6, 8]);
558 assert_eq!(cfg.dims((1, 1)), [1, 1, 8]);
559 assert_eq!(cfg.dims((0, 0)), [1, 1, 8]);
560 }
561
562 #[test]
563 fn dims_respect_the_cluster_budget() {
564 let cfg = ClusterConfig {
565 max_clusters: 64,
566 ..ClusterConfig::DEFAULT
567 };
568 let dims = cfg.dims((7680, 4320));
569 assert_eq!(dims[2], 8);
570 assert!(dims[0] * dims[1] * dims[2] <= 64, "{dims:?}");
571 }
572
573 #[test]
574 fn the_uniform_and_the_assignment_agree_on_the_depth_slice() {
575 let cfg = ClusterConfig::DEFAULT;
576 let dims = cfg.dims((1280, 720));
577 let uniform = cfg.uniform((1280, 720), true);
578 let first = cfg.first_slice_depth;
579 assert_eq!(depth_slice(0.0, &uniform), 0);
580 assert_eq!(depth_slice(first * 0.5, &uniform), 0);
581 assert_eq!(depth_slice(first, &uniform), 1);
582 assert_eq!(depth_slice(cfg.far, &uniform), dims[2] - 1);
583 // After the absorbing first slice, the remaining seven are equal in
584 // log space. Probe just inside each boundary so one ulp in the multiply
585 // cannot decide which side of an exact edge the assertion samples.
586 for slice in 2..dims[2] {
587 let fraction = (slice - 1) as f32 / (dims[2] - 1) as f32;
588 let edge = first * (cfg.far / first).ln().mul_add(fraction, 0.0).exp();
589 assert_eq!(depth_slice(edge * (1.0 + 1e-4), &uniform), slice);
590 }
591
592 let orthographic = cfg.uniform((1280, 720), false);
593 for slice in 1..dims[2] {
594 let edge = first + (cfg.far - first) * slice as f32 / dims[2] as f32;
595 assert_eq!(depth_slice(edge + 1e-3, &orthographic), slice);
596 }
597 }
598
599 #[test]
600 fn a_point_light_lands_only_in_the_cells_it_reaches() {
601 let cfg = ClusterConfig::DEFAULT;
602 let size = (1280, 720);
603 let dims = cfg.dims(size);
604 let mut clusters = LightClusters::default();
605 let mut lights = [point(Vec3::new(0.0, 0.0, -20.0), 1.0, 1.0)];
606 clusters.update(
607 &cfg,
608 &glam::camera::rh::proj::directx::perspective(1.0, 16.0 / 9.0, 0.1, 1000.0),
609 &view(20.0),
610 size,
611 &mut lights,
612 );
613
614 let total = dims[0] as usize * dims[1] as usize * dims[2] as usize;
615 let mut filled = 0;
616 for cell in 0..total {
617 let record = &clusters.cells[cell * CELL_WORDS..(cell + 1) * CELL_WORDS];
618 assert!(record[0] <= LIGHTS_PER_CELL as u32);
619 if record[0] == 1 {
620 assert_eq!(record[1], 0);
621 filled += 1;
622 } else {
623 assert_eq!(record[0], 0);
624 }
625 }
626 // A one-unit sphere at the center of the screen is a small patch, not
627 // the whole grid.
628 assert!(filled > 0, "the light was culled from every cell");
629 assert!(filled < total / 4, "{filled} of {total} cells");
630 assert_eq!(clusters.dims, dims);
631 }
632
633 #[test]
634 fn a_near_light_reaches_the_first_slice_and_an_offscreen_light_reaches_none() {
635 let cfg = ClusterConfig::DEFAULT;
636 let projection = glam::camera::rh::proj::directx::perspective(1.0, 1.0, 0.1, 1000.0);
637 let mut near = [point(Vec3::new(0.0, 0.0, -0.25), 0.1, 1.0)];
638 let mut clusters = LightClusters::default();
639 clusters.update(&cfg, &projection, &Mat4::IDENTITY, (128, 128), &mut near);
640 assert!(
641 clusters
642 .cells
643 .as_chunks::<CELL_WORDS>()
644 .0
645 .iter()
646 .any(|cell| cell[0] == 1)
647 );
648
649 let mut offscreen = [point(Vec3::new(100.0, 0.0, -20.0), 1.0, 1.0)];
650 clusters.update(
651 &cfg,
652 &projection,
653 &Mat4::IDENTITY,
654 (128, 128),
655 &mut offscreen,
656 );
657 assert!(
658 clusters
659 .cells
660 .as_chunks::<CELL_WORDS>()
661 .0
662 .iter()
663 .all(|cell| cell[0] == 0)
664 );
665 }
666
667 #[test]
668 fn a_centered_sphere_keeps_its_circular_projection() {
669 let projection = glam::camera::rh::proj::directx::perspective(1.0, 1.0, 0.1, 1000.0);
670 let (lo, hi) = screen_cells(
671 &projection,
672 true,
673 Vec3::new(0.0, 0.0, -10.0),
674 10.0,
675 1.0,
676 [8, 8, 8],
677 ((0, 0), (7, 7)),
678 )
679 .expect("the sphere overlaps the screen");
680 // The exact tangents are NDC +/-1/sqrt(100-1), just inside cells 3/4.
681 assert_eq!((lo.0, hi.0), (3, 4));
682 assert_eq!((lo.1, hi.1), (3, 4));
683 }
684
685 #[test]
686 fn a_sphere_left_of_the_axis_keeps_its_cells() {
687 let projection = glam::camera::rh::proj::directx::perspective(1.0, 1.0, 0.1, 1000.0);
688 let dims = [8, 8, 8];
689 let full = ((0, 0), (7, 7));
690 // The mirror of the off-axis case: the tangents stay ordered, so the
691 // cells are the mirrored pair rather than an empty range.
692 let (lo, hi) = screen_cells(
693 &projection,
694 true,
695 Vec3::new(-5.0, 0.0, -10.0),
696 10.0,
697 1.0,
698 dims,
699 full,
700 )
701 .expect("the tangent bounds overlap the screen");
702 assert_eq!((lo.0, hi.0), (0, 1));
703 assert!(lo.1 <= hi.1);
704 }
705
706 #[test]
707 fn an_off_axis_sphere_keeps_its_outer_tangent_cell() {
708 let projection = glam::camera::rh::proj::directx::perspective(1.0, 1.0, 0.1, 1000.0);
709 let dims = [8, 8, 8];
710 let full = ((0, 0), (7, 7));
711 let center = Vec3::new(5.0, 0.0, -10.0);
712 let (lo, hi) = screen_cells(&projection, true, center, 10.0, 1.0, dims, full)
713 .expect("the tangent bounds overlap the screen");
714 // The exact tangents reach NDC x = 0.3900 and 0.6150; the old disc
715 // around the center's NDC x = 0.5 ended at 0.6005 and missed cell 7.
716 assert_eq!(lo.0, 6);
717 assert_eq!(hi.0, 7);
718 assert!(hi.1 >= lo.1);
719 }
720
721 #[test]
722 fn a_directional_light_never_enters_a_cell() {
723 // Only bounded lights are packed, so the grid's records are all of
724 // something that could have been absent from a cell. The uniform is
725 // built from the frame's directional lights separately.
726 assert_eq!(MAX_DIRECTIONAL_LIGHTS, 4);
727 let lighting = LightingUniform {
728 directionals: [DirectionalGpu::default(); MAX_DIRECTIONAL_LIGHTS],
729 ambient: Vec4::new(0.1, 0.2, 0.3, 0.0),
730 };
731 // An empty slot contributes nothing: its colour is zero, so the term it
732 // adds to every fragment is too.
733 assert_eq!(lighting.directionals[0].color_intensity, Vec4::ZERO);
734 }
735
736 #[test]
737 fn a_full_cell_keeps_the_stronger_lights() {
738 let mut cells = vec![0u32; CELL_WORDS];
739 for index in 0..LIGHTS_PER_CELL as u32 + 2 {
740 insert(&mut cells, 0, index);
741 }
742 // The record is the count word followed by the indices, nothing else:
743 // the lights past the cap are the ones the cell had no room for.
744 assert_eq!(cells[0], LIGHTS_PER_CELL as u32);
745 assert_eq!(
746 &cells[1..=LIGHTS_PER_CELL],
747 &(0..LIGHTS_PER_CELL as u32).collect::<Vec<_>>()[..]
748 );
749 }
750}