pebble/app.rs
1use crate::{
2 assets::required::RequiredResources,
3 ecs::{
4 plugin::Plugin,
5 resources::Resources,
6 system::{IntoSystem, System},
7 system_set::IntoSystemSet,
8 },
9};
10use std::collections::BTreeMap;
11
12/// Determines when during a frame a system is executed.
13///
14/// [`Startup`](SystemStage::Startup) systems each run at most once — but not
15/// necessarily during [`App::build`]: a system whose hard [`Res`](crate::ecs::system::Res)/[`ResMut`](crate::ecs::system::ResMut)
16/// requirements aren't satisfied yet is skipped (never panicked) and retried
17/// every tick, prioritized ahead of [`PreUpdate`](SystemStage::PreUpdate)
18/// and everything after it, until it fires exactly once. This lets a
19/// `Startup` system depend on something that only becomes real several
20/// frames in — a `LazyResource` built from an async GPU backend, for
21/// example — without moving it off `Startup`.
22///
23/// [`AssetSync`](SystemStage::AssetSync)/[`AssetSyncDeps`](SystemStage::AssetSyncDeps)
24/// are similarly prioritized: they (and any newly-ready `Startup` systems)
25/// are re-run to convergence at the front of every tick and again after
26/// every other stage, so newly queued asset/resource work is drained before
27/// gameplay stages continue rather than waiting for the next tick's front
28/// pass. All other stages run once per [`App::update`] tick, in the order
29/// declared below.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
31pub enum SystemStage {
32 /// Each system runs at most once, as soon as its requirements are met —
33 /// possibly during [`App::build`], possibly several ticks into
34 /// [`App::update`]. See the type-level docs above.
35 Startup,
36 /// Before the main update.
37 PreUpdate,
38 /// Main game-logic update.
39 Update,
40 /// After the main update.
41 PostUpdate,
42 /// Prepare rendering data and poll for the GPU backend.
43 /// The backend resource becomes available here on the tick it finishes
44 /// initialising, making it visible to the asset sync stages.
45 PreRender,
46 /// Upload CPU-side source assets to the GPU backend.
47 AssetSync,
48 /// Construct lazy GPU resources and upload assets that depend on other
49 /// processed assets. Runs in a convergence loop so dependency chains
50 /// (e.g. LazyResource A → LazyResource B) resolve within a single tick.
51 AssetSyncDeps,
52 /// Issue draw calls.
53 Render,
54 /// Cleanup or post-processing after rendering.
55 PostRender,
56}
57
58impl SystemStage {
59 /// Returns `true` for stages that are prioritized and re-run until a
60 /// full pass produces no new resources, instead of running once in
61 /// their declared position in the tick order. See the type-level docs
62 /// on [`SystemStage`].
63 pub fn is_convergent(self) -> bool {
64 matches!(self, Self::Startup | Self::AssetSync | Self::AssetSyncDeps)
65 }
66}
67
68/// Fixed per-tick order for every stage *except* the convergent ones
69/// (`Startup`, `AssetSync`, `AssetSyncDeps`), which are driven separately by
70/// [`App::reconverge`] — at the front of the tick and again after each of
71/// these — rather than appearing in this list.
72const TICK_STAGES: [SystemStage; 6] = [
73 SystemStage::PreUpdate,
74 SystemStage::Update,
75 SystemStage::PostUpdate,
76 SystemStage::PreRender,
77 SystemStage::Render,
78 SystemStage::PostRender,
79];
80
81/// Callback used to drive the application's main loop.
82///
83/// Set with [`App::set_runner`]. The default runner calls [`App::update`] in
84/// an infinite loop.
85pub type AppRunner = Box<dyn FnOnce(App)>;
86
87/// The central application object.
88///
89/// `App` owns the ECS world, resources, plugins, and systems. The typical
90/// lifecycle is:
91///
92/// 1. Create with [`App::new`].
93/// 2. Register plugins with [`add_plugin`](App::add_plugin).
94/// 3. Call [`build`](App::build) to run all plugin registrations, execute
95/// startup systems, and validate required resources.
96/// 4. Call [`run`](App::run) to hand control to the runner.
97pub struct App {
98 pub(crate) world: hecs::World,
99 pub(crate) resources: Resources,
100 plugins: Vec<Box<dyn Plugin>>,
101 systems: BTreeMap<SystemStage, Vec<Box<dyn System>>>,
102 runner: Option<AppRunner>,
103 pub(crate) required: RequiredResources,
104 /// Per-`Startup`-system "has it run yet" flags, indexed the same as
105 /// `systems[&SystemStage::Startup]`. Sized once in [`build`](App::build).
106 /// A system is only ever invoked once its [`System::requires`] are all
107 /// satisfied, and once invoked it is never invoked again.
108 startup_done: Vec<bool>,
109}
110
111impl Default for App {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117impl App {
118 /// Create a new `App` with an empty world and a default infinite-loop runner.
119 pub fn new() -> Self {
120 let mut world = hecs::World::default();
121 let mut resources = Resources::new(&mut world);
122 resources.insert_resource(&mut world, ());
123
124 Self {
125 world: world,
126 resources: resources,
127 plugins: Vec::new(),
128 systems: BTreeMap::new(),
129 runner: Some(Box::new(|mut app| {
130 loop {
131 app.update();
132 }
133 })),
134 required: RequiredResources::new(),
135 startup_done: Vec::new(),
136 }
137 }
138
139 /// Run every system in `stage` once, flush the command buffer, and return
140 /// `true` if any resource was newly inserted during this pass.
141 ///
142 /// [`Commands::insert_resource`](crate::ecs::system::Commands::insert_resource)
143 /// bumps the generation counter at queue time, so both direct inserts and
144 /// deferred command-buffer inserts are detected here with no world
145 /// introspection needed after the flush.
146 fn run_stage_once(&mut self, stage: SystemStage) -> bool {
147 let gen_before = self.resources.generation();
148
149 if let Some(systems) = self.systems.get_mut(&stage) {
150 for system in systems.iter_mut() {
151 let _guard = crate::ecs::resources::set_current_system(system.name());
152 system.run(&self.world, &self.resources);
153 }
154 }
155 self.resources.get_command_buffer().run_on(&mut self.world);
156
157 self.resources.generation() != gen_before
158 }
159
160 /// Run every not-yet-fired `Startup` system whose hard requirements are
161 /// currently satisfied, marking each one done so it never runs again.
162 /// A system whose requirements aren't met yet is left pending — silently,
163 /// no panic — for another attempt on a later pass/tick. Returns `true` if
164 /// any system ran (i.e. a resource may have changed).
165 fn run_startup_ready(&mut self) -> bool {
166 let Some(systems) = self.systems.get_mut(&SystemStage::Startup) else {
167 return false;
168 };
169 if self.startup_done.len() != systems.len() {
170 self.startup_done.resize(systems.len(), false);
171 }
172
173 let mut any_ran = false;
174 for (idx, system) in systems.iter_mut().enumerate() {
175 if self.startup_done[idx] {
176 continue;
177 }
178 let ready = system
179 .requires()
180 .iter()
181 .all(|req| (req.present)(&self.world, &self.resources));
182 if !ready {
183 continue;
184 }
185
186 let _guard = crate::ecs::resources::set_current_system(system.name());
187 system.run(&self.world, &self.resources);
188 self.startup_done[idx] = true;
189 any_ran = true;
190 }
191
192 if any_ran {
193 self.resources.get_command_buffer().run_on(&mut self.world);
194 }
195 any_ran
196 }
197
198 /// Panic before running `stage` if any of its systems declares a hard
199 /// [`Res`](crate::ecs::system::Res)/[`ResMut`](crate::ecs::system::ResMut)
200 /// requirement on a resource that isn't present yet.
201 ///
202 /// Only applied to non-convergent stages: convergent stages
203 /// ([`Startup`](SystemStage::Startup), [`AssetSync`](SystemStage::AssetSync),
204 /// [`AssetSyncDeps`](SystemStage::AssetSyncDeps)) are handled by
205 /// [`reconverge`](App::reconverge) instead, which skips systems that
206 /// aren't ready rather than treating it as an error.
207 fn validate_stage_resources(&self, stage: SystemStage) {
208 if stage.is_convergent() {
209 return;
210 }
211
212 let Some(systems) = self.systems.get(&stage) else {
213 return;
214 };
215
216 let mut missing: Vec<(&'static str, &'static str)> = Vec::new();
217 for system in systems {
218 for req in system.requires() {
219 if !(req.present)(&self.world, &self.resources) {
220 missing.push((system.name(), req.name));
221 tracing::error!(
222 stage = ?stage,
223 system = system.name(),
224 resource = req.name,
225 "system requires a resource that is not yet available"
226 );
227 }
228 }
229 }
230
231 if !missing.is_empty() {
232 missing.sort_unstable();
233 missing.dedup();
234 panic!(
235 "{stage:?}: system(s) require resource(s) that are not yet available:\n{}\n\n\
236 Insert these via App::add_resource, or a Startup/AssetSync system, before \
237 this stage runs.",
238 missing
239 .iter()
240 .map(|(system, resource)| format!(" - system `{system}` requires `{resource}`"))
241 .collect::<Vec<_>>()
242 .join("\n")
243 );
244 }
245 }
246
247 /// Prioritize resource/asset construction: run any not-yet-fired
248 /// `Startup` systems that are now ready, then `AssetSync`, then
249 /// `AssetSyncDeps` — repeating the whole trio until a full pass produces
250 /// no new resources, up to `max_passes`. `Startup` runs first each pass
251 /// so a same-pass consumer (an `AssetSyncDeps` system, say) can see what
252 /// it just produced. Logs a warning if the limit is reached — that
253 /// usually means a [`LazyResource`](crate::assets::singleton_asset::LazyResource)
254 /// or [`Asset`](crate::assets::upload::Asset) dependency is permanently
255 /// unsatisfiable (or a `Startup` system's requirement is never met).
256 ///
257 /// Called at the front of every tick and again after every stage in
258 /// [`update`](App::update) (and once during [`build`](App::build)), so
259 /// newly-queued asset/resource work — and any `Startup` system it
260 /// unblocks — is drained immediately instead of waiting for next tick's
261 /// front pass.
262 fn reconverge(&mut self, max_passes: u32) {
263 for pass in 0..max_passes {
264 let gen_before = self.resources.generation();
265
266 self.run_startup_ready();
267 self.run_stage_once(SystemStage::AssetSync);
268 self.run_stage_once(SystemStage::AssetSyncDeps);
269
270 if self.resources.generation() == gen_before {
271 return;
272 }
273 if pass == max_passes - 1 {
274 tracing::warn!(
275 "Startup/AssetSync/AssetSyncDeps did not settle after {max_passes} passes — \
276 a dependency may be permanently unsatisfiable. Check for a Startup system \
277 whose Res/ResMut requirement is never met, a LazyResource whose construct() \
278 always returns None, or an Asset whose upload() always returns None."
279 );
280 }
281 }
282 }
283
284 /// Queue a plugin to be built during [`build`](App::build).
285 pub fn add_plugin(&mut self, plugin: impl Plugin) -> &mut Self {
286 self.plugins.push(Box::new(plugin));
287 self
288 }
289
290 /// Insert a resource into the world immediately.
291 pub fn add_resource(&mut self, res: impl hecs::Component) -> &mut Self {
292 self.resources.insert_resource(&mut self.world, res);
293 self
294 }
295
296 /// Borrow resource `T`, panicking if it is absent.
297 pub fn get_resource<'a, T: hecs::Component>(&'a self) -> hecs::Ref<'a, T> {
298 self.resources.get_resource(&self.world)
299 }
300
301 /// Mutably borrow resource `T`, panicking if it is absent.
302 pub fn get_resource_mut<'a, T: hecs::Component>(&'a self) -> hecs::RefMut<'a, T> {
303 self.resources.get_resource_mut(&self.world)
304 }
305
306 /// Insert resource `T` only if it is not already present.
307 ///
308 /// Returns `true` if the resource was inserted.
309 pub fn try_insert_resource<T: hecs::Component>(&mut self, res: T) -> bool {
310 self.resources.try_insert(&mut self.world, res)
311 }
312
313 /// Register a single system to run at `stage`.
314 pub fn add_system<Marker>(
315 &mut self,
316 stage: SystemStage,
317 system: impl IntoSystem<Marker> + 'static,
318 ) -> &mut Self {
319 self.systems
320 .entry(stage)
321 .or_default()
322 .push(Box::new(system.into_system()));
323 self
324 }
325
326 /// Register multiple systems to run at `stage`.
327 ///
328 /// Accepts a tuple of systems via [`IntoSystemSet`].
329 pub fn add_systems<Marker>(
330 &mut self,
331 stage: SystemStage,
332 systems: impl IntoSystemSet<Marker>,
333 ) -> &mut Self {
334 let entry = self.systems.entry(stage).or_default();
335 entry.extend(systems.into_system_set());
336 self
337 }
338
339 /// Build all plugins, run startup systems, and validate required resources.
340 ///
341 /// Plugins may register additional plugins during their `build` call; this
342 /// repeats until no new plugins are added, up to a hard limit of 64 passes
343 /// to catch accidental infinite registration cycles.
344 pub fn build(&mut self) -> &mut Self {
345 let mut iterations = 0;
346 const MAX_PLUGIN_BUILD_ITERATIONS: u32 = 64;
347
348 while !self.plugins.is_empty() {
349 iterations += 1;
350 if iterations > MAX_PLUGIN_BUILD_ITERATIONS {
351 panic!(
352 "App::build() exceeded {MAX_PLUGIN_BUILD_ITERATIONS} plugin-registration passes — \
353 likely a cycle where plugins keep registering each other. Check for a plugin whose \
354 build() unconditionally re-adds itself or another plugin that re-adds it."
355 );
356 }
357 let plugins: Vec<_> = self.plugins.drain(..).collect();
358 for plugin in plugins {
359 plugin.build(self);
360 }
361 }
362
363 self.required.validate();
364
365 // Size the per-system done-tracking now that every plugin has
366 // registered its Startup systems.
367 let startup_len = self
368 .systems
369 .get(&SystemStage::Startup)
370 .map(Vec::len)
371 .unwrap_or(0);
372 self.startup_done = vec![false; startup_len];
373
374 // Resolve as much as possible synchronously (headless/CPU-only
375 // backends, tests) so resources are ready immediately after
376 // build(). Anything still pending — a Startup system waiting on an
377 // async GPU backend, say — keeps getting retried every tick by
378 // update(), prioritized ahead of PreUpdate/Update/etc.
379 self.reconverge(64);
380
381 self
382 }
383
384 /// Run every stage once per tick, in [`TICK_STAGES`] order. Before every
385 /// tick, and again after every stage, [`reconverge`](App::reconverge)
386 /// drains `Startup`/`AssetSync`/`AssetSyncDeps` — so newly-queued asset
387 /// or resource work (and any `Startup` system it unblocks) is handled
388 /// immediately rather than waiting for the next tick's front pass.
389 pub fn update(&mut self) {
390 self.reconverge(64);
391
392 for stage in TICK_STAGES {
393 self.validate_stage_resources(stage);
394 self.run_stage_once(stage);
395 self.reconverge(64);
396 }
397 }
398
399 /// Replace the default runner with a custom one.
400 ///
401 /// The runner receives ownership of the `App` and is responsible for
402 /// calling [`update`](App::update) at the appropriate cadence (e.g. driven
403 /// by a window event loop).
404 pub fn set_runner<F>(&mut self, runner: F) -> &mut Self
405 where
406 F: FnOnce(App) + 'static,
407 {
408 self.runner = Some(Box::new(runner));
409 self
410 }
411
412 /// Consume the app and hand it to the configured runner.
413 ///
414 /// Panics if no runner has been set.
415 pub fn run(&mut self) {
416 let mut owned_app = std::mem::take(self);
417 let runner = owned_app.runner.take().expect("No runner found!");
418 runner(owned_app);
419 }
420}