Skip to main content

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}