Skip to main content

proof_engine/terrain/
chunks.rs

1//! Chunked terrain streaming system.
2//!
3//! Manages the loading, unloading, caching, and mesh generation of terrain
4//! chunks around a moving viewer. Provides:
5//!
6//! - `ChunkCoord` — integer 2D chunk address
7//! - `ChunkState` — lifecycle state machine (Unloaded/Loading/Loaded/Unloading)
8//! - Priority queue ordered by distance-to-viewer
9//! - Async-style load queue (single-threaded work queue, drain-per-frame)
10//! - Mesh cache with LRU eviction
11//! - Collision hull generation per chunk
12
13use super::heightmap::{HeightMap, DiamondSquare, HydraulicErosion};
14use std::collections::{HashMap, VecDeque, BinaryHeap};
15use std::cmp::Ordering;
16
17// ── TerrainMesh ──────────────────────────────────────────────────────────────
18
19/// A simple triangle mesh generated from a heightmap.
20#[derive(Clone, Debug)]
21pub struct TerrainMesh {
22    pub vertices: Vec<[f32; 3]>,
23    pub normals:  Vec<[f32; 3]>,
24    pub indices:  Vec<u32>,
25}
26
27// ── ChunkCoord ────────────────────────────────────────────────────────────────
28
29/// Integer 2D address of a terrain chunk in chunk-space.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31pub struct ChunkCoord {
32    pub x: i32,
33    pub z: i32,
34}
35
36impl ChunkCoord {
37    pub const ZERO: Self = Self { x: 0, z: 0 };
38
39    pub fn new(x: i32, z: i32) -> Self { Self { x, z } }
40
41    /// Manhattan distance between two chunk coordinates.
42    pub fn manhattan(self, other: ChunkCoord) -> u32 {
43        ((self.x - other.x).abs() + (self.z - other.z).abs()) as u32
44    }
45
46    /// Euclidean distance (in chunk units) between two chunks.
47    pub fn distance(self, other: ChunkCoord) -> f32 {
48        let dx = (self.x - other.x) as f32;
49        let dz = (self.z - other.z) as f32;
50        (dx*dx + dz*dz).sqrt()
51    }
52
53    /// World-space center of this chunk given `chunk_size` world units per chunk.
54    pub fn world_center(self, chunk_size: f32) -> [f32; 3] {
55        let half = chunk_size * 0.5;
56        [self.x as f32 * chunk_size + half, 0.0, self.z as f32 * chunk_size + half]
57    }
58
59    /// All 8 neighbors (including diagonals).
60    pub fn neighbors_8(self) -> [ChunkCoord; 8] {
61        [
62            ChunkCoord::new(self.x - 1, self.z - 1),
63            ChunkCoord::new(self.x,     self.z - 1),
64            ChunkCoord::new(self.x + 1, self.z - 1),
65            ChunkCoord::new(self.x - 1, self.z),
66            ChunkCoord::new(self.x + 1, self.z),
67            ChunkCoord::new(self.x - 1, self.z + 1),
68            ChunkCoord::new(self.x,     self.z + 1),
69            ChunkCoord::new(self.x + 1, self.z + 1),
70        ]
71    }
72
73    /// Orthogonal neighbors only.
74    pub fn neighbors_4(self) -> [ChunkCoord; 4] {
75        [
76            ChunkCoord::new(self.x,     self.z - 1),
77            ChunkCoord::new(self.x,     self.z + 1),
78            ChunkCoord::new(self.x - 1, self.z),
79            ChunkCoord::new(self.x + 1, self.z),
80        ]
81    }
82}
83
84impl std::fmt::Display for ChunkCoord {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        write!(f, "({}, {})", self.x, self.z)
87    }
88}
89
90// ── ChunkState ────────────────────────────────────────────────────────────────
91
92/// Lifecycle state of a terrain chunk.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum ChunkState {
95    /// Not in memory; no data loaded.
96    Unloaded,
97    /// Queued for loading; data generation has not started.
98    Queued,
99    /// Actively being generated/loaded (in-flight).
100    Loading,
101    /// Fully loaded; heightmap and mesh are available.
102    Loaded,
103    /// Marked for unloading on the next eviction pass.
104    Unloading,
105}
106
107impl ChunkState {
108    pub fn is_usable(self) -> bool {
109        self == ChunkState::Loaded
110    }
111
112    pub fn is_pending(self) -> bool {
113        matches!(self, ChunkState::Queued | ChunkState::Loading)
114    }
115}
116
117// ── TerrainChunkData ──────────────────────────────────────────────────────────
118
119/// All data for a single loaded terrain chunk.
120#[derive(Clone, Debug)]
121pub struct TerrainChunkData {
122    pub coord:        ChunkCoord,
123    pub heightmap:    HeightMap,
124    pub vertices:     Vec<[f32; 3]>,
125    pub collision:    CollisionHull,
126    pub state:        ChunkState,
127    pub seed:         u64,
128    pub last_used_frame: u64,
129}
130
131impl TerrainChunkData {
132    pub fn world_aabb(&self, chunk_size: f32, height_scale: f32) -> Aabb {
133        let x0 = self.coord.x as f32 * chunk_size;
134        let z0 = self.coord.z as f32 * chunk_size;
135        let x1 = x0 + chunk_size;
136        let z1 = z0 + chunk_size;
137        let y_min = self.heightmap.data.iter().cloned().fold(f32::INFINITY,    f32::min) * height_scale;
138        let y_max = self.heightmap.data.iter().cloned().fold(f32::NEG_INFINITY, f32::max) * height_scale;
139        Aabb { min: [x0, y_min, z0], max: [x1, y_max, z1] }
140    }
141}
142
143// ── Axis-Aligned Bounding Box ─────────────────────────────────────────────────
144
145/// Axis-aligned bounding box in world space.
146#[derive(Clone, Debug)]
147pub struct Aabb {
148    pub min: [f32; 3],
149    pub max: [f32; 3],
150}
151
152impl Aabb {
153    pub fn center(&self) -> [f32; 3] {
154        [
155            (self.min[0] + self.max[0]) * 0.5,
156            (self.min[1] + self.max[1]) * 0.5,
157            (self.min[2] + self.max[2]) * 0.5,
158        ]
159    }
160
161    pub fn contains_xz(&self, x: f32, z: f32) -> bool {
162        x >= self.min[0] && x <= self.max[0] &&
163        z >= self.min[2] && z <= self.max[2]
164    }
165
166    pub fn intersects(&self, other: &Aabb) -> bool {
167        self.min[0] <= other.max[0] && self.max[0] >= other.min[0] &&
168        self.min[1] <= other.max[1] && self.max[1] >= other.min[1] &&
169        self.min[2] <= other.max[2] && self.max[2] >= other.min[2]
170    }
171}
172
173// ── Collision Hull ────────────────────────────────────────────────────────────
174
175/// A simplified collision representation for a terrain chunk.
176///
177/// Uses a low-resolution height grid suitable for physics queries.
178#[derive(Clone, Debug)]
179pub struct CollisionHull {
180    /// Low-res height grid (collision_res x collision_res cells).
181    pub heights:        Vec<f32>,
182    pub resolution:     usize,
183    /// World width/height covered by this hull.
184    pub chunk_size:     f32,
185    pub height_scale:   f32,
186}
187
188impl CollisionHull {
189    /// Generate a collision hull from a heightmap.
190    ///
191    /// `resolution` controls how many cells wide the collision grid is.
192    /// Typically 8–32 for performance.
193    pub fn generate(heightmap: &HeightMap, chunk_size: f32, height_scale: f32, resolution: usize) -> Self {
194        let res = resolution.max(2);
195        let mut heights = Vec::with_capacity(res * res);
196
197        for row in 0..res {
198            for col in 0..res {
199                let fx = col as f32 / (res - 1).max(1) as f32 * (heightmap.width  - 1) as f32;
200                let fy = row as f32 / (res - 1).max(1) as f32 * (heightmap.height - 1) as f32;
201                heights.push(heightmap.sample_bilinear(fx, fy) * height_scale);
202            }
203        }
204
205        Self { heights, resolution: res, chunk_size, height_scale }
206    }
207
208    /// Sample height at world-space (x, z) relative to chunk origin.
209    pub fn height_at_local(&self, lx: f32, lz: f32) -> f32 {
210        let fx = (lx / self.chunk_size * (self.resolution - 1) as f32).clamp(0.0, (self.resolution - 1) as f32);
211        let fz = (lz / self.chunk_size * (self.resolution - 1) as f32).clamp(0.0, (self.resolution - 1) as f32);
212        let col = fx as usize;
213        let row = fz as usize;
214        let tx = fx - col as f32;
215        let tz = fz - row as f32;
216        let col1 = (col + 1).min(self.resolution - 1);
217        let row1 = (row + 1).min(self.resolution - 1);
218        let h00 = self.heights[row  * self.resolution + col];
219        let h10 = self.heights[row  * self.resolution + col1];
220        let h01 = self.heights[row1 * self.resolution + col];
221        let h11 = self.heights[row1 * self.resolution + col1];
222        let h0 = h00 + (h10 - h00) * tx;
223        let h1 = h01 + (h11 - h01) * tx;
224        h0 + (h1 - h0) * tz
225    }
226
227    /// Returns a set of triangles for this hull (for physics engine submission).
228    pub fn triangles(&self) -> Vec<[[f32; 3]; 3]> {
229        let res = self.resolution;
230        let cell_w = self.chunk_size / (res - 1).max(1) as f32;
231        let mut tris = Vec::with_capacity((res - 1) * (res - 1) * 2);
232
233        for row in 0..res.saturating_sub(1) {
234            for col in 0..res.saturating_sub(1) {
235                let x0 = col as f32 * cell_w;
236                let x1 = (col + 1) as f32 * cell_w;
237                let z0 = row as f32 * cell_w;
238                let z1 = (row + 1) as f32 * cell_w;
239                let h00 = self.heights[row     * res + col];
240                let h10 = self.heights[row     * res + col + 1];
241                let h01 = self.heights[(row+1) * res + col];
242                let h11 = self.heights[(row+1) * res + col + 1];
243
244                tris.push([[x0,h00,z0], [x0,h01,z1], [x1,h10,z0]]);
245                tris.push([[x1,h10,z0], [x0,h01,z1], [x1,h11,z1]]);
246            }
247        }
248
249        tris
250    }
251}
252
253// ── Priority Queue Item ───────────────────────────────────────────────────────
254
255/// An item in the chunk load priority queue.
256#[derive(Clone, Debug)]
257struct PriorityItem {
258    coord:    ChunkCoord,
259    priority: u32, // Higher = more urgent
260}
261
262impl PartialEq for PriorityItem {
263    fn eq(&self, other: &Self) -> bool { self.priority == other.priority }
264}
265
266impl Eq for PriorityItem {}
267
268impl PartialOrd for PriorityItem {
269    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
270        Some(self.cmp(other))
271    }
272}
273
274impl Ord for PriorityItem {
275    fn cmp(&self, other: &Self) -> Ordering {
276        self.priority.cmp(&other.priority)
277    }
278}
279
280// ── Chunk Generator ───────────────────────────────────────────────────────────
281
282/// Configuration for chunk generation.
283#[derive(Clone, Debug)]
284pub struct ChunkGenConfig {
285    /// Number of cells per side (must be 2^n + 1 for diamond-square).
286    pub cells_per_chunk:  usize,
287    /// World units per chunk.
288    pub chunk_size:       f32,
289    /// World height scale (multiplier applied to [0,1] heights).
290    pub height_scale:     f32,
291    /// Diamond-square roughness.
292    pub roughness:        f32,
293    /// Number of hydraulic erosion iterations.
294    pub erosion_iters:    u32,
295    /// Number of LOD levels to generate.
296    pub lod_levels:       u32,
297    /// Resolution of the collision hull.
298    pub collision_res:    usize,
299    /// Base RNG seed — combined with coord for per-chunk seed.
300    pub base_seed:        u64,
301}
302
303impl Default for ChunkGenConfig {
304    fn default() -> Self {
305        Self {
306            cells_per_chunk: 65,
307            chunk_size:      128.0,
308            height_scale:    80.0,
309            roughness:       0.55,
310            erosion_iters:   200,
311            lod_levels:      4,
312            collision_res:   16,
313            base_seed:       0xdeadbeef_cafebabe,
314        }
315    }
316}
317
318/// Generates terrain chunk data synchronously.
319pub struct ChunkGenerator {
320    pub config: ChunkGenConfig,
321}
322
323impl ChunkGenerator {
324    pub fn new(config: ChunkGenConfig) -> Self { Self { config } }
325
326    /// Derive a deterministic seed for a coordinate.
327    fn chunk_seed(&self, coord: ChunkCoord) -> u64 {
328        let mut h = self.config.base_seed;
329        h ^= (coord.x as i64 as u64).wrapping_mul(0x9e3779b97f4a7c15);
330        h ^= (coord.z as i64 as u64).wrapping_mul(0x6c62272e07bb0142);
331        h ^= h >> 33;
332        h = h.wrapping_mul(0xff51afd7ed558ccd);
333        h ^= h >> 33;
334        h
335    }
336
337    /// Generate a complete chunk.
338    pub fn generate(&self, coord: ChunkCoord) -> TerrainChunkData {
339        let seed = self.chunk_seed(coord);
340
341        // Generate heightmap via diamond-square
342        let mut hm = DiamondSquare::generate(self.config.cells_per_chunk, self.config.roughness, seed);
343
344        // Apply hydraulic erosion
345        if self.config.erosion_iters > 0 {
346            HydraulicErosion::erode(
347                &mut hm,
348                self.config.erosion_iters as usize,
349                0.01,  // rain_amount
350                4.0,   // sediment_capacity
351                0.01,  // evaporation
352                seed ^ 0x1234,
353            );
354        }
355
356        let scale = self.config.chunk_size / (self.config.cells_per_chunk - 1) as f32;
357
358        // Generate simple mesh vertices from heightmap
359        let mut vertices = Vec::new();
360        for y in 0..hm.height {
361            for x in 0..hm.width {
362                vertices.push([
363                    x as f32 * scale,
364                    hm.get(x, y) * self.config.height_scale,
365                    y as f32 * scale,
366                ]);
367            }
368        }
369
370        // Generate collision hull
371        let collision = CollisionHull::generate(
372            &hm,
373            self.config.chunk_size,
374            self.config.height_scale,
375            self.config.collision_res,
376        );
377
378        TerrainChunkData {
379            coord,
380            heightmap: hm,
381            vertices,
382            collision,
383            state: ChunkState::Loaded,
384            seed,
385            last_used_frame: 0,
386        }
387    }
388}
389
390// ── Mesh Cache ────────────────────────────────────────────────────────────────
391
392/// LRU mesh cache for terrain chunks.
393///
394/// Evicts least-recently-used chunks when capacity is exceeded.
395pub struct MeshCache {
396    pub capacity: usize,
397    chunks:       HashMap<ChunkCoord, TerrainChunkData>,
398    /// Access order: front = most recent, back = least recent.
399    access_order: VecDeque<ChunkCoord>,
400}
401
402impl MeshCache {
403    pub fn new(capacity: usize) -> Self {
404        Self {
405            capacity,
406            chunks:       HashMap::with_capacity(capacity + 1),
407            access_order: VecDeque::with_capacity(capacity + 1),
408        }
409    }
410
411    /// Insert or update a chunk. Evicts LRU if over capacity.
412    pub fn insert(&mut self, data: TerrainChunkData) {
413        let coord = data.coord;
414        self.chunks.insert(coord, data);
415        self.touch(coord);
416        self.evict_if_needed();
417    }
418
419    /// Get a reference to a chunk, updating its LRU position.
420    pub fn get(&mut self, coord: ChunkCoord) -> Option<&TerrainChunkData> {
421        if self.chunks.contains_key(&coord) {
422            self.touch(coord);
423            self.chunks.get(&coord)
424        } else {
425            None
426        }
427    }
428
429    /// Get without updating LRU (read-only peek).
430    pub fn peek(&self, coord: ChunkCoord) -> Option<&TerrainChunkData> {
431        self.chunks.get(&coord)
432    }
433
434    /// Remove a chunk explicitly.
435    pub fn remove(&mut self, coord: ChunkCoord) -> Option<TerrainChunkData> {
436        self.access_order.retain(|c| *c != coord);
437        self.chunks.remove(&coord)
438    }
439
440    pub fn contains(&self, coord: ChunkCoord) -> bool {
441        self.chunks.contains_key(&coord)
442    }
443
444    pub fn len(&self) -> usize { self.chunks.len() }
445    pub fn is_empty(&self) -> bool { self.chunks.is_empty() }
446
447    /// Iterate over all loaded chunks.
448    pub fn iter(&self) -> impl Iterator<Item = (&ChunkCoord, &TerrainChunkData)> {
449        self.chunks.iter()
450    }
451
452    fn touch(&mut self, coord: ChunkCoord) {
453        self.access_order.retain(|c| *c != coord);
454        self.access_order.push_front(coord);
455    }
456
457    fn evict_if_needed(&mut self) {
458        while self.chunks.len() > self.capacity {
459            if let Some(lru) = self.access_order.pop_back() {
460                self.chunks.remove(&lru);
461            } else {
462                break;
463            }
464        }
465    }
466
467    /// Evict all chunks marked as Unloading.
468    pub fn evict_unloading(&mut self) {
469        let to_remove: Vec<ChunkCoord> = self.chunks.iter()
470            .filter(|(_, v)| v.state == ChunkState::Unloading)
471            .map(|(k, _)| *k)
472            .collect();
473        for coord in to_remove {
474            self.remove(coord);
475        }
476    }
477}
478
479// ── Load Queue ────────────────────────────────────────────────────────────────
480
481/// Async-style load queue: accepts load requests, drains N per frame.
482pub struct LoadQueue {
483    heap:       BinaryHeap<PriorityItem>,
484    in_queue:   HashMap<ChunkCoord, u32>, // coord → priority
485}
486
487impl LoadQueue {
488    pub fn new() -> Self {
489        Self {
490            heap:     BinaryHeap::new(),
491            in_queue: HashMap::new(),
492        }
493    }
494
495    /// Enqueue a chunk for loading with a given priority (higher = sooner).
496    pub fn enqueue(&mut self, coord: ChunkCoord, priority: u32) {
497        if let Some(existing) = self.in_queue.get_mut(&coord) {
498            if priority <= *existing { return; }
499            *existing = priority;
500        } else {
501            self.in_queue.insert(coord, priority);
502        }
503        self.heap.push(PriorityItem { coord, priority });
504    }
505
506    /// Dequeue up to `max_count` chunks for loading this frame.
507    pub fn drain(&mut self, max_count: usize) -> Vec<ChunkCoord> {
508        let mut result = Vec::with_capacity(max_count);
509        while result.len() < max_count {
510            match self.heap.pop() {
511                None => break,
512                Some(item) => {
513                    // Skip stale entries (priority changed)
514                    let current = self.in_queue.get(&item.coord).copied().unwrap_or(0);
515                    if current != item.priority { continue; }
516                    self.in_queue.remove(&item.coord);
517                    result.push(item.coord);
518                }
519            }
520        }
521        result
522    }
523
524    /// Remove a chunk from the queue (e.g., it moved out of range).
525    pub fn cancel(&mut self, coord: ChunkCoord) {
526        self.in_queue.remove(&coord);
527        // We leave stale entries in the heap; they'll be skipped in drain()
528    }
529
530    pub fn len(&self) -> usize { self.in_queue.len() }
531    pub fn is_empty(&self) -> bool { self.in_queue.is_empty() }
532}
533
534impl Default for LoadQueue {
535    fn default() -> Self { Self::new() }
536}
537
538// ── LOD Scheduler ─────────────────────────────────────────────────────────────
539
540/// Determines which LOD level a chunk should be rendered at.
541pub struct LodScheduler {
542    /// Distance thresholds for each LOD level.
543    /// `lod_distances[0]` = max distance for LOD 0 (highest detail).
544    pub lod_distances: Vec<f32>,
545}
546
547impl LodScheduler {
548    pub fn new(chunk_size: f32, max_lod: u32) -> Self {
549        let mut dists = Vec::with_capacity(max_lod as usize + 1);
550        for i in 0..=max_lod {
551            dists.push(chunk_size * 2.0f32.powi(i as i32 + 1));
552        }
553        Self { lod_distances: dists }
554    }
555
556    /// Select the appropriate LOD for a chunk at `distance` world units.
557    pub fn select_lod(&self, distance: f32) -> u32 {
558        for (i, &threshold) in self.lod_distances.iter().enumerate() {
559            if distance <= threshold {
560                return i as u32;
561            }
562        }
563        self.lod_distances.len() as u32
564    }
565}
566
567// ── Visibility Set ─────────────────────────────────────────────────────────────
568
569/// Tracks which chunks are visible this frame.
570#[derive(Default)]
571pub struct VisibilitySet {
572    visible:     HashMap<ChunkCoord, u32>, // coord → LOD level
573}
574
575impl VisibilitySet {
576    pub fn new() -> Self { Self::default() }
577
578    pub fn mark_visible(&mut self, coord: ChunkCoord, lod: u32) {
579        self.visible.insert(coord, lod);
580    }
581
582    pub fn is_visible(&self, coord: ChunkCoord) -> bool {
583        self.visible.contains_key(&coord)
584    }
585
586    pub fn lod_for(&self, coord: ChunkCoord) -> Option<u32> {
587        self.visible.get(&coord).copied()
588    }
589
590    pub fn clear(&mut self) { self.visible.clear(); }
591
592    pub fn iter(&self) -> impl Iterator<Item = (&ChunkCoord, &u32)> {
593        self.visible.iter()
594    }
595
596    pub fn len(&self) -> usize { self.visible.len() }
597}
598
599// ── Chunk Streaming Manager ───────────────────────────────────────────────────
600
601/// Configuration for the streaming manager.
602#[derive(Clone, Debug)]
603pub struct StreamingConfig {
604    /// Radius in chunks around the viewer to keep loaded.
605    pub load_radius:      u32,
606    /// Radius at which chunks are unloaded (should be > load_radius).
607    pub unload_radius:    u32,
608    /// Maximum chunks to generate per frame.
609    pub loads_per_frame:  usize,
610    /// Maximum chunks in the mesh cache.
611    pub cache_capacity:   usize,
612    /// Chunk generation configuration.
613    pub gen_config:       ChunkGenConfig,
614}
615
616impl Default for StreamingConfig {
617    fn default() -> Self {
618        Self {
619            load_radius:     6,
620            unload_radius:   10,
621            loads_per_frame: 2,
622            cache_capacity:  200,
623            gen_config:      ChunkGenConfig::default(),
624        }
625    }
626}
627
628/// Runtime statistics for the streaming system.
629#[derive(Clone, Debug, Default)]
630pub struct StreamingStats {
631    pub loaded_chunks:    usize,
632    pub queued_chunks:    usize,
633    pub chunks_loaded_this_frame: usize,
634    pub chunks_unloaded_this_frame: usize,
635    pub cache_hits:       u64,
636    pub cache_misses:     u64,
637    pub frame_count:      u64,
638}
639
640/// The main terrain streaming manager.
641///
642/// Call `update(viewer_pos)` every frame to drive chunk loading/unloading.
643/// Query `height_at(world_x, world_z)` for terrain height at any position.
644pub struct ChunkStreamingManager {
645    pub config:     StreamingConfig,
646    pub stats:      StreamingStats,
647    cache:          MeshCache,
648    load_queue:     LoadQueue,
649    lod_scheduler:  LodScheduler,
650    visibility:     VisibilitySet,
651    generator:      ChunkGenerator,
652    chunk_states:   HashMap<ChunkCoord, ChunkState>,
653    current_frame:  u64,
654}
655
656impl ChunkStreamingManager {
657    pub fn new(config: StreamingConfig) -> Self {
658        let gen = ChunkGenerator::new(config.gen_config.clone());
659        let lod = LodScheduler::new(
660            config.gen_config.chunk_size,
661            config.gen_config.lod_levels,
662        );
663        let cache_cap = config.cache_capacity;
664        Self {
665            config,
666            stats:         StreamingStats::default(),
667            cache:         MeshCache::new(cache_cap),
668            load_queue:    LoadQueue::new(),
669            lod_scheduler: lod,
670            visibility:    VisibilitySet::new(),
671            generator:     gen,
672            chunk_states:  HashMap::new(),
673            current_frame: 0,
674        }
675    }
676
677    /// Update the streaming system for the current frame.
678    ///
679    /// `viewer_world_pos` is the viewer's world-space position.
680    pub fn update(&mut self, viewer_world_pos: [f32; 3]) {
681        self.current_frame += 1;
682        let chunk_size = self.config.gen_config.chunk_size;
683
684        // Determine viewer chunk coordinate
685        let viewer_chunk = ChunkCoord::new(
686            (viewer_world_pos[0] / chunk_size).floor() as i32,
687            (viewer_world_pos[2] / chunk_size).floor() as i32,
688        );
689
690        // ── 1. Visibility culling & LOD selection ─────────────────────────────
691        self.visibility.clear();
692        let lr = self.config.load_radius as i32;
693        for dz in -lr..=lr {
694            for dx in -lr..=lr {
695                let coord = ChunkCoord::new(viewer_chunk.x + dx, viewer_chunk.z + dz);
696                let dist = viewer_chunk.distance(coord) * chunk_size;
697                let lod = self.lod_scheduler.select_lod(dist);
698                self.visibility.mark_visible(coord, lod);
699            }
700        }
701
702        // ── 2. Enqueue missing chunks ─────────────────────────────────────────
703        let mut to_enqueue = Vec::new();
704        for (&coord, _) in self.visibility.iter() {
705            let state = self.chunk_states.get(&coord).copied().unwrap_or(ChunkState::Unloaded);
706            if state == ChunkState::Unloaded {
707                let dist = viewer_chunk.distance(coord);
708                // Priority: inverse distance, integer scaled
709                let priority = (1000.0 / (dist + 1.0)) as u32;
710                to_enqueue.push((coord, priority));
711            }
712        }
713        for (coord, priority) in to_enqueue {
714            self.load_queue.enqueue(coord, priority);
715            self.chunk_states.insert(coord, ChunkState::Queued);
716        }
717
718        // ── 3. Generate chunks from queue ─────────────────────────────────────
719        let to_load = self.load_queue.drain(self.config.loads_per_frame);
720        let mut loaded_count = 0usize;
721        for coord in to_load {
722            let state = self.chunk_states.get(&coord).copied().unwrap_or(ChunkState::Unloaded);
723            if state != ChunkState::Queued { continue; }
724
725            self.chunk_states.insert(coord, ChunkState::Loading);
726            let mut data = self.generator.generate(coord);
727            data.last_used_frame = self.current_frame;
728            self.cache.insert(data);
729            self.chunk_states.insert(coord, ChunkState::Loaded);
730            loaded_count += 1;
731        }
732
733        // ── 4. Mark distant chunks for unloading ──────────────────────────────
734        let unload_r = self.config.unload_radius as i32;
735        let mut to_unload = Vec::new();
736        for (&coord, &state) in &self.chunk_states {
737            if state == ChunkState::Loaded {
738                let dist = (viewer_chunk.x - coord.x).abs().max((viewer_chunk.z - coord.z).abs());
739                if dist > unload_r {
740                    to_unload.push(coord);
741                }
742            }
743        }
744        let unloaded_count = to_unload.len();
745        for coord in to_unload {
746            if let Some(chunk) = self.cache.chunks.get_mut(&coord) {
747                chunk.state = ChunkState::Unloading;
748            }
749            self.chunk_states.insert(coord, ChunkState::Unloading);
750        }
751
752        // ── 5. Evict Unloading chunks ─────────────────────────────────────────
753        self.cache.evict_unloading();
754        self.chunk_states.retain(|_, s| *s != ChunkState::Unloading);
755
756        // ── 6. Update stats ───────────────────────────────────────────────────
757        self.stats.loaded_chunks = self.cache.len();
758        self.stats.queued_chunks = self.load_queue.len();
759        self.stats.chunks_loaded_this_frame   = loaded_count;
760        self.stats.chunks_unloaded_this_frame = unloaded_count;
761        self.stats.frame_count = self.current_frame;
762    }
763
764    /// Query the terrain height at a world-space position.
765    ///
766    /// Returns `None` if the chunk containing this position is not loaded.
767    pub fn height_at(&mut self, world_x: f32, world_z: f32) -> Option<f32> {
768        let chunk_size = self.config.gen_config.chunk_size;
769        let cx = (world_x / chunk_size).floor() as i32;
770        let cz = (world_z / chunk_size).floor() as i32;
771        let coord = ChunkCoord::new(cx, cz);
772
773        if let Some(chunk) = self.cache.get(coord) {
774            self.stats.cache_hits += 1;
775            let local_x = world_x - cx as f32 * chunk_size;
776            let local_z = world_z - cz as f32 * chunk_size;
777            Some(chunk.collision.height_at_local(local_x, local_z))
778        } else {
779            self.stats.cache_misses += 1;
780            None
781        }
782    }
783
784    /// Get the vertex data for a loaded chunk.
785    pub fn mesh_for(&mut self, coord: ChunkCoord) -> Option<&[[f32; 3]]> {
786        if let Some(chunk) = self.cache.get(coord) {
787            Some(&chunk.vertices)
788        } else {
789            None
790        }
791    }
792
793    /// Force-load a chunk immediately (bypasses the queue).
794    pub fn force_load(&mut self, coord: ChunkCoord) {
795        if self.cache.contains(coord) { return; }
796        let mut data = self.generator.generate(coord);
797        data.last_used_frame = self.current_frame;
798        self.cache.insert(data);
799        self.chunk_states.insert(coord, ChunkState::Loaded);
800    }
801
802    /// Returns a reference to a loaded chunk, if present.
803    pub fn get_chunk(&self, coord: ChunkCoord) -> Option<&TerrainChunkData> {
804        self.cache.peek(coord)
805    }
806
807    /// Returns all currently visible chunk coordinates.
808    pub fn visible_coords(&self) -> Vec<ChunkCoord> {
809        self.visibility.iter().map(|(&c, _)| c).collect()
810    }
811
812    /// Returns the state of a chunk.
813    pub fn chunk_state(&self, coord: ChunkCoord) -> ChunkState {
814        self.chunk_states.get(&coord).copied().unwrap_or(ChunkState::Unloaded)
815    }
816}
817
818// ── Prefetcher ────────────────────────────────────────────────────────────────
819
820/// Predicts where the viewer will be and pre-queues chunks.
821pub struct Prefetcher {
822    /// How many frames ahead to predict.
823    pub lookahead_frames: u32,
824    prev_chunk: Option<ChunkCoord>,
825}
826
827impl Prefetcher {
828    pub fn new(lookahead_frames: u32) -> Self {
829        Self { lookahead_frames, prev_chunk: None }
830    }
831
832    /// Given the current viewer chunk, compute which chunks to prefetch.
833    pub fn prefetch_coords(&mut self, current: ChunkCoord, radius: u32) -> Vec<ChunkCoord> {
834        let velocity = match self.prev_chunk {
835            None    => (0, 0),
836            Some(p) => (current.x - p.x, current.z - p.z),
837        };
838        self.prev_chunk = Some(current);
839
840        let look = self.lookahead_frames as i32;
841        let predicted = ChunkCoord::new(
842            current.x + velocity.0 * look,
843            current.z + velocity.1 * look,
844        );
845
846        let r = radius as i32;
847        let mut coords = Vec::new();
848        for dz in -r..=r {
849            for dx in -r..=r {
850                coords.push(ChunkCoord::new(predicted.x + dx, predicted.z + dz));
851            }
852        }
853        coords
854    }
855}
856
857// ── Chunk Serializer ──────────────────────────────────────────────────────────
858
859/// Simple in-memory serializer/deserializer for heightmaps.
860/// (In a real engine, this would write to disk.)
861pub struct ChunkSerializer;
862
863impl ChunkSerializer {
864    /// Serialize a heightmap to a compact byte buffer (f32 little-endian).
865    pub fn serialize_heights(hm: &HeightMap) -> Vec<u8> {
866        let mut out = Vec::with_capacity(8 + hm.data.len() * 4);
867        // Header: width (u32 LE), height (u32 LE)
868        out.extend_from_slice(&(hm.width  as u32).to_le_bytes());
869        out.extend_from_slice(&(hm.height as u32).to_le_bytes());
870        for &v in &hm.data {
871            out.extend_from_slice(&v.to_le_bytes());
872        }
873        out
874    }
875
876    /// Deserialize a heightmap from bytes produced by `serialize_heights`.
877    pub fn deserialize_heights(bytes: &[u8]) -> Option<HeightMap> {
878        if bytes.len() < 8 { return None; }
879        let w = u32::from_le_bytes(bytes[0..4].try_into().ok()?) as usize;
880        let h = u32::from_le_bytes(bytes[4..8].try_into().ok()?) as usize;
881        let expected = 8 + w * h * 4;
882        if bytes.len() < expected { return None; }
883        let mut data = Vec::with_capacity(w * h);
884        for i in 0..w*h {
885            let off = 8 + i * 4;
886            let v = f32::from_le_bytes(bytes[off..off+4].try_into().ok()?);
887            data.push(v);
888        }
889        Some(HeightMap { width: w, height: h, data })
890    }
891}
892
893// ── Tests ─────────────────────────────────────────────────────────────────────
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    #[test]
900    fn test_chunk_coord_distance() {
901        let a = ChunkCoord::new(0, 0);
902        let b = ChunkCoord::new(3, 4);
903        assert!((a.distance(b) - 5.0).abs() < 1e-5);
904    }
905
906    #[test]
907    fn test_chunk_coord_neighbors() {
908        let c = ChunkCoord::new(5, 5);
909        let n4 = c.neighbors_4();
910        assert!(n4.contains(&ChunkCoord::new(5, 4)));
911        assert!(n4.contains(&ChunkCoord::new(5, 6)));
912        assert!(n4.contains(&ChunkCoord::new(4, 5)));
913        assert!(n4.contains(&ChunkCoord::new(6, 5)));
914    }
915
916    #[test]
917    fn test_load_queue_drain() {
918        let mut q = LoadQueue::new();
919        q.enqueue(ChunkCoord::new(0, 0), 100);
920        q.enqueue(ChunkCoord::new(1, 0), 50);
921        q.enqueue(ChunkCoord::new(0, 1), 200);
922        let batch = q.drain(2);
923        assert_eq!(batch.len(), 2);
924        // Highest priority first
925        assert_eq!(batch[0], ChunkCoord::new(0, 1));
926        assert_eq!(batch[1], ChunkCoord::new(0, 0));
927    }
928
929    #[test]
930    fn test_load_queue_cancel() {
931        let mut q = LoadQueue::new();
932        q.enqueue(ChunkCoord::new(0, 0), 100);
933        q.cancel(ChunkCoord::new(0, 0));
934        let batch = q.drain(10);
935        assert!(batch.is_empty());
936    }
937
938    #[test]
939    fn test_mesh_cache_lru_eviction() {
940        let mut cache = MeshCache::new(2);
941        let gen = ChunkGenerator::new(ChunkGenConfig {
942            cells_per_chunk: 9,
943            erosion_iters:   0,
944            lod_levels:      1,
945            ..ChunkGenConfig::default()
946        });
947        cache.insert(gen.generate(ChunkCoord::new(0, 0)));
948        cache.insert(gen.generate(ChunkCoord::new(1, 0)));
949        // Access (0,0) to make it MRU
950        cache.get(ChunkCoord::new(0, 0));
951        // Insert third → (1,0) should be evicted
952        cache.insert(gen.generate(ChunkCoord::new(2, 0)));
953        assert_eq!(cache.len(), 2);
954        assert!(cache.peek(ChunkCoord::new(0, 0)).is_some(), "0,0 should still be in cache");
955        assert!(cache.peek(ChunkCoord::new(1, 0)).is_none(), "1,0 should have been evicted");
956    }
957
958    #[test]
959    fn test_collision_hull_height() {
960        let ds = crate::terrain::heightmap::DiamondSquare::new(7, 0.5);
961        let hm = ds.generate(9);
962        let hull = CollisionHull::generate(&hm, 64.0, 50.0, 8);
963        let h = hull.height_at_local(32.0, 32.0);
964        assert!(h >= 0.0 && h <= 50.0, "height out of range: {h}");
965    }
966
967    #[test]
968    fn test_chunk_generator_output() {
969        let gen = ChunkGenerator::new(ChunkGenConfig {
970            cells_per_chunk: 9,
971            erosion_iters:   0,
972            lod_levels:      2,
973            ..ChunkGenConfig::default()
974        });
975        let chunk = gen.generate(ChunkCoord::new(3, -2));
976        assert_eq!(chunk.coord, ChunkCoord::new(3, -2));
977        assert_eq!(chunk.state, ChunkState::Loaded);
978        assert!(!chunk.vertices.is_empty());
979    }
980
981    #[test]
982    fn test_streaming_manager_basic() {
983        let cfg = StreamingConfig {
984            load_radius:    2,
985            unload_radius:  4,
986            loads_per_frame: 50,
987            cache_capacity: 100,
988            gen_config: ChunkGenConfig {
989                cells_per_chunk: 9,
990                erosion_iters:   0,
991                lod_levels:      1,
992                ..ChunkGenConfig::default()
993            },
994        };
995        let mut mgr = ChunkStreamingManager::new(cfg);
996        mgr.update([0.0, 0.0, 0.0]);
997        mgr.update([0.0, 0.0, 0.0]);
998        assert!(mgr.stats.loaded_chunks > 0, "should have loaded some chunks");
999    }
1000
1001    #[test]
1002    fn test_height_query_after_force_load() {
1003        let cfg = StreamingConfig {
1004            gen_config: ChunkGenConfig {
1005                cells_per_chunk: 9,
1006                erosion_iters:   0,
1007                lod_levels:      1,
1008                ..ChunkGenConfig::default()
1009            },
1010            ..StreamingConfig::default()
1011        };
1012        let mut mgr = ChunkStreamingManager::new(cfg);
1013        let coord = ChunkCoord::new(0, 0);
1014        mgr.force_load(coord);
1015        let h = mgr.height_at(64.0, 64.0);
1016        assert!(h.is_some(), "height query should succeed after force load");
1017        let h = h.unwrap();
1018        assert!(h >= 0.0, "height should be non-negative: {h}");
1019    }
1020
1021    #[test]
1022    fn test_serializer_roundtrip() {
1023        let ds = crate::terrain::heightmap::DiamondSquare::new(42, 0.5);
1024        let hm = ds.generate(17);
1025        let bytes = ChunkSerializer::serialize_heights(&hm);
1026        let hm2 = ChunkSerializer::deserialize_heights(&bytes).expect("deserialize failed");
1027        assert_eq!(hm.width,  hm2.width);
1028        assert_eq!(hm.height, hm2.height);
1029        for (a, b) in hm.data.iter().zip(hm2.data.iter()) {
1030            assert!((a - b).abs() < 1e-6, "roundtrip mismatch: {a} vs {b}");
1031        }
1032    }
1033
1034    #[test]
1035    fn test_lod_scheduler() {
1036        let sched = LodScheduler::new(128.0, 3);
1037        assert_eq!(sched.select_lod(100.0),  0);
1038        assert_eq!(sched.select_lod(300.0),  1);
1039        assert_eq!(sched.select_lod(600.0),  2);
1040        assert_eq!(sched.select_lod(1200.0), 3);
1041    }
1042
1043    #[test]
1044    fn test_prefetcher() {
1045        let mut pf = Prefetcher::new(3);
1046        // No velocity yet
1047        let c0 = pf.prefetch_coords(ChunkCoord::new(0, 0), 1);
1048        assert!(!c0.is_empty());
1049        // Move right: velocity = (1, 0)
1050        let c1 = pf.prefetch_coords(ChunkCoord::new(1, 0), 1);
1051        // Predicted = (1 + 1*3, 0) = (4, 0); with radius 1 → 9 chunks around (4, 0)
1052        assert_eq!(c1.len(), 9);
1053        assert!(c1.contains(&ChunkCoord::new(4, 0)));
1054    }
1055
1056    #[test]
1057    fn test_collision_hull_triangles() {
1058        let ds = crate::terrain::heightmap::DiamondSquare::new(5, 0.4);
1059        let hm = ds.generate(9);
1060        let hull = CollisionHull::generate(&hm, 64.0, 50.0, 4);
1061        let tris = hull.triangles();
1062        // 4x4 grid → 3x3 quads → 9*2 = 18 triangles
1063        assert_eq!(tris.len(), 18);
1064    }
1065
1066    #[test]
1067    fn test_visibility_set() {
1068        let mut vis = VisibilitySet::new();
1069        vis.mark_visible(ChunkCoord::new(1, 2), 0);
1070        vis.mark_visible(ChunkCoord::new(3, 4), 2);
1071        assert!(vis.is_visible(ChunkCoord::new(1, 2)));
1072        assert!(!vis.is_visible(ChunkCoord::new(0, 0)));
1073        assert_eq!(vis.lod_for(ChunkCoord::new(3, 4)), Some(2));
1074        vis.clear();
1075        assert!(!vis.is_visible(ChunkCoord::new(1, 2)));
1076    }
1077
1078    #[test]
1079    fn test_aabb_intersects() {
1080        let a = Aabb { min: [0.0, 0.0, 0.0], max: [10.0, 10.0, 10.0] };
1081        let b = Aabb { min: [5.0, 5.0, 5.0], max: [15.0, 15.0, 15.0] };
1082        let c = Aabb { min: [20.0, 0.0, 0.0], max: [30.0, 10.0, 10.0] };
1083        assert!(a.intersects(&b));
1084        assert!(!a.intersects(&c));
1085    }
1086}