Skip to main content

concinnity_render/
area_light.rs

1//! Packs authored `RectAreaLight`s into the per-scene `AreaLightData` table the
2//! forward pass reads alongside the `GpuLight` buffer.
3//!
4//! The GpuLight record carries the panel's centre (`position`), emitting
5//! direction (`direction`), colour, intensity, and range; only the two in-plane
6//! edge vectors and the sidedness flag need the parallel table, indexed by
7//! `GpuLight.data_index`. The edge vectors are pre-scaled by the half-extents, so
8//! the shader reconstructs the four corners as `centre +/- right +/- up` without
9//! needing the sizes separately.
10//!
11//! The tangent frame comes from `geometry::glass_quad::plane_basis`, shared with
12//! the glass-panel quad builder, so a panel and an area light with the same
13//! normal agree on which way is "across".
14
15use crate::components::RectAreaLight;
16use crate::geometry::glass_quad::plane_basis;
17use crate::render_types::{AreaLightData, MAX_AREA_LIGHTS};
18use alloc::vec;
19use alloc::vec::Vec;
20
21// Per-rect table index: `indices[i]` is the `AreaLightData` slot rect `i` owns,
22// or -1 once the table is full. The value is what `GpuLight.data_index` carries.
23pub(crate) fn assign_area_light_slots(rect_lights: &[RectAreaLight]) -> Vec<i32> {
24    if rect_lights.len() > MAX_AREA_LIGHTS {
25        tracing::warn!(
26            "GraphicsSystem: {} area lights declared; only {} are supported -- extras ignored",
27            rect_lights.len(),
28            MAX_AREA_LIGHTS
29        );
30    }
31    (0..rect_lights.len())
32        .map(|i| if i < MAX_AREA_LIGHTS { i as i32 } else { -1 })
33        .collect()
34}
35
36// The `AreaLightData` for each assigned slot, ordered by slot index.
37pub(crate) fn build_area_light_data(
38    rect_lights: &[RectAreaLight],
39    slots: &[i32],
40) -> Vec<AreaLightData> {
41    let mut out = vec![AreaLightData::ZERO; count_area_lights(slots)];
42    for (light, &slot) in rect_lights.iter().zip(slots) {
43        if slot >= 0 {
44            out[slot as usize] = area_light_data(light);
45        }
46    }
47    out
48}
49
50// How many slots `assign_area_light_slots` handed out.
51pub(crate) fn count_area_lights(slots: &[i32]) -> usize {
52    slots.iter().filter(|s| **s >= 0).count()
53}
54
55// One rect's edge vectors, pre-scaled by its half-extents. The authored normal
56// is already unit length and the half-extents already positive (the
57// `rect_area_light` validator guarantees both), so no re-clamping here.
58fn area_light_data(light: &RectAreaLight) -> AreaLightData {
59    let (tangent, bitangent) = plane_basis(light.normal);
60    let hw = light.half_size[0];
61    let hh = light.half_size[1];
62    AreaLightData {
63        right: [tangent[0] * hw, tangent[1] * hw, tangent[2] * hw],
64        two_sided: u32::from(light.two_sided),
65        up: [bitangent[0] * hh, bitangent[1] * hh, bitangent[2] * hh],
66        _pad: 0.0,
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use concinnity_core::math::vec3::{dot, length};
74
75    fn rect(normal: [f32; 3], half_size: [f32; 2]) -> RectAreaLight {
76        RectAreaLight {
77            normal,
78            half_size,
79            ..RectAreaLight::default()
80        }
81    }
82
83    #[test]
84    fn slots_are_handed_out_in_declaration_order() {
85        let lights = vec![rect([0.0, 0.0, 1.0], [1.0, 1.0]); 3];
86        assert_eq!(assign_area_light_slots(&lights), vec![0, 1, 2]);
87    }
88
89    #[test]
90    fn slots_past_the_cap_are_dropped() {
91        let lights = vec![rect([0.0, 0.0, 1.0], [1.0, 1.0]); MAX_AREA_LIGHTS + 2];
92        let slots = assign_area_light_slots(&lights);
93        assert_eq!(count_area_lights(&slots), MAX_AREA_LIGHTS);
94        assert!(slots[MAX_AREA_LIGHTS..].iter().all(|s| *s == -1));
95    }
96
97    // The edge vectors carry the half-extents, so the shader can rebuild the
98    // corners without the sizes.
99    #[test]
100    fn edge_vectors_are_scaled_by_the_half_extents() {
101        let d = area_light_data(&rect([0.0, 0.0, 1.0], [3.0, 0.5]));
102        assert!((length(d.right) - 3.0).abs() < 1e-5);
103        assert!((length(d.up) - 0.5).abs() < 1e-5);
104    }
105
106    // The two edges and the normal must stay mutually perpendicular, or the
107    // reconstructed quad is skewed.
108    #[test]
109    fn the_edge_frame_stays_orthogonal_for_any_normal() {
110        for n in [
111            [0.0, 0.0, 1.0],
112            [0.0, -1.0, 0.0],
113            [0.0, 1.0, 0.0],
114            [0.577, 0.577, 0.577],
115            [-0.3, 0.9, 0.31],
116        ] {
117            let len = length(n);
118            let unit = [n[0] / len, n[1] / len, n[2] / len];
119            let d = area_light_data(&rect(unit, [2.0, 2.0]));
120            assert!(
121                dot(d.right, d.up).abs() < 1e-4,
122                "edges perpendicular: {n:?}"
123            );
124            assert!(dot(d.right, unit).abs() < 1e-4, "right in plane: {n:?}");
125            assert!(dot(d.up, unit).abs() < 1e-4, "up in plane: {n:?}");
126            assert!(d.right.iter().chain(&d.up).all(|v| v.is_finite()));
127        }
128    }
129
130    #[test]
131    fn two_sided_flag_is_carried() {
132        let mut l = rect([0.0, 0.0, 1.0], [1.0, 1.0]);
133        assert_eq!(area_light_data(&l).two_sided, 0);
134        l.two_sided = true;
135        assert_eq!(area_light_data(&l).two_sided, 1);
136    }
137
138    #[test]
139    fn data_is_indexed_by_slot() {
140        let lights = vec![
141            rect([0.0, 0.0, 1.0], [5.0, 1.0]),
142            rect([0.0, 0.0, 1.0], [1.0, 7.0]),
143        ];
144        let slots = assign_area_light_slots(&lights);
145        let data = build_area_light_data(&lights, &slots);
146        assert_eq!(data.len(), 2);
147        assert!((length(data[0].right) - 5.0).abs() < 1e-5);
148        assert!((length(data[1].up) - 7.0).abs() < 1e-5);
149    }
150}