src/light.rs 7.6 K · 243 lines · raw · history

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