Skip to main content

pebble/rendering/
render_plugin.rs

1use crate::{
2    prelude::{Backend, Commands, CurrentFrame, Plugin, Res, ResMut, SystemStage},
3    rendering::errors::AcquireError,
4};
5
6/// Inserted the first time [`Backend::acquire`] returns
7/// [`AcquireError::Fatal`] — the render loop has permanently stopped
8/// producing frames (a lost device, a destroyed surface, anything the
9/// backend itself judged unrecoverable). Once this exists, `begin_frame`
10/// stops calling `acquire` at all, so the same fatal condition isn't
11/// re-triggered and re-logged every tick forever.
12///
13/// The framework deliberately does *not* panic or exit on your behalf here
14/// — a hard crash isn't always the right response, and only your
15/// application knows whether the right move is an error screen, a full
16/// backend re-init, or something else. Check for it explicitly wherever
17/// that decision belongs:
18///
19/// ```ignore
20/// fn on_render_death(failure: Res<RenderFailure>) -> Option<()> {
21///     eprintln!("rendering has permanently stopped: {}", failure.message);
22///     Some(()) // returning Some(()) retires the system after one run
23/// }
24///
25/// app.add_system(SystemStage::PostRender, on_render_death);
26/// ```
27pub struct RenderFailure {
28    pub message: String,
29}
30
31/// Plugin that manages the per-frame acquire / present cycle.
32///
33/// Adds a [`CurrentFrame<B>`] resource and two systems:
34/// - [`PreRender`](SystemStage::PreRender): acquires a frame from the backend.
35/// - [`PostRender`](SystemStage::PostRender): presents the completed frame.
36///
37/// Rendering systems should check [`CurrentFrame::is_active`] before issuing
38/// draw calls, as the frame may be absent when the backend is not yet ready or
39/// a transient acquire error occurs. See [`RenderFailure`] for the
40/// permanent-failure case specifically.
41pub struct RenderPlugin<B: Backend> {
42    _marker: std::marker::PhantomData<B>,
43}
44
45impl<B: Backend> RenderPlugin<B> {
46    pub fn new() -> Self {
47        Self {
48            _marker: std::marker::PhantomData,
49        }
50    }
51}
52
53impl<B: Backend> Plugin for RenderPlugin<B> {
54    fn build(&self, app: &mut crate::prelude::App) {
55        app.add_resource(CurrentFrame::<B> { frame: None })
56            .add_system(SystemStage::PreRender, begin_frame::<B>)
57            .add_system(SystemStage::PostRender, end_frame::<B>);
58    }
59}
60
61/// PreRender system: acquire the next frame. Clears the current frame on a
62/// transient error; on a fatal one, logs it once and inserts
63/// [`RenderFailure`] instead of retrying forever.
64fn begin_frame<B: Backend>(
65    backend: Option<ResMut<B>>,
66    mut frame: ResMut<CurrentFrame<B>>,
67    already_failed: Option<Res<RenderFailure>>,
68    mut commands: Commands,
69) {
70    // Already permanently failed — nothing acquire() could tell us now
71    // changes that, so don't keep calling into a backend that may itself be
72    // in a broken state.
73    if already_failed.is_some() {
74        return;
75    }
76
77    let Some(mut backend) = backend else { return };
78
79    match backend.acquire() {
80        Ok(f) => frame.frame = Some(f),
81        Err(AcquireError::Transient) => frame.frame = None,
82        Err(AcquireError::Fatal(msg)) => {
83            tracing::error!("Fatal frame acquisition error — rendering has permanently stopped: {msg}");
84            frame.frame = None;
85            commands.insert_resource(RenderFailure { message: msg });
86        }
87    }
88}
89
90/// PostRender system: present the completed frame to the display.
91///
92/// `pub(crate)` (not private) so other in-crate plugins that draw directly
93/// onto the frame after the app's own render systems but before it's
94/// presented — the `profiler` feature's overlay, currently the only such
95/// case — can order themselves against it via
96/// [`SystemOrderingExt::before`](crate::ecs::system::SystemOrderingExt::before)
97/// instead of depending on registration order.
98pub(crate) fn end_frame<B: Backend>(backend: Option<ResMut<B>>, mut current: ResMut<CurrentFrame<B>>) {
99    let Some(mut backend) = backend else { return };
100    if let Some(frame) = current.frame.take() {
101        backend.present(frame);
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::app::App;
109    use crate::rendering::backend::{FrameOperations, Pass};
110    use crate::rendering::sync::InitSender;
111    use crate::rendering::window::GPUSurfaceHandle;
112    use std::sync::Arc;
113    use std::sync::atomic::{AtomicU32, Ordering};
114
115    struct FakeFrame;
116    impl FrameOperations for FakeFrame {
117        type Context<'a> = ();
118        type Attachment = ();
119        type DepthAttachment = ();
120        fn begin(&mut self, _pass: Pass<'_, Self>) -> Self::Context<'_> {}
121    }
122
123    struct FakeBackend {
124        acquire_calls: Arc<AtomicU32>,
125    }
126
127    impl Backend for FakeBackend {
128        type Frame = FakeFrame;
129
130        fn init(_handle: impl GPUSurfaceHandle, _width: u32, _height: u32, _sender: InitSender<Self>) {
131            unreachable!("not exercised by this test — the backend is inserted directly")
132        }
133
134        fn acquire(&mut self) -> Result<Self::Frame, AcquireError> {
135            self.acquire_calls.fetch_add(1, Ordering::SeqCst);
136            Err(AcquireError::Fatal("simulated device loss".to_string()))
137        }
138
139        fn present(&mut self, _frame: Self::Frame) {}
140    }
141
142    #[test]
143    fn a_fatal_acquire_error_reports_failure_and_stops_retrying() {
144        let acquire_calls = Arc::new(AtomicU32::new(0));
145        let backend = FakeBackend { acquire_calls: acquire_calls.clone() };
146
147        let mut app = App::new();
148        app.add_resource(backend);
149        app.add_plugin(RenderPlugin::<FakeBackend>::new());
150        app.build();
151
152        app.update();
153        assert_eq!(acquire_calls.load(Ordering::SeqCst), 1);
154        assert_eq!(app.get_resource::<RenderFailure>().message, "simulated device loss");
155
156        // Two more ticks: acquire() must not be called again now that
157        // RenderFailure exists — this is the "stop retrying forever"
158        // half of the fix, not just "report it once".
159        app.update();
160        app.update();
161        assert_eq!(
162            acquire_calls.load(Ordering::SeqCst),
163            1,
164            "begin_frame kept calling acquire() after a Fatal error instead of stopping"
165        );
166    }
167}