concinnity_core/components/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_core::components::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}