Skip to main content

concinnity_asset/
spot_light.rs

1// Spot-light schema.
2
3/// A cone-shaped local light: a point light restricted to the cone around
4/// `direction`, with a soft edge between `inner_angle` and `outer_angle`.
5///
6/// Distance attenuation matches [PointLight](#pointlight); the cone adds an
7/// angular falloff that is full brightness inside the inner cone and fades to
8/// black at the outer cone. Spot lights share the same per-scene local-light
9/// budget as point lights and are culled by the same clustered pass. Secondary
10/// effects (volumetric fog, SDF raymarching, and reflection-probe capture) do
11/// not consider them.
12///
13/// ```rust
14/// # use concinnity_asset::SpotLight;
15/// SpotLight {
16///     position: [0.0, 4.0, -2.0],
17///     direction: [0.0, -1.0, 0.0],
18///     color: [1.0, 0.9, 0.7],
19///     intensity: 20.0,
20///     range: 10.0,
21///     inner_angle: 18.0,
22///     ..Default::default()
23/// };
24/// ```
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26#[serde(default)]
27pub struct SpotLight {
28    /// World-space position of the light source.
29    pub position: [f32; 3],
30    /// Direction the cone points, away from the light. Does not need to be
31    /// normalised; defaults to straight down when degenerate.
32    pub direction: [f32; 3],
33    /// Linear-space RGB colour of the light.
34    pub color: [f32; 3],
35    /// Intensity multiplier applied to the colour.
36    pub intensity: f32,
37    /// Maximum reach in world units; attenuation is zero at this distance.
38    pub range: f32,
39    /// Half-angle in degrees of the fully lit inner cone. Clamped to
40    /// `outer_angle`.
41    pub inner_angle: f32,
42    /// Half-angle in degrees at which the cone fades to black. Clamped to
43    /// (0, 89.9].
44    pub outer_angle: f32,
45    /// Whether this light casts shadows. Shadowed spots claim one slice of the
46    /// spot shadow map in declaration order; once the slices are used up the
47    /// remaining spots still light the scene but cast nothing.
48    pub cast_shadows: bool,
49}
50
51impl Default for SpotLight {
52    fn default() -> Self {
53        Self {
54            position: [0.0, 4.0, 0.0],
55            direction: [0.0, -1.0, 0.0],
56            color: [1.0, 1.0, 1.0],
57            intensity: 20.0,
58            range: 10.0,
59            inner_angle: 18.0,
60            outer_angle: 30.0,
61            cast_shadows: true,
62        }
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn the_default_spot_points_down_with_a_soft_edged_cone() {
72        let l = SpotLight::default();
73        assert_eq!(l.position, [0.0, 4.0, 0.0]);
74        assert_eq!(l.direction, [0.0, -1.0, 0.0]);
75        // The inner cone is narrower than the outer one, so the falloff band
76        // exists and the edge is not a hard circle.
77        assert!(l.inner_angle < l.outer_angle);
78        assert_eq!(l.intensity, 20.0);
79        assert_eq!(l.range, 10.0);
80        assert!(l.cast_shadows);
81    }
82
83    #[test]
84    fn an_authored_spot_parses_and_round_trips_through_postcard() {
85        let l: SpotLight = serde_json::from_str(
86            r#"{"position":[2,3,-1],"direction":[0,-1,0.5],"color":[1,0.9,0.7],
87                "intensity":45,"range":18,"inner_angle":10,"outer_angle":25,
88                "cast_shadows":false}"#,
89        )
90        .unwrap();
91        assert!(!l.cast_shadows);
92
93        let bytes = postcard::to_allocvec(&l).unwrap();
94        let back: SpotLight = postcard::from_bytes(&bytes).unwrap();
95        assert_eq!(back.position, [2.0, 3.0, -1.0]);
96        assert_eq!(back.direction, [0.0, -1.0, 0.5]);
97        assert_eq!(back.color, [1.0, 0.9, 0.7]);
98        assert_eq!(back.intensity, 45.0);
99        assert_eq!(back.range, 18.0);
100        assert_eq!((back.inner_angle, back.outer_angle), (10.0, 25.0));
101    }
102}