Visit only worn tiles and growing plants in periodic passes; rate-limit light re-tints
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 22:56 UTC
- commit
- ab8b35f205da7f706282bb313276dc00be05468f
- parent
- 2102323480
- tree
- browse at this commit
4 files changed +91 -5
| src/paths.rs | +16 -2 | |
| src/plants.rs | +1 -1 | |
| src/tilemap.rs | +27 -1 | |
| src/world.rs | +47 -1 |
src/paths.rs +16 -2
| @@ -35,7 +35,7 @@ pub fn wears_into_path(terrain: Terrain) -> bool { | ||
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | pub fn evolve(map: &mut WorldMap, zones: &Zones, mut on_change: impl FnMut(UVec2)) { |
| 38 | - for pos in map.positions() { | |
| 38 | + for pos in map.worn_tiles() { | |
| 39 | 39 | let p = pos.as_ivec2(); |
| 40 | 40 | let wear = map.wear(p) * WEAR_KEPT_PER_HOUR; |
| 41 | 41 | map.set_wear(pos, wear); |
| @@ -83,7 +83,8 @@ pub fn paving_candidates( | ||
| 83 | 83 | .filter(|k| **k == BuildKind::Pave) |
| 84 | 84 | .count(); |
| 85 | 85 | let mut busiest: Vec<UVec2> = map |
| 86 | - .positions() | |
| 86 | + .worn_tiles() | |
| 87 | + .into_iter() | |
| 87 | 88 | .filter(|&p| { |
| 88 | 89 | map.get(p.as_ivec2()) == Some(Terrain::Path) |
| 89 | 90 | && map.wear(p.as_ivec2()) >= PAVE_WEAR |
| @@ -160,6 +161,19 @@ mod tests { | ||
| 160 | 161 | assert_eq!(map.get(IVec2::ONE), Some(Terrain::Grass)); |
| 161 | 162 | } |
| 162 | 163 | |
| 164 | + // Only worn tiles are visited each hour (a full-map pass every game hour dominated frame time | |
| 165 | + // at high speeds), so faded wear must drop out of the worn set and stray paths must still | |
| 166 | + // regrow before that happens. | |
| 167 | + #[test] | |
| 168 | + fn faded_paths_regrow_and_leave_the_worn_set() { | |
| 169 | + let mut map = worn(Terrain::Path, PATH_WEAR); | |
| 170 | + for _ in 0..500 { | |
| 171 | + evolve(&mut map, &Zones::default(), |_| {}); | |
| 172 | + } | |
| 173 | + assert_eq!(map.get(IVec2::ONE), Some(Terrain::Grass)); | |
| 174 | + assert!(map.worn_tiles().is_empty()); | |
| 175 | + } | |
| 176 | + | |
| 163 | 177 | #[test] |
| 164 | 178 | fn wear_fades_over_time() { |
| 165 | 179 | let mut map = worn(Terrain::Grass, 10.0); |
src/plants.rs +1 -1
| @@ -28,7 +28,7 @@ pub fn fertility(terrain: Terrain) -> f32 { | ||
| 28 | 28 | } |
| 29 | 29 | |
| 30 | 30 | pub fn grow(map: &mut WorldMap, elapsed_ticks: f32, mut on_change: impl FnMut(UVec2)) { |
| 31 | - for pos in map.positions() { | |
| 31 | + for pos in map.growing_plants() { | |
| 32 | 32 | let p = pos.as_ivec2(); |
| 33 | 33 | let Some(plant) = map.plant(p) else { |
| 34 | 34 | continue; |
src/tilemap.rs +27 -1
| @@ -1,3 +1,5 @@ | ||
| 1 | +use std::time::Duration; | |
| 2 | + | |
| 1 | 3 | use bevy::{ |
| 2 | 4 | asset::RenderAssetUsages, |
| 3 | 5 | image::ImageSampler, |
| @@ -20,6 +22,7 @@ const LIGHT: Vec3 = Vec3::new(-1.0, 1.0, 1.5); | ||
| 20 | 22 | const SHADE_FLAT: f32 = 1.1; |
| 21 | 23 | const MAX_SHADOW: f32 = 0.5; |
| 22 | 24 | const HILL_SHADE_Z: f32 = 0.1; |
| 25 | +const LIGHT_REFRESH_INTERVAL: Duration = Duration::from_millis(100); | |
| 23 | 26 | |
| 24 | 27 | type Texture = Vec<[u8; 4]>; |
| 25 | 28 | |
| @@ -198,11 +201,25 @@ fn refresh_chunks( | ||
| 198 | 201 | } |
| 199 | 202 | } |
| 200 | 203 | |
| 204 | +pub fn light_refresh_due(pending: bool, since_last: Duration) -> bool { | |
| 205 | + pending && since_last >= LIGHT_REFRESH_INTERVAL | |
| 206 | +} | |
| 207 | + | |
| 201 | 208 | fn world_or_light_changed( |
| 202 | 209 | mut generated: MessageReader<WorldGenerated>, |
| 203 | 210 | light: Res<LightMap>, |
| 211 | + real: Res<Time<Real>>, | |
| 212 | + mut pending: Local<bool>, | |
| 213 | + mut last: Local<Duration>, | |
| 204 | 214 | ) -> bool { |
| 205 | - generated.read().count() > 0 || light.is_changed() | |
| 215 | + *pending |= light.is_changed(); | |
| 216 | + let now = real.elapsed(); | |
| 217 | + let due = generated.read().count() > 0 || light_refresh_due(*pending, now - *last); | |
| 218 | + if due { | |
| 219 | + *pending = false; | |
| 220 | + *last = now; | |
| 221 | + } | |
| 222 | + due | |
| 206 | 223 | } |
| 207 | 224 | |
| 208 | 225 | fn refresh_zone_chunks( |
| @@ -647,6 +664,15 @@ mod tests { | ||
| 647 | 664 | assert_eq!(shadow_alpha(0.0), (MAX_SHADOW * 255.0) as u8); |
| 648 | 665 | } |
| 649 | 666 | |
| 667 | + // Every ambient light step re-tints all chunks; at high sim speeds dusk produced one per | |
| 668 | + // frame and dragged the frame rate down, so light-driven refreshes are rate limited. | |
| 669 | + #[test] | |
| 670 | + fn light_refreshes_are_rate_limited() { | |
| 671 | + assert!(!light_refresh_due(true, Duration::from_millis(20))); | |
| 672 | + assert!(light_refresh_due(true, LIGHT_REFRESH_INTERVAL)); | |
| 673 | + assert!(!light_refresh_due(false, Duration::from_secs(5))); | |
| 674 | + } | |
| 675 | + | |
| 650 | 676 | #[test] |
| 651 | 677 | fn tileset_layer_count_avoids_webgl_view_dimension_heuristics() { |
| 652 | 678 | for n in 0..50 { |
src/world.rs +47 -1
| @@ -1,5 +1,8 @@ | ||
| 1 | +use std::collections::HashSet; | |
| 2 | + | |
| 1 | 3 | use bevy::prelude::*; |
| 2 | 4 | |
| 5 | +pub const MIN_WEAR: f32 = 0.05; | |
| 3 | 6 | pub const TILE_SIZE: f32 = 16.0; |
| 4 | 7 | pub const MAX_HEIGHT: f32 = 100.0; |
| 5 | 8 | pub const UPHILL_COST_PER_METER: f32 = 20.0; |
| @@ -128,6 +131,8 @@ pub struct WorldMap { | ||
| 128 | 131 | roofs: Vec<bool>, |
| 129 | 132 | growth: Vec<f32>, |
| 130 | 133 | wear: Vec<f32>, |
| 134 | + worn: HashSet<UVec2>, | |
| 135 | + growing: HashSet<UVec2>, | |
| 131 | 136 | } |
| 132 | 137 | |
| 133 | 138 | impl WorldMap { |
| @@ -142,6 +147,8 @@ impl WorldMap { | ||
| 142 | 147 | roofs: vec![false; n], |
| 143 | 148 | growth: vec![0.0; n], |
| 144 | 149 | wear: vec![0.0; n], |
| 150 | + worn: HashSet::new(), | |
| 151 | + growing: HashSet::new(), | |
| 145 | 152 | } |
| 146 | 153 | } |
| 147 | 154 | |
| @@ -172,6 +179,17 @@ impl WorldMap { | ||
| 172 | 179 | pub fn set_plant(&mut self, pos: UVec2, plant: Option<Plant>) { |
| 173 | 180 | let i = self.index(pos); |
| 174 | 181 | self.plants[i] = plant; |
| 182 | + if plant.is_some_and(|p| p.ripening_ticks().is_some()) { | |
| 183 | + self.growing.insert(pos); | |
| 184 | + } else { | |
| 185 | + self.growing.remove(&pos); | |
| 186 | + } | |
| 187 | + } | |
| 188 | + | |
| 189 | + pub fn growing_plants(&self) -> Vec<UVec2> { | |
| 190 | + let mut tiles: Vec<UVec2> = self.growing.iter().copied().collect(); | |
| 191 | + tiles.sort_by_key(|t| (t.y, t.x)); | |
| 192 | + tiles | |
| 175 | 193 | } |
| 176 | 194 | |
| 177 | 195 | pub fn height(&self, pos: IVec2) -> Option<f32> { |
| @@ -216,7 +234,19 @@ impl WorldMap { | ||
| 216 | 234 | |
| 217 | 235 | pub fn set_wear(&mut self, pos: UVec2, wear: f32) { |
| 218 | 236 | let i = self.index(pos); |
| 219 | - self.wear[i] = wear.max(0.0); | |
| 237 | + let wear = if wear < MIN_WEAR { 0.0 } else { wear }; | |
| 238 | + self.wear[i] = wear; | |
| 239 | + if wear > 0.0 { | |
| 240 | + self.worn.insert(pos); | |
| 241 | + } else { | |
| 242 | + self.worn.remove(&pos); | |
| 243 | + } | |
| 244 | + } | |
| 245 | + | |
| 246 | + pub fn worn_tiles(&self) -> Vec<UVec2> { | |
| 247 | + let mut tiles: Vec<UVec2> = self.worn.iter().copied().collect(); | |
| 248 | + tiles.sort_by_key(|t| (t.y, t.x)); | |
| 249 | + tiles | |
| 220 | 250 | } |
| 221 | 251 | |
| 222 | 252 | pub fn ripe_plant(&self, pos: IVec2) -> Option<Plant> { |
| @@ -277,6 +307,22 @@ pub fn hash2(x: u32, y: u32) -> u32 { | ||
| 277 | 307 | mod tests { |
| 278 | 308 | use super::*; |
| 279 | 309 | |
| 310 | + // Growth runs ten times a game hour; it visits only tracked growing plants because a | |
| 311 | + // full-map pass that often dominated frame time at high speeds. | |
| 312 | + #[test] | |
| 313 | + fn only_ripening_plants_are_tracked_as_growing() { | |
| 314 | + let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass); | |
| 315 | + map.set_plant(UVec2::new(1, 1), Some(Plant::Tree)); | |
| 316 | + map.set_plant(UVec2::new(2, 2), Some(Plant::BerryBush)); | |
| 317 | + map.set_plant(UVec2::new(3, 3), Some(Plant::Crop)); | |
| 318 | + assert_eq!( | |
| 319 | + map.growing_plants(), | |
| 320 | + vec![UVec2::new(2, 2), UVec2::new(3, 3)] | |
| 321 | + ); | |
| 322 | + map.set_plant(UVec2::new(2, 2), None); | |
| 323 | + assert_eq!(map.growing_plants(), vec![UVec2::new(3, 3)]); | |
| 324 | + } | |
| 325 | + | |
| 280 | 326 | #[test] |
| 281 | 327 | fn get_out_of_bounds_is_none() { |
| 282 | 328 | let map = WorldMap::filled(UVec2::new(4, 3), Terrain::Grass); |