rustial_engine/loading_placeholder.rs
1// ---------------------------------------------------------------------------
2//! # Loading placeholders and skeleton rendering
3//!
4//! When tiles are in the visible set but have no data yet, the engine emits
5//! [`LoadingPlaceholder`] descriptors so that renderers can draw styled
6//! rectangles at the tile's world bounds instead of leaving blank gaps.
7//! This matches the Mapbox "skeleton chrome" loading experience.
8//!
9//! ## Data flow
10//!
11//! ```text
12//! VisibleTile { data: None } PlaceholderStyle (per-source config)
13//! | |
14//! +----> PlaceholderGenerator::generate +
15//! |
16//! Vec<LoadingPlaceholder>
17//! |
18//! FrameOutput.placeholders
19//! |
20//! +-----------+-----------+
21//! | |
22//! WGPU renderer Bevy renderer
23//! (solid quad pass) (entity sync)
24//! ```
25//!
26//! ## Animation
27//!
28//! Each placeholder carries an `animation_phase` in `[0.0, 1.0)` that
29//! renderers can use to drive a pulsing opacity or horizontal shimmer
30//! effect. The phase is derived from a monotonic time value so that all
31//! placeholders pulse in sync regardless of spawn order.
32//!
33//! ## Style
34//!
35//! [`PlaceholderStyle`] is a lightweight value-type that controls the
36//! visual appearance. It lives on [`MapState`] (and can be set via
37//! the style document) so that both Bevy and WGPU renderers see the
38//! same configuration.
39//!
40//! [`MapState`]: crate::MapState
41// ---------------------------------------------------------------------------
42
43use crate::tile_manager::VisibleTile;
44use rustial_math::{tile_bounds_world, TileId, WorldBounds};
45
46// ---------------------------------------------------------------------------
47// PlaceholderStyle
48// ---------------------------------------------------------------------------
49
50/// Visual configuration for loading-placeholder tiles.
51///
52/// A single instance is stored on [`MapState`](crate::MapState) and
53/// applies to all sources. All colours use linear RGBA `[f32; 4]`.
54#[derive(Debug, Clone, PartialEq)]
55pub struct PlaceholderStyle {
56 /// Background fill colour for the placeholder quad.
57 ///
58 /// Default: light grey `[0.90, 0.90, 0.90, 1.0]`.
59 pub background_color: [f32; 4],
60
61 /// Colour of optional skeleton-chrome lines drawn inside the
62 /// placeholder to hint at future geometry (roads, blocks, etc.).
63 ///
64 /// Set the alpha to 0 to disable skeleton lines entirely.
65 ///
66 /// Default: slightly darker grey `[0.82, 0.82, 0.82, 1.0]`.
67 pub skeleton_line_color: [f32; 4],
68
69 /// Whether the pulsing shimmer animation is enabled.
70 ///
71 /// When `true`, renderers modulate the placeholder opacity using
72 /// [`LoadingPlaceholder::animation_phase`].
73 ///
74 /// Default: `true`.
75 pub animate: bool,
76
77 /// Speed of the shimmer animation in cycles per second.
78 ///
79 /// Higher values produce a faster pulse. Ignored when `animate`
80 /// is `false`.
81 ///
82 /// Default: `1.2` Hz.
83 pub shimmer_speed: f32,
84
85 /// Peak-to-trough opacity amplitude of the shimmer effect.
86 ///
87 /// A value of `0.15` means the opacity oscillates between
88 /// `1.0 - 0.15 = 0.85` and `1.0`.
89 ///
90 /// Default: `0.15`.
91 pub shimmer_amplitude: f32,
92}
93
94impl Default for PlaceholderStyle {
95 fn default() -> Self {
96 Self {
97 background_color: [0.90, 0.90, 0.90, 1.0],
98 skeleton_line_color: [0.82, 0.82, 0.82, 1.0],
99 animate: true,
100 shimmer_speed: 1.2,
101 shimmer_amplitude: 0.15,
102 }
103 }
104}
105
106impl PlaceholderStyle {
107 /// Create a placeholder style with the default settings.
108 pub fn new() -> Self {
109 Self::default()
110 }
111
112 /// Builder: set the background fill colour.
113 pub fn with_background_color(mut self, color: [f32; 4]) -> Self {
114 self.background_color = color;
115 self
116 }
117
118 /// Builder: set the skeleton line colour.
119 pub fn with_skeleton_line_color(mut self, color: [f32; 4]) -> Self {
120 self.skeleton_line_color = color;
121 self
122 }
123
124 /// Builder: enable or disable shimmer animation.
125 pub fn with_animate(mut self, animate: bool) -> Self {
126 self.animate = animate;
127 self
128 }
129
130 /// Builder: set the shimmer animation speed in Hz.
131 pub fn with_shimmer_speed(mut self, speed: f32) -> Self {
132 self.shimmer_speed = speed;
133 self
134 }
135
136 /// Builder: set the shimmer amplitude.
137 pub fn with_shimmer_amplitude(mut self, amplitude: f32) -> Self {
138 self.shimmer_amplitude = amplitude;
139 self
140 }
141
142 /// Compute the shimmer opacity multiplier for a given animation phase.
143 ///
144 /// Returns a value in `[1.0 - amplitude, 1.0]` when `animate` is
145 /// `true`, or `1.0` when disabled.
146 #[inline]
147 pub fn shimmer_opacity(&self, phase: f32) -> f32 {
148 if !self.animate {
149 return 1.0;
150 }
151 // Smooth pulse using a cosine curve: 1.0 at phase=0, trough at
152 // phase=0.5, back to 1.0 at phase=1.0.
153 let t = (phase * std::f32::consts::TAU).cos(); // [-1, 1]
154 1.0 - self.shimmer_amplitude * 0.5 * (1.0 - t)
155 }
156}
157
158// ---------------------------------------------------------------------------
159// LoadingPlaceholder
160// ---------------------------------------------------------------------------
161
162/// A single loading-placeholder tile emitted for rendering.
163///
164/// Renderers draw a styled rectangle at `bounds` with the colour and
165/// opacity derived from the associated [`PlaceholderStyle`].
166#[derive(Debug, Clone, PartialEq)]
167pub struct LoadingPlaceholder {
168 /// The tile this placeholder stands in for.
169 pub tile: TileId,
170
171 /// World-space bounding box of the tile (Web Mercator meters).
172 pub bounds: WorldBounds,
173
174 /// Animation phase in `[0.0, 1.0)` for the shimmer effect.
175 ///
176 /// Derived from `(time * shimmer_speed) % 1.0` so all placeholders
177 /// pulse in sync.
178 pub animation_phase: f32,
179}
180
181// ---------------------------------------------------------------------------
182// PlaceholderGenerator
183// ---------------------------------------------------------------------------
184
185/// Builds [`LoadingPlaceholder`] entries from the current visible tile set.
186///
187/// Stateless -- call [`generate`](Self::generate) each frame with the
188/// latest visible tiles and a monotonic time value.
189pub struct PlaceholderGenerator;
190
191impl PlaceholderGenerator {
192 /// Scan the visible tile set and emit a [`LoadingPlaceholder`] for
193 /// every tile that has no data.
194 ///
195 /// # Arguments
196 ///
197 /// * `visible_tiles` -- The current frame's visible tile set from
198 /// the tile manager.
199 /// * `style` -- Active placeholder style (controls animation speed).
200 /// * `time_seconds` -- Monotonic time in seconds (e.g.
201 /// `std::time::Instant::elapsed().as_secs_f64()`). Used to
202 /// compute the shimmer animation phase.
203 pub fn generate(
204 visible_tiles: &[VisibleTile],
205 style: &PlaceholderStyle,
206 time_seconds: f64,
207 ) -> Vec<LoadingPlaceholder> {
208 let phase = if style.animate {
209 ((time_seconds * style.shimmer_speed as f64) % 1.0) as f32
210 } else {
211 0.0
212 };
213
214 visible_tiles
215 .iter()
216 .filter(|t| t.data.is_none())
217 .map(|t| LoadingPlaceholder {
218 tile: t.target,
219 bounds: tile_bounds_world(&t.target),
220 animation_phase: phase,
221 })
222 .collect()
223 }
224}
225
226// ---------------------------------------------------------------------------
227// Tests
228// ---------------------------------------------------------------------------
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::tile_source::{DecodedImage, TileData};
234 use rustial_math::TileId;
235 use std::sync::Arc;
236
237 /// Helper: create a `VisibleTile` with no data.
238 fn missing_tile(zoom: u8, x: u32, y: u32) -> VisibleTile {
239 let id = TileId::new(zoom, x, y);
240 VisibleTile {
241 target: id,
242 actual: id,
243 data: None,
244 fade_opacity: 1.0,
245 }
246 }
247
248 /// Helper: create a `VisibleTile` with dummy raster data.
249 fn loaded_tile(zoom: u8, x: u32, y: u32) -> VisibleTile {
250 let id = TileId::new(zoom, x, y);
251 VisibleTile {
252 target: id,
253 actual: id,
254 data: Some(TileData::Raster(DecodedImage {
255 width: 1,
256 height: 1,
257 data: Arc::new(vec![0u8; 4]),
258 })),
259 fade_opacity: 1.0,
260 }
261 }
262
263 // -- PlaceholderStyle ---------------------------------------------------
264
265 #[test]
266 fn default_style_has_expected_values() {
267 let s = PlaceholderStyle::default();
268 assert_eq!(s.background_color, [0.90, 0.90, 0.90, 1.0]);
269 assert!(s.animate);
270 assert!((s.shimmer_speed - 1.2).abs() < 1e-5);
271 }
272
273 #[test]
274 fn shimmer_opacity_is_one_when_disabled() {
275 let s = PlaceholderStyle::new().with_animate(false);
276 assert!((s.shimmer_opacity(0.0) - 1.0).abs() < 1e-6);
277 assert!((s.shimmer_opacity(0.5) - 1.0).abs() < 1e-6);
278 }
279
280 #[test]
281 fn shimmer_opacity_peaks_at_phase_zero() {
282 let s = PlaceholderStyle::new()
283 .with_animate(true)
284 .with_shimmer_amplitude(0.2);
285 // At phase 0 the cosine is 1 -> opacity = 1.0.
286 assert!((s.shimmer_opacity(0.0) - 1.0).abs() < 1e-6);
287 // At phase 0.5 the cosine is -1 -> opacity = 1.0 - 0.2 = 0.8.
288 assert!((s.shimmer_opacity(0.5) - 0.8).abs() < 1e-5);
289 }
290
291 #[test]
292 fn builder_overrides_defaults() {
293 let s = PlaceholderStyle::new()
294 .with_background_color([1.0, 0.0, 0.0, 1.0])
295 .with_skeleton_line_color([0.0, 1.0, 0.0, 1.0])
296 .with_shimmer_speed(2.0)
297 .with_shimmer_amplitude(0.3);
298 assert_eq!(s.background_color, [1.0, 0.0, 0.0, 1.0]);
299 assert_eq!(s.skeleton_line_color, [0.0, 1.0, 0.0, 1.0]);
300 assert!((s.shimmer_speed - 2.0).abs() < 1e-6);
301 assert!((s.shimmer_amplitude - 0.3).abs() < 1e-6);
302 }
303
304 // -- PlaceholderGenerator -----------------------------------------------
305
306 #[test]
307 fn generates_placeholders_for_missing_tiles_only() {
308 let tiles = vec![
309 missing_tile(10, 512, 512),
310 loaded_tile(10, 513, 512),
311 missing_tile(10, 514, 512),
312 ];
313 let style = PlaceholderStyle::default();
314 let placeholders = PlaceholderGenerator::generate(&tiles, &style, 0.0);
315
316 assert_eq!(placeholders.len(), 2);
317 assert_eq!(placeholders[0].tile, TileId::new(10, 512, 512));
318 assert_eq!(placeholders[1].tile, TileId::new(10, 514, 512));
319 }
320
321 #[test]
322 fn generates_nothing_when_all_loaded() {
323 let tiles = vec![loaded_tile(5, 10, 10), loaded_tile(5, 11, 10)];
324 let style = PlaceholderStyle::default();
325 let placeholders = PlaceholderGenerator::generate(&tiles, &style, 1.0);
326
327 assert!(placeholders.is_empty());
328 }
329
330 #[test]
331 fn generates_nothing_for_empty_tile_set() {
332 let style = PlaceholderStyle::default();
333 let placeholders = PlaceholderGenerator::generate(&[], &style, 0.0);
334 assert!(placeholders.is_empty());
335 }
336
337 #[test]
338 fn animation_phase_is_zero_when_disabled() {
339 let tiles = vec![missing_tile(5, 0, 0)];
340 let style = PlaceholderStyle::new().with_animate(false);
341 let placeholders = PlaceholderGenerator::generate(&tiles, &style, 99.9);
342 assert!((placeholders[0].animation_phase).abs() < 1e-6);
343 }
344
345 #[test]
346 fn animation_phase_wraps_around_one() {
347 let tiles = vec![missing_tile(5, 0, 0)];
348 let style = PlaceholderStyle::new()
349 .with_animate(true)
350 .with_shimmer_speed(1.0);
351 // time=2.7, speed=1.0 -> phase = 2.7 % 1.0 = 0.7
352 let placeholders = PlaceholderGenerator::generate(&tiles, &style, 2.7);
353 assert!((placeholders[0].animation_phase - 0.7).abs() < 1e-5);
354 }
355
356 #[test]
357 fn bounds_match_tile_bounds_world() {
358 let tiles = vec![missing_tile(3, 4, 2)];
359 let style = PlaceholderStyle::default();
360 let placeholders = PlaceholderGenerator::generate(&tiles, &style, 0.0);
361
362 let expected = tile_bounds_world(&TileId::new(3, 4, 2));
363 let actual = &placeholders[0].bounds;
364 assert!((actual.min.position.x - expected.min.position.x).abs() < 1e-3);
365 assert!((actual.min.position.y - expected.min.position.y).abs() < 1e-3);
366 assert!((actual.max.position.x - expected.max.position.x).abs() < 1e-3);
367 assert!((actual.max.position.y - expected.max.position.y).abs() < 1e-3);
368 }
369
370 #[test]
371 fn all_placeholders_share_same_phase() {
372 let tiles = vec![
373 missing_tile(5, 0, 0),
374 missing_tile(5, 1, 0),
375 missing_tile(5, 2, 0),
376 ];
377 let style = PlaceholderStyle::default();
378 let placeholders = PlaceholderGenerator::generate(&tiles, &style, 0.42);
379 let p0 = placeholders[0].animation_phase;
380 for ph in &placeholders {
381 assert!((ph.animation_phase - p0).abs() < 1e-6);
382 }
383 }
384}