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            }
139        }
140        FadePhase::FromBlack { started_at } => {
141            let t = ((elapsed - started_at) / FADE_HALF_SECS).clamp(0.0, 1.0);
142            backend.set_fade(1.0 - t);
143            if t >= 1.0 {
144                flow.fade = FadePhase::None;
145            }
146        }
147        FadePhase::None => {}
148    }
149}
150
151/// Imperatively jump to a named scene. Ignored with a warning if the target
152/// scene is not declared, or no scenes exist.
153pub fn jump_to_scene<B: SceneControl + ?Sized>(
154    flow_opt: &mut Option<SceneFlow>,
155    visibility: &SceneVisibility,
156    elapsed: f32,
157    target_scene: AssetId,
158    transition: &str,
159    backend: &mut B,
160) {
161    let flow = match flow_opt {
162        Some(f) => f,
163        None => {
164            return;
165        }
166    };
167
168    if !flow.scenes.contains(&target_scene) {
169        return;
170    }
171
172    if target_scene == flow.current {
173        return;
174    }
175
176    match transition {
177        "FadeBlack" => {
178            flow.fade = FadePhase::ToBlack {
179                started_at: elapsed,
180                next: target_scene,
181            };
182        }
183        _ => {
184            flow.current = target_scene;
185            flow.fade = FadePhase::None;
186            set_scene_visibility(visibility, target_scene, backend);
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    use alloc::vec;
196    // Minimal SceneControl implementation that records every call.
197    #[derive(Default)]
198    struct TestBackend {
199        visibility: Vec<(usize, bool)>,
200        fades: Vec<f32>,
201    }
202
203    impl SceneControl for TestBackend {
204        fn update_visibility(&mut self, draw_idx: usize, visible: bool) {
205            self.visibility.push((draw_idx, visible));
206        }
207        fn set_fade(&mut self, fade: f32) {
208            self.fades.push(fade);
209        }
210    }
211
212    fn make_flow(scenes: &[AssetId]) -> SceneFlow {
213        SceneFlow {
214            scenes: scenes.to_vec(),
215            current: scenes[0],
216            fade: FadePhase::None,
217        }
218    }
219
220    fn vis(props: &[(&[usize], Option<AssetId>)]) -> SceneVisibility {
221        let mut v = SceneVisibility::default();
222        for (draws, scene) in props {
223            v.begin_prop(*scene);
224            for &d in *draws {
225                v.push_draw(d);
226            }
227        }
228        v
229    }
230
231    #[test]
232    fn set_visibility_active_scene_visible_others_hidden() {
233        // Three props: one in "a", one with no scene, one in "b".
234        let visibility = vis(&[
235            (&[0], Some(AssetId(0))),
236            (&[1], None),
237            (&[2], Some(AssetId(1))),
238        ]);
239        let mut backend = TestBackend::default();
240        set_scene_visibility(&visibility, AssetId(0), &mut backend);
241
242        assert!(
243            backend.visibility.contains(&(0, true)),
244            "prop in 'a' should be visible"
245        );
246        assert!(
247            backend.visibility.contains(&(1, true)),
248            "scene-less prop always visible"
249        );
250        assert!(
251            backend.visibility.contains(&(2, false)),
252            "prop in 'b' should be hidden"
253        );
254    }
255
256    #[test]
257    fn set_visibility_no_scene_always_visible_regardless_of_active() {
258        let visibility = vis(&[(&[0], None)]);
259        let mut backend = TestBackend::default();
260        set_scene_visibility(&visibility, AssetId(99), &mut backend);
261        assert_eq!(backend.visibility, vec![(0, true)]);
262    }
263
264    #[test]
265    fn tick_without_fade_is_a_no_op() {
266        let mut opt = Some(make_flow(&[AssetId(0), AssetId(1)]));
267        let mut backend = TestBackend::default();
268        tick_transitions(&mut opt, &SceneVisibility::default(), 999.0, &mut backend);
269        assert!(backend.visibility.is_empty());
270        assert!(backend.fades.is_empty());
271        assert_eq!(opt.as_ref().unwrap().current, AssetId(0));
272    }
273
274    #[test]
275    fn tick_fade_to_black_ramps_the_fade_up() {
276        let mut flow = make_flow(&[AssetId(0), AssetId(1)]);
277        flow.fade = FadePhase::ToBlack {
278            started_at: 0.0,
279            next: AssetId(1),
280        };
281        let mut opt = Some(flow);
282        let mut backend = TestBackend::default();
283        // elapsed = FADE_HALF_SECS / 2 → t = 0.5
284        tick_transitions(
285            &mut opt,
286            &SceneVisibility::default(),
287            FADE_HALF_SECS * 0.5,
288            &mut backend,
289        );
290        assert_eq!(backend.fades.len(), 1);
291        assert!(
292            (backend.fades[0] - 0.5).abs() < 1e-5,
293            "half way to black at the midpoint of the first half"
294        );
295        // Still in ToBlack, no scene switch yet.
296        assert!(matches!(
297            opt.as_ref().unwrap().fade,
298            FadePhase::ToBlack { .. }
299        ));
300    }
301
302    #[test]
303    fn tick_fade_to_black_completes_and_enters_from_black() {
304        let mut flow = make_flow(&[AssetId(0), AssetId(1)]);
305        flow.fade = FadePhase::ToBlack {
306            started_at: 0.0,
307            next: AssetId(1),
308        };
309        let mut opt = Some(flow);
310        let mut backend = TestBackend::default();
311        // elapsed = FADE_HALF_SECS → t = 1.0, scene switches
312        tick_transitions(
313            &mut opt,
314            &SceneVisibility::default(),
315            FADE_HALF_SECS,
316            &mut backend,
317        );
318        let f = opt.as_ref().unwrap();
319        assert_eq!(f.current, AssetId(1));
320        assert!(matches!(f.fade, FadePhase::FromBlack { .. }));
321    }
322
323    #[test]
324    fn tick_fade_from_black_clears_the_fade() {
325        let mut flow = make_flow(&[AssetId(0)]);
326        flow.fade = FadePhase::FromBlack { started_at: 0.0 };
327        let mut opt = Some(flow);
328        let mut backend = TestBackend::default();
329        // elapsed = FADE_HALF_SECS → t = 1.0, fade ends
330        tick_transitions(
331            &mut opt,
332            &SceneVisibility::default(),
333            FADE_HALF_SECS,
334            &mut backend,
335        );
336        assert!(matches!(opt.as_ref().unwrap().fade, FadePhase::None));
337        // The last push leaves the image un-faded.
338        assert_eq!(*backend.fades.last().unwrap(), 0.0);
339    }
340
341    // The fade-in half runs the fade back down, so a frame partway through it
342    // is partially, not fully, black.
343    #[test]
344    fn tick_fade_from_black_ramps_the_fade_down() {
345        let mut flow = make_flow(&[AssetId(0)]);
346        flow.fade = FadePhase::FromBlack { started_at: 0.0 };
347        let mut opt = Some(flow);
348        let mut backend = TestBackend::default();
349        tick_transitions(
350            &mut opt,
351            &SceneVisibility::default(),
352            FADE_HALF_SECS * 0.25,
353            &mut backend,
354        );
355        assert_eq!(backend.fades.len(), 1);
356        assert!((backend.fades[0] - 0.75).abs() < 1e-5);
357        assert!(matches!(
358            opt.as_ref().unwrap().fade,
359            FadePhase::FromBlack { .. }
360        ));
361    }
362
363    #[test]
364    fn jump_to_scene_no_flow_is_no_op() {
365        let mut opt: Option<SceneFlow> = None;
366        let mut backend = TestBackend::default();
367        jump_to_scene(
368            &mut opt,
369            &SceneVisibility::default(),
370            0.0,
371            AssetId(99),
372            "Cut",
373            &mut backend,
374        );
375        assert!(backend.visibility.is_empty());
376    }
377
378    #[test]
379    fn jump_to_unknown_scene_is_no_op() {
380        let mut opt = Some(make_flow(&[AssetId(0), AssetId(1)]));
381        let mut backend = TestBackend::default();
382        jump_to_scene(
383            &mut opt,
384            &SceneVisibility::default(),
385            0.0,
386            AssetId(99),
387            "Cut",
388            &mut backend,
389        );
390        assert_eq!(opt.as_ref().unwrap().current, AssetId(0));
391        assert!(backend.visibility.is_empty());
392    }
393
394    #[test]
395    fn jump_to_scene_same_scene_is_no_op() {
396        let mut opt = Some(make_flow(&[AssetId(0), AssetId(1)]));
397        let mut backend = TestBackend::default();
398        jump_to_scene(
399            &mut opt,
400            &SceneVisibility::default(),
401            0.0,
402            AssetId(0),
403            "Cut",
404            &mut backend,
405        );
406        assert_eq!(opt.as_ref().unwrap().current, AssetId(0));
407        assert!(backend.visibility.is_empty());
408    }
409
410    #[test]
411    fn jump_to_scene_cut_switches_immediately() {
412        let visibility = vis(&[(&[0], Some(AssetId(0))), (&[1], Some(AssetId(1)))]);
413        let mut opt = Some(make_flow(&[AssetId(0), AssetId(1)]));
414        let mut backend = TestBackend::default();
415        jump_to_scene(&mut opt, &visibility, 1.0, AssetId(1), "Cut", &mut backend);
416        assert_eq!(opt.as_ref().unwrap().current, AssetId(1));
417        assert!(matches!(opt.as_ref().unwrap().fade, FadePhase::None));
418        assert!(backend.visibility.contains(&(1, true)));
419    }
420
421    #[test]
422    fn jump_to_scene_fade_black_starts_to_black_phase() {
423        let mut opt = Some(make_flow(&[AssetId(0), AssetId(1)]));
424        let mut backend = TestBackend::default();
425        jump_to_scene(
426            &mut opt,
427            &SceneVisibility::default(),
428            5.0,
429            AssetId(1),
430            "FadeBlack",
431            &mut backend,
432        );
433        // current not changed yet; scene switches mid-fade
434        assert_eq!(opt.as_ref().unwrap().current, AssetId(0));
435        assert!(matches!(
436            opt.as_ref().unwrap().fade,
437            FadePhase::ToBlack { started_at, next } if (started_at - 5.0).abs() < 1e-6 && next == AssetId(1)
438        ));
439    }
440}