Skip to main content

pebble/assets/
plugin.rs

1use std::collections::HashMap;
2
3use crate::{
4    app::SystemStage,
5    assets::{
6        deps::Dependencies,
7        storage::{Assets, ProcessedAssets, RawAssetHandle},
8        upload::Asset,
9    },
10    ecs::{
11        plugin::Plugin,
12        resources::Resources,
13        system::{Local, Res, ResMut},
14    },
15};
16
17/// Ticks a pending asset or a system blocked on `backend`/`Deps` can retry
18/// before the pipeline escalates from a quiet `debug!`/`trace!` to a
19/// `warn!`. Long enough that a legitimately slow dependency chain (a
20/// `LazyResource` waiting on a device, a multi-hop asset dependency) doesn't
21/// trip it on every run; short enough that something genuinely stuck
22/// (`upload`/`construct` unconditionally returning `None`, a `Deps`
23/// resource nothing will ever provide) doesn't stay invisible for minutes.
24/// Not a hard limit — retries continue past this, just louder, and repeat
25/// every `STUCK_AFTER_TICKS` after the first warning instead of going quiet
26/// again.
27const STUCK_AFTER_TICKS: u32 = 300;
28
29/// `true` on the tick a stuck-ness warning should fire: the first time
30/// `ticks` crosses the threshold, then again every `STUCK_AFTER_TICKS`
31/// ticks after that (rather than either spamming every tick past the
32/// threshold, or warning exactly once and going silent again).
33fn should_warn_stuck(ticks: u32) -> bool {
34    ticks >= STUCK_AFTER_TICKS && ticks.is_multiple_of(STUCK_AFTER_TICKS)
35}
36
37/// Plugin that drives the source → processed conversion pipeline for a single
38/// asset type `T`.
39///
40/// `B` is the *backend* passed to [`Asset::upload`] and is intentionally
41/// generic — it need not be a GPU backend:
42/// - **GPU assets**: `B` = your graphics backend (e.g. wgpu `Device`).
43/// - **CPU-only / audio / other**: `B = ()` or any other service type.
44///
45/// Registering `AssetPlugin::<B, T>::new()` will:
46/// - Insert an [`Assets<T::Source>`] resource for raw source assets.
47/// - Insert a [`ProcessedAssets<T>`] resource for the converted results.
48/// - Add a system on [`SystemStage::AssetSync`] that flushes the dirty queue
49///   each tick, calling [`Asset::upload`] for every pending entry.
50///
51/// The sync system waits silently until both `B` and all of `T`'s
52/// [`Dependencies`] are present as resources before processing any uploads.
53pub struct AssetPlugin<B, T: Asset<B>> {
54    _marker: std::marker::PhantomData<(B, T)>,
55}
56
57impl<B, T: Asset<B>> AssetPlugin<B, T> {
58    /// Create the plugin. See the type-level docs for what registering it does.
59    pub fn new() -> Self {
60        Self {
61            _marker: std::marker::PhantomData,
62        }
63    }
64}
65
66impl<B, T> Plugin for AssetPlugin<B, T>
67where
68    B: 'static + Send + Sync,
69    T: Asset<B>,
70{
71    fn build(&self, app: &mut crate::app::App) {
72        app.try_insert_resource(Assets::<T::Source>::new());
73        app.try_insert_resource(ProcessedAssets::<T>::new());
74        app.add_system(SystemStage::AssetSync, sync_assets::<B, T>);
75        app.provides::<ProcessedAssets<T>>();
76    }
77}
78
79/// Per-tick system: flush the dirty queue and convert pending assets.
80///
81/// Skips processing if `B` or any dependency is not yet available as a
82/// resource. Assets whose [`Asset::upload`] returns `None` are re-queued for
83/// the next tick.
84fn sync_assets<B, T>(
85    mut cpu: ResMut<Assets<T::Source>>,
86    mut processed: ResMut<ProcessedAssets<T>>,
87    backend: Option<Res<B>>,
88    mut blocked_ticks: Local<u32>,
89    mut pending_ticks: Local<HashMap<RawAssetHandle, u32>>,
90    world: &hecs::World,
91    resources: &Resources,
92) where
93    B: 'static + Send + Sync,
94    T: Asset<B>,
95{
96    let Some(backend) = backend else {
97        log_waiting::<B, T>(&cpu, "backend", &mut blocked_ticks);
98        return;
99    };
100    let Some(deps) = T::Deps::try_gather(world, resources) else {
101        log_waiting::<B, T>(&cpu, "dependencies", &mut blocked_ticks);
102        return;
103    };
104    *blocked_ticks = 0;
105
106    for handle in cpu.take_removed() {
107        processed.remove(handle);
108        pending_ticks.remove(&handle);
109    }
110
111    let mut still_pending = Vec::new();
112
113    for handle in cpu.take_dirty() {
114        let Some(source) = cpu.get_quiet(handle) else {
115            // Asset was inserted then removed before sync ran — nothing to upload.
116            tracing::debug!(
117                "{}: handle {:?} was in the dirty queue but the source asset is already gone \
118                 (inserted and removed in the same tick?)",
119                std::any::type_name::<T>(),
120                handle
121            );
122            pending_ticks.remove(&handle);
123            continue;
124        };
125        match T::upload(source, &backend, &deps) {
126            Some(value) => {
127                if let Some(name) = cpu.name_for_handle(handle) {
128                    processed.names.insert(name.to_string(), handle);
129                }
130                tracing::debug!(
131                    "{}: uploaded {:?}{}",
132                    std::any::type_name::<T>(),
133                    handle,
134                    cpu.name_for_handle(handle)
135                        .map(|n| format!(" ({n})"))
136                        .unwrap_or_default()
137                );
138                processed.insert(handle, value);
139                pending_ticks.remove(&handle);
140            }
141            None => {
142                let ticks = pending_ticks.entry(handle).or_insert(0);
143                *ticks += 1;
144                if should_warn_stuck(*ticks) {
145                    tracing::warn!(
146                        "{}: {:?}{} has not uploaded after {} ticks — upload() may be \
147                         unconditionally returning None, or a Deps resource it needs is never \
148                         actually going to appear. Still retrying every tick.",
149                        std::any::type_name::<T>(),
150                        handle,
151                        cpu.name_for_handle(handle)
152                            .map(|n| format!(" ({n})"))
153                            .unwrap_or_default(),
154                        *ticks
155                    );
156                } else {
157                    tracing::debug!(
158                        "{}: {:?} upload returned None — a required dependency is not yet ready, \
159                         requeued for next tick",
160                        std::any::type_name::<T>(),
161                        handle
162                    );
163                }
164                still_pending.push(handle);
165            }
166        }
167    }
168
169    if !still_pending.is_empty() {
170        tracing::debug!(
171            "{}: {} handle(s) still pending upload (waiting on dependencies)",
172            std::any::type_name::<T>(),
173            still_pending.len()
174        );
175    }
176
177    cpu.requeue(still_pending);
178}
179
180fn log_waiting<D, T>(cpu: &Assets<T::Source>, what: &str, blocked_ticks: &mut u32)
181where
182    D: 'static + Send + Sync,
183    T: Asset<D>,
184{
185    if cpu.dirty_is_empty() {
186        *blocked_ticks = 0;
187        return;
188    }
189
190    *blocked_ticks += 1;
191    if should_warn_stuck(*blocked_ticks) {
192        tracing::warn!(
193            "{}: {} asset(s) have been queued for {} ticks, still waiting on {what} before \
194             upload can begin — if {what} is never going to appear, this pipeline will wait \
195             forever.",
196            std::any::type_name::<T>(),
197            cpu.dirty_len(),
198            *blocked_ticks,
199        );
200    } else {
201        tracing::debug!(
202            "{}: {} asset(s) queued but waiting on {what} before upload can begin",
203            std::any::type_name::<T>(),
204            cpu.dirty_len()
205        );
206    }
207}