Skip to main content

concinnity_render/
scene_flow.rs

1//! Platform-agnostic active-scene state and transition logic. Scenes are pure
2//! content groupings; changes are imperative jumps (UI actions, Behaviors), so
3//! this module tracks only which scene is active and drives fade transitions.
4//! The SceneControl trait decouples this module from any specific backend;
5//! callers supply a concrete backend that implements the two mutation methods.
6
7use crate::ecs::asset_id::AssetId;
8use alloc::vec::Vec;
9
10const FADE_HALF_SECS: f32 = 0.3;
11
12/// The active scene and any transition in flight.
13pub struct SceneFlow {
14    /// Every Scene declared in the world, in declaration order.
15    pub scenes: Vec<AssetId>,
16    /// The scene currently active.
17    pub current: AssetId,
18    /// Where the transition fade stands.
19    pub fade: FadePhase,
20}
21
22/// A scene transition's fade phase.
23pub enum FadePhase {
24    /// No transition in flight.
25    None,
26    /// Fading the composited image toward black; next is the scene to activate
27    /// mid-fade.
28    ToBlack {
29        /// When the fade-out started, in world seconds.
30        started_at: f32,
31        /// The scene being faded to.
32        next: AssetId,
33    },
34    /// New scene is active; fading the composited image back from black.
35    FromBlack {
36        /// When the fade-in started, in world seconds.
37        started_at: f32,
38    },
39}
40
41/// Backend operations required to drive scene visibility and fade transitions.
42pub trait SceneControl {
43    /// Show or hide one draw slot.
44    fn update_visibility(&mut self, draw_idx: usize, visible: bool);
45    /// Fade the composited image to black by `fade` in `[0, 1]`: 0 leaves the
46    /// frame untouched, 1 renders it fully black. Applied in the composite pass
47    /// so the whole image fades, not just the pixels no geometry covers.
48    fn set_fade(&mut self, fade: f32);
49}
50
51/// Per-prop scene visibility pairs, flattened so a per-frame refresh during a
52/// fade reuses its buffers instead of allocating a slot list per prop: prop
53/// `i`'s draw slots are the `spans[i]` range of `draws`, its scene is
54/// `scenes[i]` (`None` = always visible).
55#[derive(Default)]
56pub struct SceneVisibility {
57    draws: Vec<usize>,
58    spans: Vec<(u32, u32)>,
59    scenes: Vec<Option<AssetId>>,
60}
61
62impl SceneVisibility {
63    /// Forget every prop, retaining the buffers for the next refresh.
64    pub fn clear(&mut self) {
65        self.draws.clear();
66        self.spans.clear();
67        self.scenes.clear();
68    }
69
70    /// Start the next prop's entry; its draw slots follow via
71    /// [`SceneVisibility::push_draw`].
72    pub fn begin_prop(&mut self, scene: Option<AssetId>) {
73        self.spans.push((self.draws.len() as u32, 0));
74        self.scenes.push(scene);
75    }
76
77    /// Add one draw slot to the prop most recently begun.
78    pub fn push_draw(&mut self, draw_idx: usize) {
79        debug_assert!(!self.spans.is_empty(), "push_draw before begin_prop");
80        self.draws.push(draw_idx);
81        if let Some(span) = self.spans.last_mut() {
82            span.1 += 1;
83        }
84    }
85
86    /// Every prop's `(draw slots, scene)`, in insertion order.
87    pub fn props(&self) -> impl Iterator<Item = (&[usize], Option<AssetId>)> + '_ {
88        self.spans
89            .iter()
90            .zip(self.scenes.iter())
91            .map(|(&(start, len), scene)| {
92                (&self.draws[start as usize..(start + len) as usize], *scene)
93            })
94    }
95}
96
97/// Set draw-object visibility according to which scene is currently active.
98/// Props with no scene association (scene == None) are always visible.
99pub fn set_scene_visibility<B: SceneControl + ?Sized>(
100    visibility: &SceneVisibility,
101    active_scene: AssetId,
102    backend: &mut B,
103) {
104    for (draw_idxs, scene_opt) in visibility.props() {
105        let visible = match scene_opt {
106            None => true,
107            Some(s) => s == active_scene,
108        };
109        for &draw_idx in draw_idxs {
110            backend.update_visibility(draw_idx, visible);
111        }
112    }
113}
114
115/// Advance any in-flight fade transition, updating the composite fade and
116/// switching visibility to the target scene mid-fade.
117pub fn tick_transitions<B: SceneControl + ?Sized>(
118    flow_opt: &mut Option<SceneFlow>,
119    visibility: &SceneVisibility,
120    elapsed: f32,
121    backend: &mut B,
122) {
123    let flow = match flow_opt {
124        Some(f) => f,
125        None => return,
126    };
127
128    match flow.fade {
129        FadePhase::ToBlack { started_at, next } => {
130            let t = ((elapsed - started_at) / FADE_HALF_SECS).clamp(0.0, 1.0);
131            backend.set_fade(t);
132            if t >= 1.0 {
133                flow.current = next;
134                flow.fade = FadePhase::FromBlack {
135                    started_at: elapsed,
136                };
137                set_scene_visibility(visibility, next, backend);
138                tracing::debug!("SceneFlow: switched to scene {}", next);
139            }
140        }
141        FadePhase::FromBlack { started_at } => {
142            let t = ((elapsed - started_at) / FADE_HALF_SECS).clamp(0.0, 1.0);
143            backend.set_fade(1.0 - t);
144            if t >= 1.0 {
145                flow.fade = FadePhase::None;
146            }
147        }
148        FadePhase::None => {}
149    }
150}
151
152/// Imperatively jump to a named scene. Ignored with a warning if the target
153/// scene is not declared, or no scenes exist.
154pub fn jump_to_scene<B: SceneControl + ?Sized>(
155    flow_opt: &mut Option<SceneFlow>,
156    visibility: &SceneVisibility,
157    elapsed: f32,
158    target_scene: AssetId,
159    transition: &str,
160    backend: &mut B,
161) {
162    let flow = match flow_opt {
163        Some(f) => f,
164        None => {
165            tracing::warn!(
166                "SceneCommand: jump to {} ignored -- no Scene assets in world",
167                target_scene
168            );
169            return;
170        }
171    };
172
173    if !flow.scenes.contains(&target_scene) {
174        tracing::warn!(
175            "SceneCommand: jump to {} ignored -- no Scene with that name",
176            target_scene
177        );
178        return;
179    }
180
181    if target_scene == flow.current {
182        return;
183    }
184
185    match transition {
186        "FadeBlack" => {
187            flow.fade = FadePhase::ToBlack {
188                started_at: elapsed,
189                next: target_scene,
190            };
191        }
192        _ => {
193            flow.current = target_scene;
194            flow.fade = FadePhase::None;
195            set_scene_visibility(visibility, target_scene, backend);
196            tracing::debug!("SceneCommand: cut to scene {}", target_scene);
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    use alloc::vec;
206    // Minimal SceneControl implementation that records every call.
207    #[derive(Default)]
208    struct TestBackend {
209        visibility: Vec<(usize, bool)>,
210        fades: Vec<f32>,
211    }
212
213    impl SceneControl for TestBackend {
214        fn update_visibility(&mut self, draw_idx: usize, visible: bool) {
215            self.visibility.push((draw_idx, visible));
216        }
217        fn set_fade(&mut self, fade: f32) {
218            self.fades.push(fade);
219        }
220    }
221
222    fn make_flow(scenes: &[AssetId]) -> SceneFlow {
223        SceneFlow {
224            scenes: scenes.to_vec(),
225            current: scenes[0],
226            fade: FadePhase::None,
227        }
228    }
229
230    fn vis(props: &[(&[usize], Option<AssetId>)]) -> SceneVisibility {
231        let mut v = SceneVisibility::default();
232        for (draws, scene) in props {
233            v.begin_prop(*scene);
234            for &d in *draws {
235                v.push_draw(d);
236            }
237        }
238        v
239    }
240
241    #[test]
242    fn set_visibility_active_scene_visible_others_hidden() {
243        // Three props: one in "a", one with no scene, one in "b".
244        let visibility = vis(&[
245            (&[0], Some(AssetId(0))),
246            (&[1], None),
247            (&[2], Some(AssetId(1))),
248        ]);
249        let mut backend = TestBackend::default();
250        set_scene_visibility(&visibility, AssetId(0), &mut backend);
251
252        assert!(
253            backend.visibility.contains(&(0, true)),
254            "prop in 'a' should be visible"
255        );
256        assert!(
257            backend.visibility.contains(&(1, true)),
258            "scene-less prop always visible"
259        );
260        assert!(
261            backend.visibility.contains(&(2, false)),
262            "prop in 'b' should be hidden"
263        );
264    }
265
266    #[test]
267    fn set_visibility_no_scene_always_visible_regardless_of_active() {
268        let visibility = vis(&[(&[0], None)]);
269        let mut backend = TestBackend::default();
270        set_scene_visibility(&visibility, AssetId(99), &mut backend);
271        assert_eq!(backend.visibility, vec![(0, true)]);
272    }
273
274    #[test]
275    fn tick_without_fade_is_a_no_op() {
276        let mut opt = Some(make_flow(&[AssetId(0), AssetId(1)]));
277        let mut backend = TestBackend::default();
278        tick_transitions(&mut opt, &SceneVisibility::default(), 999.0, &mut backend);
279        assert!(backend.visibility.is_empty());
280        assert!(backend.fades.is_empty());
281        assert_eq!(opt.as_ref().unwrap().current, AssetId(0));
282    }
283
284    #[test]
285    fn tick_fade_to_black_ramps_the_fade_up() {
286        let mut flow = make_flow(&[AssetId(0), AssetId(1)]);
287        flow.fade = FadePhase::ToBlack {
288            started_at: 0.0,
289            next: AssetId(1),
290        };
291        let mut opt = Some(flow);
292        let mut backend = TestBackend::default();
293        // elapsed = FADE_HALF_SECS / 2 → t = 0.5
294        tick_transitions(
295            &mut opt,
296            &SceneVisibility::default(),
297            FADE_HALF_SECS * 0.5,
298            &mut backend,
299        );
300        assert_eq!(backend.fades.len(), 1);
301        assert!(
302            (backend.fades[0] - 0.5).abs() < 1e-5,
303            "half way to black at the midpoint of the first half"
304        );
305        // Still in ToBlack, no scene switch yet.
306        assert!(matches!(
307            opt.as_ref().unwrap().fade,
308            FadePhase::ToBlack { .. }
309        ));
310    }
311
312    #[test]
313    fn tick_fade_to_black_completes_and_enters_from_black() {
314        let mut flow = make_flow(&[AssetId(0), AssetId(1)]);
315        flow.fade = FadePhase::ToBlack {
316            started_at: 0.0,
317            next: AssetId(1),
318        };
319        let mut opt = Some(flow);
320        let mut backend = TestBackend::default();
321        // elapsed = FADE_HALF_SECS → t = 1.0, scene switches
322        tick_transitions(
323            &mut opt,
324            &SceneVisibility::default(),
325            FADE_HALF_SECS,
326            &mut backend,
327        );
328        let f = opt.as_ref().unwrap();
329        assert_eq!(f.current, AssetId(1));
330        assert!(matches!(f.fade, FadePhase::FromBlack { .. }));
331    }
332
333    #[test]
334    fn tick_fade_from_black_clears_the_fade() {
335        let mut flow = make_flow(&[AssetId(0)]);
336        flow.fade = FadePhase::FromBlack { started_at: 0.0 };
337        let mut opt = Some(flow);
338        let mut backend = TestBackend::default();
339        // elapsed = FADE_HALF_SECS → t = 1.0, fade ends
340        tick_transitions(
341            &mut opt,
342            &SceneVisibility::default(),
343            FADE_HALF_SECS,
344            &mut backend,
345        );
346        assert!(matches!(opt.as_ref().unwrap().fade, FadePhase::None));
347        // The last push leaves the image un-faded.
348        assert_eq!(*backend.fades.last().unwrap(), 0.0);
349    }
350
351    // The fade-in half runs the fade back down, so a frame partway through it
352    // is partially, not fully, black.
353    #[test]
354    fn tick_fade_from_black_ramps_the_fade_down() {
355        let mut flow = make_flow(&[AssetId(0)]);
356        flow.fade = FadePhase::FromBlack { started_at: 0.0 };
357        let mut opt = Some(flow);
358        let mut backend = TestBackend::default();
359        tick_transitions(
360            &mut opt,
361            &SceneVisibility::default(),
362            FADE_HALF_SECS * 0.25,
363            &mut backend,
364        );
365        assert_eq!(backend.fades.len(), 1);
366        assert!((backend.fades[0] - 0.75).abs() < 1e-5);
367        assert!(matches!(
368            opt.as_ref().unwrap().fade,
369            FadePhase::FromBlack { .. }
370        ));
371    }
372
373    #[test]
374    fn jump_to_scene_no_flow_is_no_op() {
375        let mut opt: Option<SceneFlow> = None;
376        let mut backend = TestBackend::default();
377        jump_to_scene(
378            &mut opt,
379            &SceneVisibility::default(),
380            0.0,
381            AssetId(99),
382            "Cut",
383            &mut backend,
384        );
385        assert!(backend.visibility.is_empty());
386    }
387
388    #[test]
389    fn jump_to_unknown_scene_is_no_op() {
390        let mut opt = Some(make_flow(&[AssetId(0), AssetId(1)]));
391        let mut backend = TestBackend::default();
392        jump_to_scene(
393            &mut opt,
394            &SceneVisibility::default(),
395            0.0,
396            AssetId(99),
397            "Cut",
398            &mut backend,
399        );
400        assert_eq!(opt.as_ref().unwrap().current, AssetId(0));
401        assert!(backend.visibility.is_empty());
402    }
403
404    #[test]
405    fn jump_to_scene_same_scene_is_no_op() {
406        let mut opt = Some(make_flow(&[AssetId(0), AssetId(1)]));
407        let mut backend = TestBackend::default();
408        jump_to_scene(
409            &mut opt,
410            &SceneVisibility::default(),
411            0.0,
412            AssetId(0),
413            "Cut",
414            &mut backend,
415        );
416        assert_eq!(opt.as_ref().unwrap().current, AssetId(0));
417        assert!(backend.visibility.is_empty());
418    }
419
420    #[test]
421    fn jump_to_scene_cut_switches_immediately() {
422        let visibility = vis(&[(&[0], Some(AssetId(0))), (&[1], Some(AssetId(1)))]);
423        let mut opt = Some(make_flow(&[AssetId(0), AssetId(1)]));
424        let mut backend = TestBackend::default();
425        jump_to_scene(&mut opt, &visibility, 1.0, AssetId(1), "Cut", &mut backend);
426        assert_eq!(opt.as_ref().unwrap().current, AssetId(1));
427        assert!(matches!(opt.as_ref().unwrap().fade, FadePhase::None));
428        assert!(backend.visibility.contains(&(1, true)));
429    }
430
431    #[test]
432    fn jump_to_scene_fade_black_starts_to_black_phase() {
433        let mut opt = Some(make_flow(&[AssetId(0), AssetId(1)]));
434        let mut backend = TestBackend::default();
435        jump_to_scene(
436            &mut opt,
437            &SceneVisibility::default(),
438            5.0,
439            AssetId(1),
440            "FadeBlack",
441            &mut backend,
442        );
443        // current not changed yet; scene switches mid-fade
444        assert_eq!(opt.as_ref().unwrap().current, AssetId(0));
445        assert!(matches!(
446            opt.as_ref().unwrap().fade,
447            FadePhase::ToBlack { started_at, next } if (started_at - 5.0).abs() < 1e-6 && next == AssetId(1)
448        ));
449    }
450}