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