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(()) // .once() — react exactly once, not every tick thereafter
23/// }
24///
25/// app.add_system(SystemStage::PostRender, on_render_death.once());
26/// ```
27///
28/// `RenderPlugin` declares this as [provided](crate::app::App::provides), so
29/// a system with a hard `Res<RenderFailure>` requirement (as in the example
30/// above) waits quietly for it rather than panicking at startup over a
31/// resource that, in the common case where rendering never fails, is
32/// correctly never going to appear.
33pub struct RenderFailure {
34 pub message: String,
35}
36
37/// Plugin that manages the per-frame acquire / present cycle.
38///
39/// Adds a [`CurrentFrame<B>`] resource and two systems:
40/// - [`PreRender`](SystemStage::PreRender): acquires a frame from the backend.
41/// - [`PostRender`](SystemStage::PostRender): presents the completed frame.
42///
43/// Rendering systems should check [`CurrentFrame::is_active`] before issuing
44/// draw calls, as the frame may be absent when the backend is not yet ready or
45/// a transient acquire error occurs. See [`RenderFailure`] for the
46/// permanent-failure case specifically.
47pub struct RenderPlugin<B: Backend> {
48 _marker: std::marker::PhantomData<B>,
49}
50
51impl<B: Backend> RenderPlugin<B> {
52 pub fn new() -> Self {
53 Self {
54 _marker: std::marker::PhantomData,
55 }
56 }
57}
58
59impl<B: Backend> Plugin for RenderPlugin<B> {
60 fn build(&self, app: &mut crate::prelude::App) {
61 app.add_resource(CurrentFrame::<B> { frame: None })
62 .provides::<RenderFailure>()
63 .add_system(SystemStage::PreRender, begin_frame::<B>)
64 .add_system(SystemStage::PostRender, end_frame::<B>);
65 }
66}
67
68/// PreRender system: acquire the next frame. Clears the current frame on a
69/// transient error; on a fatal one, logs it once and inserts
70/// [`RenderFailure`] instead of retrying forever.
71fn begin_frame<B: Backend>(
72 backend: Option<ResMut<B>>,
73 mut frame: ResMut<CurrentFrame<B>>,
74 already_failed: Option<Res<RenderFailure>>,
75 mut commands: Commands,
76) {
77 // Already permanently failed — nothing acquire() could tell us now
78 // changes that, so don't keep calling into a backend that may itself be
79 // in a broken state.
80 if already_failed.is_some() {
81 return;
82 }
83
84 let Some(mut backend) = backend else { return };
85
86 match backend.acquire() {
87 Ok(f) => frame.frame = Some(f),
88 Err(AcquireError::Transient) => frame.frame = None,
89 Err(AcquireError::Fatal(msg)) => {
90 tracing::error!("Fatal frame acquisition error — rendering has permanently stopped: {msg}");
91 frame.frame = None;
92 commands.insert_resource(RenderFailure { message: msg });
93 }
94 }
95}
96
97/// PostRender system: present the completed frame to the display.
98///
99/// `pub(crate)` (not private) so other in-crate plugins that draw directly
100/// onto the frame after the app's own render systems but before it's
101/// presented — the `profiler` feature's overlay, currently the only such
102/// case — can order themselves against it via
103/// [`SystemOrderingExt::before`](crate::ecs::system::SystemOrderingExt::before)
104/// instead of depending on registration order.
105pub(crate) fn end_frame<B: Backend>(backend: Option<ResMut<B>>, mut current: ResMut<CurrentFrame<B>>) {
106 let Some(mut backend) = backend else { return };
107 if let Some(frame) = current.frame.take() {
108 backend.present(frame);
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use crate::app::App;
116 use crate::rendering::backend::{FrameOperations, Pass};
117 use crate::rendering::sync::InitSender;
118 use crate::rendering::window::GPUSurfaceHandle;
119 use std::sync::Arc;
120 use std::sync::atomic::{AtomicU32, Ordering};
121
122 struct FakeFrame;
123 impl FrameOperations for FakeFrame {
124 type Context<'a> = ();
125 type Attachment = ();
126 type DepthAttachment = ();
127 fn begin(&mut self, _pass: Pass<'_, Self>) -> Self::Context<'_> {}
128 }
129
130 struct FakeBackend {
131 acquire_calls: Arc<AtomicU32>,
132 }
133
134 impl Backend for FakeBackend {
135 type Frame = FakeFrame;
136
137 fn init(_handle: impl GPUSurfaceHandle, _width: u32, _height: u32, _sender: InitSender<Self>) {
138 unreachable!("not exercised by this test — the backend is inserted directly")
139 }
140
141 fn acquire(&mut self) -> Result<Self::Frame, AcquireError> {
142 self.acquire_calls.fetch_add(1, Ordering::SeqCst);
143 Err(AcquireError::Fatal("simulated device loss".to_string()))
144 }
145
146 fn present(&mut self, _frame: Self::Frame) {}
147 }
148
149 #[test]
150 fn a_fatal_acquire_error_reports_failure_and_stops_retrying() {
151 let acquire_calls = Arc::new(AtomicU32::new(0));
152 let backend = FakeBackend { acquire_calls: acquire_calls.clone() };
153
154 let mut app = App::new();
155 app.add_resource(backend);
156 app.add_plugin(RenderPlugin::<FakeBackend>::new());
157 app.build();
158
159 app.update();
160 assert_eq!(acquire_calls.load(Ordering::SeqCst), 1);
161 assert_eq!(app.get_resource::<RenderFailure>().message, "simulated device loss");
162
163 // Two more ticks: acquire() must not be called again now that
164 // RenderFailure exists — this is the "stop retrying forever"
165 // half of the fix, not just "report it once".
166 app.update();
167 app.update();
168 assert_eq!(
169 acquire_calls.load(Ordering::SeqCst),
170 1,
171 "begin_frame kept calling acquire() after a Fatal error instead of stopping"
172 );
173 }
174}