concinnity_core/render/chunk_window.rs
1//! Sliding-window streaming policy for an infinite voxel world.
2//!
3//! `gfx::streaming::StreamPlanner` streams a *fixed* pool of items known at
4//! init (textures, build-time meshes). An infinite chunk world is different:
5//! the item set is unbounded and only a bounded *window* around the camera is
6//! ever resident. This module is that policy -- given the camera's chunk and a
7//! view radius it decides which chunks to load (nearest first, budget-limited)
8//! and which to evict (those that have fallen well outside the window).
9//!
10//! Two concentric bands: chunks within `near_radius` stream at full voxel
11//! detail; chunks beyond it but within `far_radius` stream as cheap coarse
12//! "impostors" (a low-poly surface mesh). As the camera moves a chunk crosses
13//! the near/far boundary and is *re-detailed* -- evicted and reloaded at the new
14//! detail. A small detail hysteresis keeps a chunk pacing across the boundary
15//! from thrashing. When `far_radius == near_radius` (the default) the far band
16//! is empty, so the window behaves exactly as the original single-detail one.
17//!
18//! The `std`-side driver (background generation thread, GPU upload) lives in
19//! concinnity-engine's `app::chunk_stream`.
20
21// `BTreeMap` rather than a hash map: `plan` hands back its eviction lists in
22// this map's iteration order, which a hash map would leave unpinned.
23use alloc::collections::BTreeMap;
24use alloc::vec::Vec;
25
26use crate::gfx::chunk_coord::ChunkCoord;
27
28// Extra chunk rings a chunk may drift beyond the view radius before it is
29// evicted. The gap between the load radius and the evict radius is hysteresis:
30// without it a chunk straddling the boundary would load and evict on
31// alternating frames as the camera jitters across a chunk edge.
32const EVICT_HYSTERESIS: i32 = 2;
33
34// Extra rings a currently-full (Near) chunk may drift past `near_radius` before
35// it is downgraded to a Far impostor. Without it a camera pacing back and forth
36// across the near/far boundary would re-detail a chunk every step.
37const DETAIL_HYSTERESIS: i32 = 1;
38
39// Low-water mark (percent of the byte budget) the effective window regrows at.
40// The window shrinks a ring whenever resident bytes exceed the budget and only
41// regrows once they fall back under this fraction of it; the gap is hysteresis
42// that stops the radius oscillating a ring in and out at the boundary.
43const BYTE_BUDGET_LOW_PCT: u64 = 75;
44
45// Residency state of a chunk the window is currently tracking.
46//
47// A chunk not in the window's map is simply unloaded -- there is no explicit
48// `Unloaded` state, since the grid is infinite and tracking every never-seen
49// chunk would be unbounded.
50#[derive(Clone, Copy, PartialEq, Eq, Debug)]
51pub(crate) enum ChunkState {
52 // A background generation+upload has been dispatched but not completed.
53 Pending,
54 // The chunk's mesh is resident on the GPU.
55 Resident,
56}
57
58/// Which representation a chunk is streamed at.
59#[derive(Clone, Copy, PartialEq, Eq, Debug)]
60pub enum ChunkDetail {
61 /// Full voxel geometry for chunks within `near_radius`.
62 Near,
63 /// A coarse distant-impostor surface mesh for chunks beyond `near_radius` but
64 /// within `far_radius`.
65 Far,
66}
67
68#[derive(Clone, Copy, PartialEq, Eq, Debug)]
69struct Slot {
70 state: ChunkState,
71 detail: ChunkDetail,
72 // Resident GPU footprint in bytes, reported by the driver on load
73 // completion. Zero while Pending; only counted toward `resident_bytes`
74 // once the slot is Resident.
75 bytes: u64,
76}
77
78/// The load / evict decisions produced by one [`ChunkWindow::plan`] call.
79#[derive(Debug, Default, PartialEq, Eq)]
80pub struct ChunkPlan {
81 /// Chunks whose background load should be dispatched this frame, nearest
82 /// to the camera first, each tagged with the detail to generate it at.
83 /// Already marked `ChunkState::Pending`.
84 pub to_load: Vec<(ChunkCoord, ChunkDetail)>,
85 /// Chunks removed from the GPU this frame: those that fell outside the
86 /// evict radius, plus those crossing the near/far boundary (which reload at
87 /// the new detail). Already dropped from the window's tracking map.
88 pub to_evict: Vec<ChunkCoord>,
89}
90
91/// Decides which chunks stream in and out of the camera-centred view window,
92/// and at which detail.
93///
94/// The window owns only residency *bookkeeping* -- it never generates a chunk
95/// or touches a GPU resource. Each frame the driver calls [`plan`] with the
96/// camera's current chunk, dispatches the loads, applies the evictions, and
97/// reports completed loads back via [`mark_resident`].
98///
99/// [`plan`]: ChunkWindow::plan
100/// [`mark_resident`]: ChunkWindow::mark_resident
101pub struct ChunkWindow {
102 // Tracked chunks only: a chunk absent from the map is unloaded.
103 states: BTreeMap<ChunkCoord, Slot>,
104 // Chebyshev radius (in chunks) of the full-detail square window.
105 near_radius: i32,
106 // Chebyshev radius of the outer impostor window (>= near_radius).
107 far_radius: i32,
108 // Max chunk loads dispatched per `plan` call.
109 load_budget: usize,
110 // Optional cap on total resident chunk bytes. When `Some(b)`, `plan`
111 // clamps the effective window down (shrinking the far impostor band before
112 // the near full-detail band) whenever resident bytes exceed `b`, evicting
113 // the outermost chunks until they fit. `None` (the default) disables byte
114 // accounting entirely, leaving the pure radius-based window.
115 byte_budget: Option<u64>,
116 // Rings the effective window is currently shrunk by under byte pressure,
117 // in `[0, far_radius]`. Zero (the default, and always so with no byte
118 // budget) means the effective window is exactly the configured one. Each
119 // ring shrinks the far band first, then the near band once the two meet.
120 shrink: i32,
121}
122
123impl ChunkWindow {
124 /// A window with a full-detail radius of `near_radius`, an outer impostor
125 /// radius of `far_radius`, and a per-frame load budget of `load_budget`
126 /// chunks.
127 ///
128 /// `near_radius` is floored at 0 (a lone chunk), `far_radius` at
129 /// `near_radius` (so it never undercuts the full-detail band; equal means
130 /// "no impostors"), and `load_budget` at 1 so a stray 0 cannot wedge
131 /// streaming permanently.
132 pub fn new(near_radius: i32, far_radius: i32, load_budget: usize) -> Self {
133 let near_radius = near_radius.max(0);
134 let far_radius = far_radius.max(near_radius);
135 Self {
136 states: BTreeMap::new(),
137 near_radius,
138 far_radius,
139 load_budget: load_budget.max(1),
140 byte_budget: None,
141 shrink: 0,
142 }
143 }
144
145 /// Set (or clear with `None`) the total resident-chunk-byte budget. `None`
146 /// keeps the pure radius-based window; `Some(b)` additionally clamps the
147 /// effective view radius down until resident bytes fit `b`. Off by default
148 /// so worlds that never set it behave exactly as the radius-only window.
149 pub fn set_byte_budget(&mut self, budget: Option<u64>) {
150 self.byte_budget = budget;
151 }
152
153 /// The active resident-byte budget, or `None` when byte accounting is off
154 /// (the pure radius window). For diagnostics.
155 pub fn byte_budget(&self) -> Option<u64> {
156 self.byte_budget
157 }
158
159 // The detail a chunk should currently be at, given its distance from the
160 // camera, the effective full-detail radius, and (for hysteresis) the detail
161 // it is currently tracked at.
162 fn target_detail(
163 &self,
164 c: ChunkCoord,
165 camera: ChunkCoord,
166 current: Option<ChunkDetail>,
167 near_radius: i32,
168 ) -> ChunkDetail {
169 let d = c.chebyshev_distance(camera);
170 if d <= near_radius {
171 ChunkDetail::Near
172 } else if matches!(current, Some(ChunkDetail::Near)) && d <= near_radius + DETAIL_HYSTERESIS
173 {
174 // A currently-full chunk keeps full detail through the hysteresis
175 // band rather than re-detailing the instant it leaves near_radius.
176 ChunkDetail::Near
177 } else {
178 ChunkDetail::Far
179 }
180 }
181
182 // The effective `(near_radius, far_radius)` after applying the byte-pressure
183 // `shrink`. A shrink first trims the far impostor band down to the near
184 // band, then shrinks the near band (with the far band held equal to it), so
185 // cheap distant impostors are dropped before full-detail near chunks.
186 fn effective_radii(&self) -> (i32, i32) {
187 let far_span = self.far_radius - self.near_radius;
188 if self.shrink <= far_span {
189 (self.near_radius, self.far_radius - self.shrink)
190 } else {
191 let near = (self.near_radius - (self.shrink - far_span)).max(0);
192 (near, near)
193 }
194 }
195
196 // Adjust the byte-pressure `shrink` from resident bytes vs the budget, with
197 // hysteresis so the effective radius does not oscillate at the boundary. A
198 // no-op (shrink pinned to 0) when no byte budget is set.
199 fn adjust_shrink(&mut self) {
200 let Some(budget) = self.byte_budget else {
201 self.shrink = 0;
202 return;
203 };
204 let resident = self.resident_bytes();
205 if resident > budget {
206 // Over budget: shrink one more ring (far impostor band first, then
207 // the near full-detail band), never past a lone camera chunk.
208 self.shrink = (self.shrink + 1).min(self.far_radius);
209 return;
210 }
211 // Under budget: regrow one ring only once resident bytes fall
212 // comfortably below the low-water margin AND no loads are in flight
213 // (a pending load's bytes are not yet counted, so regrowing before it
214 // lands could overshoot and force an immediate re-shrink). The margin
215 // plus the in-flight gate are the hysteresis that keeps the effective
216 // radius from oscillating ring in and out frame to frame.
217 if self.shrink == 0 {
218 return;
219 }
220 let pending = self
221 .states
222 .values()
223 .filter(|slot| slot.state == ChunkState::Pending)
224 .count();
225 if pending == 0 && resident.saturating_mul(100) < budget.saturating_mul(BYTE_BUDGET_LOW_PCT)
226 {
227 self.shrink -= 1;
228 }
229 }
230
231 /// Decide this frame's chunk loads and evictions for a camera in chunk
232 /// `camera`.
233 ///
234 /// First reconciles the effective view radius against the byte budget (a
235 /// no-op when none is set), then evicts every tracked chunk now beyond the
236 /// effective evict radius, re-details any tracked chunk that has crossed the
237 /// near/far boundary (evict + reload at the new detail), and dispatches the
238 /// nearest in-window chunks not yet tracked, up to the load budget, marking
239 /// each `Pending`.
240 pub fn plan(&mut self, camera: ChunkCoord) -> ChunkPlan {
241 // 0. Reconcile the effective window with the byte budget. The clamp only
242 // ever shrinks below the configured radii, so a world with no budget
243 // (shrink pinned to 0) plans exactly the configured window.
244 self.adjust_shrink();
245 let (near_radius, far_radius) = self.effective_radii();
246 let evict_radius = far_radius + EVICT_HYSTERESIS;
247
248 let mut to_evict: Vec<ChunkCoord> = Vec::new();
249
250 // 1. Evict chunks that have drifted past the evict radius.
251 let gone: Vec<ChunkCoord> = self
252 .states
253 .keys()
254 .copied()
255 .filter(|c| c.chebyshev_distance(camera) > evict_radius)
256 .collect();
257 for c in &gone {
258 self.states.remove(c);
259 }
260 to_evict.extend_from_slice(&gone);
261
262 // 2. Re-detail: a tracked chunk whose target detail no longer matches
263 // is dropped + evicted so the candidate scan reloads it at the new
264 // detail (a near<->far crossing as the camera moves).
265 let redetail: Vec<ChunkCoord> = self
266 .states
267 .iter()
268 .filter(|(c, slot)| {
269 self.target_detail(**c, camera, Some(slot.detail), near_radius) != slot.detail
270 })
271 .map(|(c, _)| *c)
272 .collect();
273 for c in &redetail {
274 self.states.remove(c);
275 }
276 to_evict.extend_from_slice(&redetail);
277
278 // 3. Collect in-window chunks (within far_radius) not yet tracked,
279 // nearest first.
280 let mut candidates: Vec<ChunkCoord> = Vec::new();
281 for dz in -far_radius..=far_radius {
282 for dx in -far_radius..=far_radius {
283 let c = camera.offset(dx, dz);
284 if !self.states.contains_key(&c) {
285 candidates.push(c);
286 }
287 }
288 }
289 candidates.sort_unstable_by(|a, b| {
290 a.sq_distance(camera)
291 .cmp(&b.sq_distance(camera))
292 // Stable tiebreak on the coordinate so the plan is deterministic.
293 .then(a.cmp(b))
294 });
295 candidates.truncate(self.load_budget);
296
297 let mut to_load = Vec::with_capacity(candidates.len());
298 for &c in &candidates {
299 let detail = self.target_detail(c, camera, None, near_radius);
300 self.states.insert(
301 c,
302 Slot {
303 state: ChunkState::Pending,
304 detail,
305 bytes: 0,
306 },
307 );
308 to_load.push((c, detail));
309 }
310
311 to_evict.sort_unstable();
312 ChunkPlan { to_load, to_evict }
313 }
314
315 /// Mark a dispatched chunk resident once its mesh is on the GPU, recording
316 /// its GPU footprint in `bytes` (the decoded vertex + index buffer size).
317 /// `bytes` may be 0 when nothing was uploaded (e.g. a generation that
318 /// deterministically failed and is being retired to stop retrying it).
319 ///
320 /// A no-op if the chunk is no longer tracked -- the camera may have moved
321 /// far enough to evict it while its load was still in flight.
322 pub fn mark_resident(&mut self, coord: ChunkCoord, bytes: u64) {
323 if let Some(slot) = self.states.get_mut(&coord) {
324 slot.state = ChunkState::Resident;
325 slot.bytes = bytes;
326 }
327 }
328
329 /// Total bytes of all currently Resident chunks, for diagnostics and the
330 /// byte-budget clamp. Pending chunks (not yet uploaded) are excluded.
331 pub fn resident_bytes(&self) -> u64 {
332 self.states
333 .values()
334 .filter(|slot| slot.state == ChunkState::Resident)
335 .map(|slot| slot.bytes)
336 .sum()
337 }
338
339 /// Drop `coord` from tracking so a later [`plan`](Self::plan) will
340 /// re-dispatch it.
341 ///
342 /// The driver calls this when a dispatch could not be delivered to the
343 /// background worker, so the chunk is retried rather than stuck `Pending`.
344 pub fn forget(&mut self, coord: ChunkCoord) {
345 self.states.remove(&coord);
346 }
347
348 /// Whether the window is still tracking `coord` (pending or resident).
349 ///
350 /// The driver checks this when a background load completes: a chunk
351 /// evicted mid-flight is no longer tracked and its mesh should be dropped.
352 pub fn is_tracked(&self, coord: ChunkCoord) -> bool {
353 self.states.contains_key(&coord)
354 }
355
356 /// `(resident, pending)` chunk counts -- for diagnostics.
357 pub fn counts(&self) -> (usize, usize) {
358 let mut resident = 0;
359 let mut pending = 0;
360 for slot in self.states.values() {
361 match slot.state {
362 ChunkState::Resident => resident += 1,
363 ChunkState::Pending => pending += 1,
364 }
365 }
366 (resident, pending)
367 }
368
369 /// `(near_resident, far_resident)` counts -- resident full chunks vs
370 /// resident impostors, for diagnostics / verifying the far band is active.
371 pub fn counts_by_detail(&self) -> (usize, usize) {
372 let mut near = 0;
373 let mut far = 0;
374 for slot in self.states.values() {
375 if slot.state == ChunkState::Resident {
376 match slot.detail {
377 ChunkDetail::Near => near += 1,
378 ChunkDetail::Far => far += 1,
379 }
380 }
381 }
382 (near, far)
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 use alloc::vec;
391 fn cc(x: i32, z: i32) -> ChunkCoord {
392 ChunkCoord::new(x, z)
393 }
394
395 // Coords in a plan's load list, detail dropped, for set-style assertions.
396 fn load_coords(plan: &ChunkPlan) -> Vec<ChunkCoord> {
397 plan.to_load.iter().map(|(c, _)| *c).collect()
398 }
399
400 // Fill the whole configured window at `camera`, marking every dispatched
401 // chunk resident with `bytes`. Assumes a load budget covering the window.
402 fn fill(w: &mut ChunkWindow, camera: ChunkCoord, bytes: u64) {
403 for (c, _) in w.plan(camera).to_load {
404 w.mark_resident(c, bytes);
405 }
406 }
407
408 // Replan at a stationary `camera`, marking each newly loaded chunk resident,
409 // until the byte clamp reaches a fixed point. Convergence is a streak of
410 // plans that neither load nor evict: a single quiet plan is not enough,
411 // because the effective radius shrinks two frames before the `+2` evict
412 // hysteresis actually drops the outer ring. Panics if it never settles --
413 // which doubles as the assertion that the clamp does not oscillate.
414 fn settle(w: &mut ChunkWindow, camera: ChunkCoord, bytes: u64) {
415 let mut quiet = 0;
416 for _ in 0..200 {
417 let plan = w.plan(camera);
418 for (c, _) in &plan.to_load {
419 w.mark_resident(*c, bytes);
420 }
421 if plan.to_load.is_empty() && plan.to_evict.is_empty() {
422 quiet += 1;
423 if quiet >= 4 {
424 return;
425 }
426 } else {
427 quiet = 0;
428 }
429 }
430 panic!("byte-budget clamp did not converge (oscillating?)");
431 }
432
433 #[test]
434 fn plan_loads_nearest_in_window_chunks_within_budget() {
435 // near=far=2 -> a 5x5 window of 25 chunks, impostors off; budget 4.
436 let mut w = ChunkWindow::new(2, 2, 4);
437 let plan = w.plan(cc(0, 0));
438 assert!(plan.to_evict.is_empty());
439 assert_eq!(plan.to_load.len(), 4);
440 // The camera's own chunk is distance 0 -- it must be dispatched first.
441 assert_eq!(plan.to_load[0], (cc(0, 0), ChunkDetail::Near));
442 // Every dispatched chunk is within the load radius and full-detail.
443 for (c, detail) in &plan.to_load {
444 assert!(c.chebyshev_distance(cc(0, 0)) <= 2);
445 assert_eq!(*detail, ChunkDetail::Near);
446 }
447 }
448
449 #[test]
450 fn plan_does_not_redispatch_tracked_chunks() {
451 let mut w = ChunkWindow::new(3, 3, 100);
452 let first = w.plan(cc(0, 0));
453 // A generous budget loads the whole 7x7 window at once.
454 assert_eq!(first.to_load.len(), 49);
455 // Nothing left to dispatch on the next frame at the same position.
456 let second = w.plan(cc(0, 0));
457 assert!(second.to_load.is_empty());
458 assert!(second.to_evict.is_empty());
459 }
460
461 #[test]
462 fn plan_evicts_chunks_past_the_hysteresis_band() {
463 let mut w = ChunkWindow::new(2, 2, 100);
464 w.plan(cc(0, 0)); // load the 5x5 window around the origin
465 // Move far enough that the origin chunk is past radius 2 + hysteresis 2.
466 let plan = w.plan(cc(6, 0));
467 assert!(plan.to_evict.contains(&cc(0, 0)));
468 }
469
470 #[test]
471 fn evicted_chunk_can_be_reloaded_after_returning() {
472 let mut w = ChunkWindow::new(1, 1, 100);
473 w.plan(cc(0, 0));
474 w.plan(cc(20, 0)); // evicts the origin window entirely
475 assert!(!w.is_tracked(cc(0, 0)));
476 let plan = w.plan(cc(0, 0));
477 assert!(load_coords(&plan).contains(&cc(0, 0)));
478 }
479
480 #[test]
481 fn mark_resident_promotes_a_pending_chunk() {
482 let mut w = ChunkWindow::new(0, 0, 1);
483 let plan = w.plan(cc(0, 0));
484 assert_eq!(plan.to_load, vec![(cc(0, 0), ChunkDetail::Near)]);
485 assert_eq!(w.counts(), (0, 1));
486 w.mark_resident(cc(0, 0), 0);
487 assert_eq!(w.counts(), (1, 0));
488 }
489
490 #[test]
491 fn mark_resident_of_an_untracked_chunk_is_a_noop() {
492 let mut w = ChunkWindow::new(0, 0, 1);
493 w.mark_resident(cc(9, 9), 0); // never planned -- must not panic or insert
494 assert_eq!(w.counts(), (0, 0));
495 assert!(!w.is_tracked(cc(9, 9)));
496 }
497
498 #[test]
499 fn forget_lets_a_chunk_be_redispatched() {
500 let mut w = ChunkWindow::new(0, 0, 1);
501 w.plan(cc(0, 0));
502 assert!(w.is_tracked(cc(0, 0)));
503 w.forget(cc(0, 0));
504 assert!(!w.is_tracked(cc(0, 0)));
505 let plan = w.plan(cc(0, 0));
506 assert_eq!(plan.to_load, vec![(cc(0, 0), ChunkDetail::Near)]);
507 }
508
509 #[test]
510 fn zero_radius_and_budget_are_floored() {
511 // near floored to 0 (just the camera chunk), far to near, budget to 1.
512 let mut w = ChunkWindow::new(-5, -5, 0);
513 let plan = w.plan(cc(0, 0));
514 assert_eq!(plan.to_load, vec![(cc(0, 0), ChunkDetail::Near)]);
515 }
516
517 #[test]
518 fn far_band_chunks_load_as_impostors() {
519 // near 1, far 3: the 3x3 core is full detail, the surrounding rings are
520 // impostors. A generous budget loads the whole 7x7 window at once.
521 let mut w = ChunkWindow::new(1, 3, 100);
522 let plan = w.plan(cc(0, 0));
523 assert_eq!(plan.to_load.len(), 49);
524 for (c, detail) in &plan.to_load {
525 let d = c.chebyshev_distance(cc(0, 0));
526 let expected = if d <= 1 {
527 ChunkDetail::Near
528 } else {
529 ChunkDetail::Far
530 };
531 assert_eq!(*detail, expected, "chunk {:?} at distance {}", c, d);
532 }
533 }
534
535 #[test]
536 fn crossing_the_boundary_redetails_a_chunk() {
537 let mut w = ChunkWindow::new(1, 3, 100);
538 // Load + resolve the whole window around the origin.
539 let plan = w.plan(cc(0, 0));
540 let crossing = cc(2, 0); // distance 2 -> Far impostor at the origin
541 assert!(plan.to_load.contains(&(crossing, ChunkDetail::Far)));
542 for (c, _) in plan.to_load.clone() {
543 w.mark_resident(c, 0);
544 }
545 let (near0, far0) = w.counts_by_detail();
546 assert!(near0 > 0 && far0 > 0);
547
548 // Step toward `crossing` so it falls inside near_radius: it must be
549 // re-detailed (evicted) and re-dispatched as Near.
550 let plan = w.plan(cc(1, 0));
551 assert!(plan.to_evict.contains(&crossing));
552 assert!(plan.to_load.contains(&(crossing, ChunkDetail::Near)));
553 }
554
555 #[test]
556 fn detail_hysteresis_holds_a_full_chunk_through_the_band() {
557 // near 2, far 5. A chunk at the origin starts Near (camera at origin).
558 let mut w = ChunkWindow::new(2, 5, 200);
559 for (c, _) in w.plan(cc(0, 0)).to_load {
560 w.mark_resident(c, 0);
561 }
562 // Camera steps to (3,0): origin chunk is now chebyshev distance 3 =
563 // near_radius(2) + hysteresis(1), so it stays Near, no re-detail.
564 let plan = w.plan(cc(3, 0));
565 assert!(!plan.to_evict.contains(&cc(0, 0)));
566 // Step once more to (4,0): distance 4 > 2 + 1, so it downgrades to Far.
567 let plan = w.plan(cc(4, 0));
568 assert!(plan.to_evict.contains(&cc(0, 0)));
569 assert!(plan.to_load.contains(&(cc(0, 0), ChunkDetail::Far)));
570 }
571
572 #[test]
573 fn equal_radii_disable_the_far_band() {
574 // far == near -> every in-window chunk is Near, exactly as the original
575 // single-detail window.
576 let mut w = ChunkWindow::new(3, 3, 100);
577 let plan = w.plan(cc(0, 0));
578 assert!(plan.to_load.iter().all(|(_, d)| *d == ChunkDetail::Near));
579 assert_eq!(w.counts_by_detail().1, 0); // never any far chunks
580 }
581
582 #[test]
583 fn resident_bytes_counts_only_resident_chunks() {
584 let mut w = ChunkWindow::new(1, 1, 100); // 3x3 window
585 w.plan(cc(0, 0)); // all 9 dispatched Pending
586 assert_eq!(w.resident_bytes(), 0); // nothing uploaded yet
587 w.mark_resident(cc(0, 0), 500);
588 w.mark_resident(cc(1, 0), 250);
589 assert_eq!(w.resident_bytes(), 750);
590 // The 7 still-pending chunks contribute nothing to the resident total.
591 assert_eq!(w.counts(), (2, 7));
592 }
593
594 #[test]
595 fn byte_budget_accessor_reflects_set_and_clear() {
596 let mut w = ChunkWindow::new(1, 1, 4);
597 assert_eq!(w.byte_budget(), None);
598 w.set_byte_budget(Some(4096));
599 assert_eq!(w.byte_budget(), Some(4096));
600 w.set_byte_budget(None);
601 assert_eq!(w.byte_budget(), None);
602 }
603
604 #[test]
605 fn no_byte_budget_never_shrinks_the_window() {
606 // Absurd resident bytes but no budget: the window stays the configured
607 // radius and a stationary replan neither loads nor evicts -- byte-for-byte
608 // the pure radius-only behavior the existing tests pin.
609 let mut w = ChunkWindow::new(1, 3, 1000);
610 fill(&mut w, cc(0, 0), 10_000_000);
611 assert_eq!(w.counts().0, 49); // full 7x7 window resident
612 for _ in 0..4 {
613 let plan = w.plan(cc(0, 0));
614 assert!(plan.to_load.is_empty());
615 assert!(plan.to_evict.is_empty());
616 }
617 assert_eq!(w.counts().0, 49);
618 }
619
620 #[test]
621 fn byte_budget_evicts_the_far_band_before_the_near_band() {
622 let mut w = ChunkWindow::new(2, 6, 1000);
623 fill(&mut w, cc(0, 0), 100);
624 let (near_full, far_full) = w.counts_by_detail();
625 assert_eq!(near_full, 25); // the 5x5 full-detail core
626 assert!(far_full > 0);
627 // A budget that cannot hold the whole impostor band but comfortably fits
628 // the near core: the far band shrinks first, leaving the core intact.
629 w.set_byte_budget(Some(9000));
630 settle(&mut w, cc(0, 0), 100);
631 let (near_after, far_after) = w.counts_by_detail();
632 assert_eq!(near_after, 25, "full-detail core must survive");
633 assert!(far_after < far_full, "impostor band must shrink");
634 assert!(w.resident_bytes() <= 9000);
635 assert!(w.is_tracked(cc(0, 0)), "camera chunk is never evicted");
636 assert!(!w.is_tracked(cc(6, 0)), "outermost ring evicted");
637 }
638
639 #[test]
640 fn tighter_byte_budget_shrinks_the_window_further() {
641 // A tighter budget must keep strictly fewer chunks resident -- once the
642 // far band is exhausted the clamp shrinks into the near band too.
643 let build = |budget: u64| {
644 let mut w = ChunkWindow::new(2, 6, 1000);
645 fill(&mut w, cc(0, 0), 100);
646 w.set_byte_budget(Some(budget));
647 settle(&mut w, cc(0, 0), 100);
648 w
649 };
650 let loose = build(9000);
651 let tight = build(6000);
652 assert!(loose.resident_bytes() <= 9000);
653 assert!(tight.resident_bytes() <= 6000);
654 assert!(
655 tight.counts().0 < loose.counts().0,
656 "tighter budget must shrink further: tight {} vs loose {}",
657 tight.counts().0,
658 loose.counts().0
659 );
660 assert!(loose.is_tracked(cc(0, 0)) && tight.is_tracked(cc(0, 0)));
661 }
662
663 #[test]
664 fn byte_budget_clamp_settles_without_oscillating() {
665 let mut w = ChunkWindow::new(1, 5, 1000);
666 fill(&mut w, cc(0, 0), 100);
667 w.set_byte_budget(Some(5000));
668 settle(&mut w, cc(0, 0), 100); // panics if it never converges
669 // Past the fixed point a stationary replan is a no-op: the effective
670 // radius neither regrows nor re-shrinks frame to frame.
671 for _ in 0..8 {
672 let plan = w.plan(cc(0, 0));
673 assert!(plan.to_load.is_empty(), "regrew: {:?}", plan.to_load);
674 assert!(plan.to_evict.is_empty(), "evicted: {:?}", plan.to_evict);
675 }
676 assert!(w.resident_bytes() <= 5000);
677 }
678}