Skip to main content

rustial_engine/map_state/
tile_selection.rs

1use super::*;
2
3impl MapState {
4    /// Run just the terrain update (used by tests and manual pipelines).
5    pub fn update_terrain(&mut self) {
6        if self.terrain.enabled() {
7            let cam_world = self.mercator_camera_world();
8            let meshes = self.terrain.update(
9                &self.viewport_bounds,
10                self.zoom_level,
11                cam_world,
12                self.camera.projection(),
13                self.camera.distance(),
14                self.camera.pitch(),
15            );
16            self.terrain_meshes = Arc::new(meshes);
17            self.hillshade_rasters = Arc::new(self.terrain.visible_hillshade_rasters().to_vec());
18
19            // Report terrain DEM demand to the coordinator.
20            self.request_coordinator
21                .report_demand(SourcePriority::Terrain, self.terrain.pending_count());
22        }
23    }
24
25    /// Determine the terrain tile set for the current frame.
26    ///
27    /// In steep-pitch terrain views, terrain should cover both the tile layer's
28    /// selected raster targets and the stricter uncapped terrain footprint so
29    /// mesh coverage does not clip at the frustum edge.
30    ///
31    /// When the camera is highly pitched the visible ground stretches far
32    /// toward the horizon.  To avoid abrupt clipping where terrain tiles
33    /// end, we supplement the base-zoom tiles with progressively coarser
34    /// "horizon fill" tiles at lower zooms.
35    pub(super) fn desired_terrain_tiles(&self) -> Option<Vec<rustial_math::TileId>> {
36        let use_covering = self.terrain.enabled()
37            && self.camera.pitch() > 0.3
38            && self.camera.mode() == crate::camera::CameraMode::Perspective;
39
40        if !use_covering || self.visible_tiles.is_empty() {
41            return None;
42        }
43
44        // Build a set of all raster tile actuals (with loaded data) so we
45        // can verify that every candidate terrain tile has an ancestor
46        // with a raster texture available.  This prevents spawning terrain
47        // meshes that would render with the white placeholder because the
48        // covering algorithm did not select a raster tile for that area
49        // (e.g. tiles in the viewport-overscan zone beyond the frustum).
50        let raster_actuals: HashSet<TileId> = self
51            .visible_tiles
52            .iter()
53            .filter(|vt| vt.data.is_some())
54            .map(|vt| vt.actual)
55            .collect();
56
57        let has_raster_ancestor = |tile: &TileId| -> bool {
58            // Exact match among raster targets.
59            if raster_actuals.contains(tile) {
60                return true;
61            }
62            // Walk up the parent chain looking for a loaded ancestor.
63            let mut current = *tile;
64            for _ in 0..8u8 {
65                let Some(parent) = current.parent() else {
66                    break;
67                };
68                if raster_actuals.contains(&parent) {
69                    return true;
70                }
71                current = parent;
72            }
73            false
74        };
75
76        let mut terrain_tiles: Vec<rustial_math::TileId> =
77            self.visible_tiles.iter().map(|tile| tile.target).collect();
78
79        let strict_tiles = if let Some(view) = self.camera.flat_tile_view() {
80            visible_tiles_flat_view_with_config(
81                &self.viewport_bounds,
82                self.zoom_level,
83                &view,
84                &FlatTileSelectionConfig {
85                    footprint_pitch_threshold_rad: 0.0,
86                    footprint_min_tiles: 0,
87                    ..FlatTileSelectionConfig::default()
88                },
89            )
90        } else {
91            visible_tiles(&self.viewport_bounds, self.zoom_level)
92        };
93
94        let mut seen = HashSet::with_capacity(terrain_tiles.len() + strict_tiles.len());
95        terrain_tiles.retain(|tile| seen.insert(*tile));
96        for tile in strict_tiles {
97            if seen.insert(tile) && has_raster_ancestor(&tile) {
98                terrain_tiles.push(tile);
99            }
100        }
101
102        // Near-field guarantee: at steep pitch the footprint polygon can
103        // clip tiles at the bottom viewport corners. Ensure tiles around
104        // the camera eye's ground projection are always included so the
105        // near-field terrain is never missing.
106        if self.camera.pitch() > 0.4 {
107            let target_world = self.camera.target_world();
108            let eye = target_world + self.camera.eye_offset();
109            let eye_ground = WorldCoord::new(eye.x, eye.y, 0.0);
110
111            // Radius proportional to the camera altitude, covering at
112            // least a few tiles in every direction below the eye.
113            let altitude = (eye.z - target_world.z).abs().max(1.0);
114            let half = altitude * 1.5;
115            let near_bounds = WorldBounds::new(
116                WorldCoord::new(
117                    eye_ground.position.x - half,
118                    eye_ground.position.y - half,
119                    0.0,
120                ),
121                WorldCoord::new(
122                    eye_ground.position.x + half,
123                    eye_ground.position.y + half,
124                    0.0,
125                ),
126            );
127            for tile in visible_tiles(&near_bounds, self.zoom_level) {
128                if seen.insert(tile) && has_raster_ancestor(&tile) {
129                    terrain_tiles.push(tile);
130                }
131            }
132        }
133
134        let cam = self.mercator_camera_world();
135
136        // Cap base-zoom terrain tiles, keeping closest first. The cap
137        // must not fall below the strict footprint or steep views will
138        // visibly clip terrain against the lower raster layer.
139        let max_base_tiles = terrain_base_tile_budget(terrain_tiles.len());
140        if terrain_tiles.len() > max_base_tiles {
141            terrain_tiles.sort_by(|a, b| {
142                let da = tile_center_dist_sq(a, cam);
143                let db = tile_center_dist_sq(b, cam);
144                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
145            });
146            terrain_tiles.truncate(max_base_tiles);
147        }
148
149        // Horizon fill: at steep pitch, add coarser tiles to cover the
150        // distant ground that base-zoom tiles don't reach.  Each step
151        // reduces zoom by 2, so a single tile covers 4× the area.
152        // Only non-overlapping tiles are added (tiles that are NOT
153        // ancestors of any existing tile).
154        if self.camera.pitch() > 0.5 && self.zoom_level > 2 {
155            let base_tiles: Vec<TileId> = terrain_tiles.clone();
156            let is_ancestor_of_existing = |candidate: &TileId| -> bool {
157                base_tiles.iter().any(|t| {
158                    if t.zoom <= candidate.zoom {
159                        return false;
160                    }
161                    let dz = t.zoom - candidate.zoom;
162                    (t.x >> dz) == candidate.x && (t.y >> dz) == candidate.y
163                })
164            };
165            let mut horizon_budget =
166                terrain_horizon_tile_budget(max_base_tiles, self.camera.pitch());
167            let mut hz = self.zoom_level.saturating_sub(2);
168            while hz > 0 && horizon_budget > 0 {
169                let coarse = visible_tiles(&self.viewport_bounds, hz);
170                let mut coarse_sorted: Vec<_> = coarse
171                    .into_iter()
172                    .filter(|t| {
173                        !seen.contains(t) && !is_ancestor_of_existing(t) && has_raster_ancestor(t)
174                    })
175                    .map(|t| {
176                        let d = tile_center_dist_sq(&t, cam);
177                        (t, d)
178                    })
179                    .collect();
180                // Farthest first — fill the horizon end.
181                coarse_sorted
182                    .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
183                let take = coarse_sorted.len().min(horizon_budget);
184                for (t, _) in coarse_sorted.into_iter().take(take) {
185                    if seen.insert(t) {
186                        terrain_tiles.push(t);
187                        horizon_budget -= 1;
188                    }
189                }
190                hz = hz.saturating_sub(2);
191            }
192        }
193
194        Some(terrain_tiles)
195    }
196
197    /// Run the terrain update using a pre-computed tile set from the tile
198    /// layer's covering-tiles selection.  This ensures the terrain manager
199    /// generates meshes for exactly the same tiles that have raster textures
200    /// available, preventing untextured grey patches.
201    pub(super) fn update_terrain_with_tiles(&mut self, tiles: &[rustial_math::TileId]) {
202        if self.terrain.enabled() {
203            let meshes =
204                self.terrain
205                    .update_with_tiles(tiles, self.zoom_level, self.camera.projection());
206            self.terrain_meshes = Arc::new(meshes);
207            self.hillshade_rasters = Arc::new(self.terrain.visible_hillshade_rasters().to_vec());
208
209            // Report terrain DEM demand to the coordinator.
210            self.request_coordinator
211                .report_demand(SourcePriority::Terrain, self.terrain.pending_count());
212        }
213    }
214
215    /// Lightweight per-frame tile layer update: poll completed HTTP
216    /// responses, recompute the visible tile set for the current camera
217    /// position, and issue new tile requests.
218    ///
219    /// This is separated from the heavier terrain/vector/symbol/model
220    /// processing so it can run unconditionally every frame, even during
221    /// animation when the heavy layers are throttled.
222    pub(super) fn update_tile_layers(&mut self) {
223        use crate::layer::LayerKind;
224        use crate::layers::TileLayer;
225
226        // Begin a new coordinator frame so per-source budgets are
227        // allocated from the global cap before any source updates.
228        self.request_coordinator.begin_frame();
229
230        let zoom_level = self.zoom_level;
231        let camera_world = self.mercator_camera_world();
232        let flat_view = self.camera.flat_tile_view();
233        let camera_distance = self.camera.distance();
234        let viewport_bounds = self.viewport_bounds;
235        let predicted_viewport_bounds = *self.predicted_viewport_bounds();
236        let predicted_target_world = self.camera_motion_state.predicted_target_world;
237        let should_speculatively_prefetch =
238            self.camera_motion_state.pan_velocity_world.length_squared() > 1e-9;
239        let zoom_prefetch_direction = zoom_prefetch_direction(self.camera_zoom_delta);
240        let prefetch_route = self.prefetch_route.clone();
241
242        let use_covering = self.terrain.enabled()
243            && self.camera.pitch() > 0.3
244            && self.camera.mode() == crate::camera::CameraMode::Perspective;
245
246        let covering_params = if use_covering {
247            let fzoom = self.fractional_zoom();
248            self.camera.covering_camera(fzoom).map(|cam| {
249                let opts = rustial_math::CoveringTilesOptions {
250                    min_zoom: 0,
251                    max_zoom: rustial_math::MAX_ZOOM,
252                    round_zoom: false,
253                    tile_size: 256,
254                    max_tiles: 512,
255                    allow_variable_zoom: true,
256                    render_world_copies: true,
257                };
258                (cam, opts)
259            })
260        } else {
261            None
262        };
263
264        // -- Raster tile layers -------------------------------------------
265
266        // Apply the coordinator's raster budget to each tile layer.
267        let raster_budget = self.request_coordinator.budget_for(SourcePriority::Raster);
268
269        for layer in self.layers.iter_mut() {
270            if !layer.visible() {
271                continue;
272            }
273            if layer.kind() == LayerKind::Tile {
274                if let Some(tile_layer) = layer.as_any_mut().downcast_mut::<TileLayer>() {
275                    // Temporarily apply the coordinator's per-frame budget.
276                    let mut sel = tile_layer.selection_config().clone();
277                    sel.max_requests_per_frame = raster_budget;
278                    tile_layer.set_selection_config(sel);
279
280                    if let (Some(frustum), Some((ref cam, ref opts))) =
281                        (self.frustum.as_ref(), covering_params.as_ref())
282                    {
283                        tile_layer.update_with_covering(frustum, cam, opts, camera_world);
284                    } else {
285                        tile_layer.update_with_view(
286                            &viewport_bounds,
287                            zoom_level,
288                            camera_world,
289                            camera_distance,
290                            flat_view.as_ref(),
291                        );
292                    }
293
294                    // Report raster results to the coordinator.
295                    let stats = tile_layer.last_selection_stats();
296                    let desired: HashSet<TileId> = tile_layer
297                        .visible_tiles()
298                        .tiles
299                        .iter()
300                        .map(|t| t.target)
301                        .collect();
302                    let mut total_raster_requested = stats.requested_tiles;
303                    let raster_pending_demand =
304                        stats.fallback_visible_tiles + stats.missing_visible_tiles;
305                    let mut remaining_speculative_budget =
306                        speculative_prefetch_budget(raster_budget, stats.requested_tiles);
307
308                    // 1. Viewport-prediction prefetch (pan momentum).
309                    if remaining_speculative_budget > 0 && should_speculatively_prefetch {
310                        let prefetched = tile_layer.prefetch_with_view(
311                            &predicted_viewport_bounds,
312                            zoom_level,
313                            (predicted_target_world.x, predicted_target_world.y),
314                            None,
315                            remaining_speculative_budget,
316                        );
317                        total_raster_requested += prefetched;
318                        remaining_speculative_budget =
319                            remaining_speculative_budget.saturating_sub(prefetched);
320                    }
321
322                    // 2. Zoom-direction prefetch.
323                    if remaining_speculative_budget > 0 {
324                        if let Some(direction) = zoom_prefetch_direction {
325                            let prefetched = tile_layer.prefetch_zoom_direction(
326                                camera_world,
327                                direction,
328                                remaining_speculative_budget,
329                            );
330                            total_raster_requested += prefetched;
331                            remaining_speculative_budget =
332                                remaining_speculative_budget.saturating_sub(prefetched);
333                        }
334                    }
335
336                    // 3. Route-aware prefetch.
337                    if remaining_speculative_budget > 0 {
338                        if let Some(ref route) = prefetch_route {
339                            let prefetched = tile_layer.prefetch_route(
340                                route,
341                                zoom_level,
342                                camera_world,
343                                remaining_speculative_budget,
344                            );
345                            total_raster_requested += prefetched;
346                        }
347                    }
348
349                    self.request_coordinator.report(
350                        SourcePriority::Raster,
351                        desired,
352                        total_raster_requested,
353                        raster_pending_demand,
354                    );
355
356                    self.visible_tiles = Arc::new(tile_layer.visible_tiles().tiles.clone());
357                }
358            }
359        }
360
361        // -- Streamed vector source layers --------------------------------
362
363        self.update_streamed_vector_source_layers(
364            zoom_level,
365            camera_world,
366            camera_distance,
367            &viewport_bounds,
368            flat_view.as_ref(),
369            covering_params.as_ref(),
370        );
371
372        // Finish the coordinator frame and compute cross-source stats.
373        self.request_coordinator.finish_frame();
374    }
375
376    pub(super) fn update_streamed_vector_source_layers(
377        &mut self,
378        zoom_level: u8,
379        camera_world: (f64, f64),
380        camera_distance: f64,
381        viewport_bounds: &WorldBounds,
382        flat_view: Option<&rustial_math::FlatTileView>,
383        covering_params: Option<&(
384            rustial_math::CoveringCamera,
385            rustial_math::CoveringTilesOptions,
386        )>,
387    ) {
388        // Apply the coordinator's vector budget to each streamed source.
389        // The budget is shared across all vector sources (split evenly).
390        let vector_budget = self.request_coordinator.budget_for(SourcePriority::Vector);
391        let source_count = self.streamed_vector_sources.len().max(1);
392        let per_source_budget = if vector_budget == usize::MAX {
393            usize::MAX
394        } else {
395            // Give each vector source a fair share of the vector budget.
396            (vector_budget / source_count).max(1)
397        };
398
399        let mut total_vector_requested = 0usize;
400        let mut all_vector_desired = HashSet::new();
401        let predicted_viewport_bounds = *self.predicted_viewport_bounds();
402        let predicted_target_world = self.camera_motion_state.predicted_target_world;
403        let should_speculatively_prefetch =
404            self.camera_motion_state.pan_velocity_world.length_squared() > 1e-9;
405        let zoom_prefetch_direction = zoom_prefetch_direction(self.camera_zoom_delta);
406        let prefetch_route = self.prefetch_route.clone();
407
408        for source_layer in self.streamed_vector_sources.values_mut() {
409            // Temporarily apply the coordinator's per-source budget.
410            let mut sel = source_layer.selection_config().clone();
411            sel.max_requests_per_frame = per_source_budget;
412            source_layer.set_selection_config(sel);
413
414            if let (Some(frustum), Some((cam, opts))) = (self.frustum.as_ref(), covering_params) {
415                source_layer.update_with_covering(frustum, cam, opts, camera_world);
416            } else {
417                source_layer.update_with_view(
418                    viewport_bounds,
419                    zoom_level,
420                    camera_world,
421                    camera_distance,
422                    flat_view,
423                );
424            }
425
426            // Accumulate vector stats for the coordinator.
427            let stats = source_layer.last_selection_stats();
428            total_vector_requested += stats.requested_tiles;
429            for tile in source_layer.visible_tiles().tiles.iter() {
430                all_vector_desired.insert(tile.target);
431            }
432
433            let mut remaining_speculative_budget =
434                speculative_prefetch_budget(per_source_budget, stats.requested_tiles);
435
436            // 1. Viewport-prediction prefetch (pan momentum).
437            if remaining_speculative_budget > 0 && should_speculatively_prefetch {
438                let prefetched = source_layer.prefetch_with_view(
439                    &predicted_viewport_bounds,
440                    zoom_level,
441                    (predicted_target_world.x, predicted_target_world.y),
442                    None,
443                    remaining_speculative_budget,
444                );
445                total_vector_requested += prefetched;
446                remaining_speculative_budget =
447                    remaining_speculative_budget.saturating_sub(prefetched);
448            }
449
450            // 2. Zoom-direction prefetch.
451            if remaining_speculative_budget > 0 {
452                if let Some(direction) = zoom_prefetch_direction {
453                    let prefetched = source_layer.prefetch_zoom_direction(
454                        camera_world,
455                        direction,
456                        remaining_speculative_budget,
457                    );
458                    total_vector_requested += prefetched;
459                    remaining_speculative_budget =
460                        remaining_speculative_budget.saturating_sub(prefetched);
461                }
462            }
463
464            // 3. Route-aware prefetch.
465            if remaining_speculative_budget > 0 {
466                if let Some(ref route) = prefetch_route {
467                    let prefetched = source_layer.prefetch_route(
468                        route,
469                        zoom_level,
470                        camera_world,
471                        remaining_speculative_budget,
472                    );
473                    total_vector_requested += prefetched;
474                }
475            }
476        }
477
478        // Report aggregate vector results.
479        self.request_coordinator.report(
480            SourcePriority::Vector,
481            all_vector_desired,
482            total_vector_requested,
483            total_vector_requested,
484        );
485    }
486}
487
488/// Squared distance from the centre of a tile to a camera world position.
489fn tile_center_dist_sq(tile: &rustial_math::TileId, cam: (f64, f64)) -> f64 {
490    let b = rustial_math::tile_bounds_world(tile);
491    let cx = (b.min.position.x + b.max.position.x) * 0.5;
492    let cy = (b.min.position.y + b.max.position.y) * 0.5;
493    let dx = cx - cam.0;
494    let dy = cy - cam.1;
495    dx * dx + dy * dy
496}
497
498fn speculative_prefetch_budget(total_budget: usize, visible_requests: usize) -> usize {
499    if total_budget == 0 {
500        return 0;
501    }
502
503    if total_budget == usize::MAX {
504        return DEFAULT_SPECULATIVE_PREFETCH_REQUEST_BUDGET;
505    }
506
507    let remaining = total_budget.saturating_sub(visible_requests);
508    if remaining == 0 {
509        return 0;
510    }
511
512    let speculative_cap =
513        ((total_budget as f64) * SPECULATIVE_PREFETCH_BUDGET_FRACTION).ceil() as usize;
514    remaining.min(speculative_cap.max(1))
515}
516
517fn zoom_prefetch_direction(zoom_delta: f64) -> Option<ZoomPrefetchDirection> {
518    if zoom_delta > ZOOM_DIRECTION_PREFETCH_THRESHOLD {
519        Some(ZoomPrefetchDirection::In)
520    } else if zoom_delta < -ZOOM_DIRECTION_PREFETCH_THRESHOLD {
521        Some(ZoomPrefetchDirection::Out)
522    } else {
523        None
524    }
525}