src/light.rs 8.7 K · 277 lines · raw · history

1 use bevy::prelude::*;
2
3 use crate::{
4 climate::snow_cover,
5 sim::{SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day},
6 structures::{Structure, StructureKind},
7 world::{TilePos, WorldMap},
8 };
9
10 pub struct LightPlugin;
11
12 impl Plugin for LightPlugin {
13 fn build(&self, app: &mut App) {
14 app.init_resource::<LightMap>()
15 .add_systems(FixedUpdate, update_light_map.in_set(SimSystems::Decide));
16 }
17 }
18
19 pub const LIT_BRIGHTNESS: f32 = 0.5;
20 pub const LIGHT_LOOKAHEAD_TICKS: u64 = TICKS_PER_HOUR * 3 / 2;
21 const AMBIENT_STEPS: f32 = 16.0;
22 const DAY_COLOR: Vec3 = Vec3::ONE;
23 const NIGHT_COLOR: Vec3 = Vec3::new(0.2, 0.24, 0.4);
24 pub const ROOF_DAYLIGHT: f32 = 0.3;
25 const SNOW_STEPS: f32 = 8.0;
26
27 pub fn daylight(hour: f32) -> f32 {
28 match hour {
29 h if h < 5.0 => 0.0,
30 h if h < 7.0 => (h - 5.0) / 2.0,
31 h if h < 18.0 => 1.0,
32 h if h < 20.0 => 1.0 - (h - 18.0) / 2.0,
33 _ => 0.0,
34 }
35 }
36
37 pub fn ambient_color(daylight: f32) -> Vec3 {
38 NIGHT_COLOR.lerp(DAY_COLOR, daylight)
39 }
40
41 pub fn brightness(color: Vec3) -> f32 {
42 (color.x + color.y + color.z) / 3.0
43 }
44
45 pub fn dark_soon(tick: SimTick) -> bool {
46 let later = SimTick(tick.0 + LIGHT_LOOKAHEAD_TICKS);
47 daylight(hour_of_day(tick)) < LIT_BRIGHTNESS || daylight(hour_of_day(later)) < LIT_BRIGHTNESS
48 }
49
50 pub fn light_at(kind: StructureKind, source: UVec2, tile: UVec2) -> Vec3 {
51 let Some((radius, color)) = kind.light() else {
52 return Vec3::ZERO;
53 };
54 let d = source.as_vec2().distance(tile.as_vec2()) / radius;
55 if d >= 1.0 {
56 return Vec3::ZERO;
57 }
58 color * (1.0 - d * d).powi(2)
59 }
60
61 pub fn line_of_sight(map: &WorldMap, from: UVec2, to: UVec2) -> bool {
62 let (a, b) = (from.as_vec2(), to.as_vec2());
63 let steps = (b - a).abs().max_element().ceil() as i32;
64 (1..steps).all(|i| {
65 let p = a.lerp(b, i as f32 / steps as f32).round().as_ivec2();
66 !map.is_wall(p)
67 })
68 }
69
70 pub fn light_from(tile: UVec2, sources: impl IntoIterator<Item = (UVec2, StructureKind)>) -> Vec3 {
71 sources
72 .into_iter()
73 .map(|(pos, kind)| light_at(kind, pos, tile))
74 .sum()
75 }
76
77 #[derive(Resource, Default, Debug, Clone, PartialEq)]
78 pub struct LightMap {
79 size: UVec2,
80 ambient_step: u32,
81 ambient: Vec3,
82 indoor_ambient: Vec3,
83 sources: Vec<Vec3>,
84 roofed: Vec<bool>,
85 snow_step: u32,
86 }
87
88 impl LightMap {
89 pub fn color(&self, tile: UVec2) -> Vec3 {
90 if self.sources.is_empty() || tile.x >= self.size.x || tile.y >= self.size.y {
91 return Vec3::ONE;
92 }
93 let index = (tile.y * self.size.x + tile.x) as usize;
94 let ambient = if self.roofed[index] {
95 self.indoor_ambient
96 } else {
97 self.ambient
98 };
99 (ambient + self.sources[index]).min(Vec3::ONE)
100 }
101
102 pub fn snow(&self) -> f32 {
103 self.snow_step as f32 / SNOW_STEPS
104 }
105
106 pub fn with_snow(mut self, cover: f32) -> Self {
107 self.snow_step = (cover * SNOW_STEPS).round() as u32;
108 self
109 }
110
111 pub fn is_lit(&self, tile: UVec2) -> bool {
112 brightness(self.color(tile)) >= LIT_BRIGHTNESS
113 }
114
115 pub fn compute(
116 map: &WorldMap,
117 daylight: f32,
118 sources: impl IntoIterator<Item = (UVec2, StructureKind)>,
119 ) -> Self {
120 let size = map.size;
121 let ambient_step = (daylight * AMBIENT_STEPS).round() as u32;
122 let mut light = vec![Vec3::ZERO; size.element_product() as usize];
123 for (pos, kind) in sources {
124 let Some((radius, _)) = kind.light() else {
125 continue;
126 };
127 let r = radius.ceil() as i32;
128 for dy in -r..=r {
129 for dx in -r..=r {
130 let tile = pos.as_ivec2() + IVec2::new(dx, dy);
131 if tile.x < 0
132 || tile.y < 0
133 || tile.x >= size.x as i32
134 || tile.y >= size.y as i32
135 {
136 continue;
137 }
138 let tile = tile.as_uvec2();
139 if line_of_sight(map, pos, tile) {
140 light[(tile.y * size.x + tile.x) as usize] += light_at(kind, pos, tile);
141 }
142 }
143 }
144 }
145 let day = ambient_step as f32 / AMBIENT_STEPS;
146 Self {
147 size,
148 ambient_step,
149 ambient: ambient_color(day),
150 indoor_ambient: ambient_color(day * ROOF_DAYLIGHT),
151 sources: light,
152 roofed: map
153 .positions()
154 .map(|p| map.is_roofed(p.as_ivec2()))
155 .collect(),
156 snow_step: 0,
157 }
158 }
159 }
160
161 fn update_light_map(
162 tick: Res<SimTick>,
163 map: Res<WorldMap>,
164 structures: Query<(&TilePos, &Structure)>,
165 added: Query<(), Added<Structure>>,
166 mut removed: RemovedComponents<Structure>,
167 mut light: ResMut<LightMap>,
168 mut shape: Local<u64>,
169 ) {
170 let day = daylight(hour_of_day(*tick));
171 let step = (day * AMBIENT_STEPS).round() as u32;
172 let reshaped = map.shape_version() != *shape;
173 *shape = map.shape_version();
174 let sources_changed = !added.is_empty() || removed.read().count() > 0 || reshaped;
175 let snow = LightMap::default().with_snow(snow_cover(*tick)).snow_step;
176 if !sources_changed
177 && step == light.ambient_step
178 && snow == light.snow_step
179 && light.size == map.size
180 {
181 return;
182 }
183 let fresh = LightMap::compute(&map, day, structures.iter().map(|(pos, s)| (pos.0, s.kind)))
184 .with_snow(snow_cover(*tick));
185 if fresh != *light {
186 *light = fresh;
187 }
188 }
189
190 #[cfg(test)]
191 mod tests {
192 use super::*;
193 use crate::{sim::TICKS_PER_HOUR, world::Terrain};
194
195 #[test]
196 fn days_are_bright_and_nights_dark_with_gradual_dusk_and_dawn() {
197 assert_eq!(daylight(12.0), 1.0);
198 assert_eq!(daylight(23.0), 0.0);
199 assert_eq!(daylight(2.0), 0.0);
200 assert_eq!(daylight(19.0), 0.5);
201 assert_eq!(daylight(6.0), 0.5);
202 }
203
204 // Building a fire takes time, so colonists start before it actually gets dark.
205 #[test]
206 fn darkness_is_anticipated_before_dusk() {
207 let at = |hour: u64| SimTick((hour + 24 - 6) % 24 * TICKS_PER_HOUR);
208 assert!(!dark_soon(at(12)));
209 assert!(dark_soon(at(18)));
210 assert!(dark_soon(at(23)));
211 assert!(!dark_soon(at(8)));
212 }
213
214 #[test]
215 fn fires_light_their_surroundings_with_falloff() {
216 let fire = UVec2::new(10, 10);
217 let near = light_at(StructureKind::Campfire, fire, UVec2::new(11, 10));
218 let far = light_at(StructureKind::Campfire, fire, UVec2::new(15, 10));
219 assert!(brightness(near) > brightness(far));
220 assert_eq!(
221 light_at(StructureKind::Campfire, fire, UVec2::new(17, 10)),
222 Vec3::ZERO
223 );
224 }
225
226 #[test]
227 fn light_map_combines_ambient_and_fires() {
228 let fire = (UVec2::new(5, 5), StructureKind::Campfire);
229 let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
230 let night = LightMap::compute(&map, 0.0, [fire]);
231 assert!(night.is_lit(UVec2::new(5, 6)));
232 assert!(!night.is_lit(UVec2::new(18, 18)));
233 let day = LightMap::compute(&map, 1.0, []);
234 assert!(day.is_lit(UVec2::new(18, 18)));
235 }
236
237 #[test]
238 fn walls_cast_shadows_and_roofs_keep_daylight_out() {
239 let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
240 for y in 0..20 {
241 map.set_wall(UVec2::new(8, y), true);
242 }
243 map.set_roof(UVec2::new(15, 15), true);
244 let fire = (UVec2::new(6, 10), StructureKind::Campfire);
245 let night = LightMap::compute(&map, 0.0, [fire]);
246 assert!(night.is_lit(UVec2::new(7, 10)));
247 assert!(!night.is_lit(UVec2::new(9, 10)));
248 let day = LightMap::compute(&map, 1.0, []);
249 assert!(day.is_lit(UVec2::new(14, 15)));
250 assert!(!day.is_lit(UVec2::new(15, 15)));
251 }
252
253 #[test]
254 fn an_empty_light_map_counts_as_daylight() {
255 assert!(LightMap::default().is_lit(UVec2::new(3, 3)));
256 }
257
258 #[test]
259 fn beds_give_no_light() {
260 let bed = UVec2::new(4, 4);
261 assert_eq!(light_at(StructureKind::Bed, bed, bed), Vec3::ZERO);
262 }
263
264 // Lighting depends only on walls and roofs. Recomputing it (a full-map pass) on every tile
265 // change made flooring a home, one tile at a time by 20 colonists, halve Max speed.
266 #[test]
267 fn only_walls_and_roofs_change_the_lighting_shape() {
268 let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass);
269 let start = map.shape_version();
270 map.set(UVec2::ONE, Terrain::WoodFloor);
271 map.set_wall(UVec2::ZERO, false);
272 assert_eq!(map.shape_version(), start);
273 map.set_wall(UVec2::ZERO, true);
274 map.set_roof(UVec2::ONE, true);
275 assert_eq!(map.shape_version(), start + 2);
276 }
277 }