Visit only worn tiles and growing plants in periodic passes; rate-limit light re-tints

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

@@ -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);