Skip to main content

qualia_core_db/domains/geospatial/
streaming.rs

1use std::collections::{HashMap, HashSet};
2
3/// Camera pose for frustum-based tile prediction.
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct CameraPose {
6    pub x: f64,
7    pub y: f64,
8    pub z: f64,
9    pub yaw: f64,
10    pub pitch: f64,
11}
12
13/// VRAM / residency budget for a single streaming frame.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct StreamBudget {
16    pub max_tiles: usize,
17    pub timestamp: u64,
18}
19
20/// Output of the scene streaming planner for one camera step.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct StreamPlan {
23    /// Tiles that must be fetched into VRAM (not yet resident).
24    pub required_tiles: Vec<u64>,
25    /// Tiles to evict to honour the budget after loading required tiles.
26    pub evict_tiles: Vec<u64>,
27}
28
29/// Combines tile pyramid indexing, frustum prediction, and VRAM eviction.
30pub struct SceneStreamingPlanner {
31    pub pyramid: TilePyramid,
32    pub predictor: FrustumPredictor,
33    pub eviction: VramBudgetEviction,
34    /// LOD level consulted when resolving predicted tiles.
35    pub active_lod: u8,
36}
37
38impl SceneStreamingPlanner {
39    pub fn new(
40        pyramid: TilePyramid,
41        predictor: FrustumPredictor,
42        max_tiles: usize,
43        active_lod: u8,
44    ) -> Self {
45        Self {
46            pyramid,
47            predictor,
48            eviction: VramBudgetEviction::new(max_tiles),
49            active_lod,
50        }
51    }
52
53    /// Plan which tiles to load and evict for the given camera pose and budget.
54    pub fn plan_stream(&mut self, camera_pose: &CameraPose, budget: &StreamBudget) -> StreamPlan {
55        self.eviction.max_tiles = budget.max_tiles;
56
57        let predicted = self.predictor.predict_intersecting_tiles(
58            camera_pose.x,
59            camera_pose.y,
60            camera_pose.z,
61            camera_pose.yaw,
62            camera_pose.pitch,
63        );
64
65        let mut required_tiles = Vec::new();
66        for tile_id in predicted {
67            if !self.pyramid.has_tile(self.active_lod, tile_id) {
68                continue;
69            }
70            if !self.eviction.active_tiles.contains_key(&tile_id) {
71                required_tiles.push(tile_id);
72            }
73            self.eviction.access_tile(tile_id, budget.timestamp);
74        }
75
76        required_tiles.sort_unstable();
77        required_tiles.dedup();
78
79        let mut evict_tiles = self.eviction.enforce_budget();
80        evict_tiles.sort_unstable();
81
82        StreamPlan {
83            required_tiles,
84            evict_tiles,
85        }
86    }
87}
88
89/// Predicts the next required spatial tiles given a camera pose.
90/// Used for streaming prediction in Phase 6.
91pub struct FrustumPredictor {
92    pub fov_degrees: f64,
93    pub aspect_ratio: f64,
94    pub near_clip: f64,
95    pub far_clip: f64,
96}
97
98impl FrustumPredictor {
99    pub fn new(fov: f64, aspect: f64, near: f64, far: f64) -> Self {
100        Self {
101            fov_degrees: fov,
102            aspect_ratio: aspect,
103            near_clip: near,
104            far_clip: far,
105        }
106    }
107
108    /// Given a camera pose (mocked here as xyz + pitch/yaw), return required tile IDs
109    pub fn predict_intersecting_tiles(
110        &self,
111        camera_x: f64,
112        camera_y: f64,
113        camera_z: f64,
114        _yaw: f64,
115        _pitch: f64,
116    ) -> Vec<u64> {
117        // Structural stub: return a few mock tile IDs around the camera position
118        // In reality, this would intersect a frustum math volume with the TilePyramid.
119        let center_tile = ((camera_x / 100.0).floor() as u64)
120            ^ ((camera_y / 100.0).floor() as u64)
121            ^ ((camera_z / 100.0).floor() as u64);
122        vec![
123            center_tile,
124            center_tile.wrapping_add(1),
125            center_tile.wrapping_sub(1),
126        ]
127    }
128}
129
130/// Eviction policy to manage strict VRAM budgets in 42MB limits.
131pub struct VramBudgetEviction {
132    pub max_tiles: usize,
133    pub active_tiles: HashMap<u64, u64>, // tile_id -> last_access_timestamp
134}
135
136impl VramBudgetEviction {
137    pub fn new(max_tiles: usize) -> Self {
138        Self {
139            max_tiles,
140            active_tiles: HashMap::new(),
141        }
142    }
143
144    pub fn access_tile(&mut self, tile_id: u64, timestamp: u64) {
145        self.active_tiles.insert(tile_id, timestamp);
146    }
147
148    /// Returns a list of tile IDs to evict to stay under the budget.
149    pub fn enforce_budget(&mut self) -> Vec<u64> {
150        if self.active_tiles.len() <= self.max_tiles {
151            return vec![];
152        }
153
154        let mut sorted: Vec<_> = self.active_tiles.iter().collect();
155        // Sort by timestamp ascending (oldest first)
156        sorted.sort_by_key(|&(_, ts)| *ts);
157
158        let to_evict_count = self.active_tiles.len() - self.max_tiles;
159        let mut evicted = Vec::with_capacity(to_evict_count);
160
161        for (id, _) in sorted.into_iter().take(to_evict_count) {
162            evicted.push(*id);
163        }
164
165        for id in &evicted {
166            self.active_tiles.remove(id);
167        }
168
169        evicted
170    }
171}
172
173/// A hierarchical index of .10d asset streaming chunks
174pub struct TilePyramid {
175    pub layers: HashMap<u8, HashSet<u64>>, // LOD level -> active tile IDs
176}
177
178impl TilePyramid {
179    pub fn new() -> Self {
180        Self {
181            layers: HashMap::new(),
182        }
183    }
184
185    pub fn register_tile(&mut self, lod: u8, tile_id: u64) {
186        self.layers.entry(lod).or_default().insert(tile_id);
187    }
188
189    pub fn has_tile(&self, lod: u8, tile_id: u64) -> bool {
190        self.layers
191            .get(&lod)
192            .map_or(false, |s| s.contains(&tile_id))
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn tile_id_at(x: f64, y: f64, z: f64) -> u64 {
201        ((x / 100.0).floor() as u64) ^ ((y / 100.0).floor() as u64) ^ ((z / 100.0).floor() as u64)
202    }
203
204    #[test]
205    fn vram_eviction_drops_oldest_first() {
206        let mut evict = VramBudgetEviction::new(2);
207        evict.access_tile(10, 1);
208        evict.access_tile(20, 2);
209        evict.access_tile(30, 3);
210        let dropped = evict.enforce_budget();
211        assert_eq!(dropped, vec![10]);
212        assert!(!evict.active_tiles.contains_key(&10));
213    }
214
215    #[test]
216    fn scene_streaming_planner_requests_missing_tiles() {
217        let mut pyramid = TilePyramid::new();
218        let pose = CameraPose {
219            x: 150.0,
220            y: 250.0,
221            z: 50.0,
222            yaw: 0.0,
223            pitch: 0.0,
224        };
225        let center = tile_id_at(pose.x, pose.y, pose.z);
226        pyramid.register_tile(0, center);
227        pyramid.register_tile(0, center.wrapping_add(1));
228
229        let mut planner = SceneStreamingPlanner::new(
230            pyramid,
231            FrustumPredictor::new(60.0, 16.0 / 9.0, 0.1, 1000.0),
232            8,
233            0,
234        );
235
236        let plan = planner.plan_stream(
237            &pose,
238            &StreamBudget {
239                max_tiles: 8,
240                timestamp: 100,
241            },
242        );
243
244        assert!(plan.required_tiles.contains(&center));
245        assert!(plan.evict_tiles.is_empty());
246    }
247
248    #[test]
249    fn scene_streaming_planner_evicts_when_over_budget() {
250        let mut pyramid = TilePyramid::new();
251        let pose = CameraPose {
252            x: 100.0,
253            y: 200.0,
254            z: 0.0,
255            yaw: 0.0,
256            pitch: 0.0,
257        };
258        let center = tile_id_at(pose.x, pose.y, pose.z);
259        pyramid.register_tile(0, center);
260        pyramid.register_tile(0, center.wrapping_add(1));
261        pyramid.register_tile(0, center.wrapping_sub(1));
262
263        let mut planner =
264            SceneStreamingPlanner::new(pyramid, FrustumPredictor::new(90.0, 1.0, 0.1, 500.0), 2, 0);
265
266        // Pre-load two tiles so the next prediction forces an eviction.
267        planner.eviction.access_tile(999, 1);
268        planner.eviction.access_tile(888, 2);
269
270        let plan = planner.plan_stream(
271            &pose,
272            &StreamBudget {
273                max_tiles: 2,
274                timestamp: 50,
275            },
276        );
277
278        assert!(!plan.required_tiles.is_empty());
279        assert!(!plan.evict_tiles.is_empty());
280        assert!(plan.evict_tiles.contains(&999));
281    }
282
283    #[test]
284    fn scene_streaming_planner_skips_unregistered_tiles() {
285        let pyramid = TilePyramid::new();
286        let mut planner = SceneStreamingPlanner::new(
287            pyramid,
288            FrustumPredictor::new(60.0, 16.0 / 9.0, 0.1, 1000.0),
289            4,
290            0,
291        );
292        let plan = planner.plan_stream(
293            &CameraPose {
294                x: 0.0,
295                y: 0.0,
296                z: 0.0,
297                yaw: 0.0,
298                pitch: 0.0,
299            },
300            &StreamBudget {
301                max_tiles: 4,
302                timestamp: 1,
303            },
304        );
305        assert!(plan.required_tiles.is_empty());
306    }
307}