Skip to main content

bevy_render/
erased_render_asset.rs

1use crate::{
2    render_resource::AsBindGroupError, ExtractSchedule, MainWorld, Render, RenderApp,
3    RenderStartup, RenderSystems, Res,
4};
5use bevy_app::{App, Plugin, SubApp};
6use bevy_asset::RenderAssetUsages;
7use bevy_asset::{Asset, AssetEvent, AssetId, Assets, UntypedAssetId};
8use bevy_ecs::{
9    prelude::{Commands, IntoScheduleConfigs, Local, MessageReader, ResMut, Resource},
10    schedule::{ScheduleConfigs, SystemSet},
11    system::{ScheduleSystem, StaticSystemParam, SystemParam, SystemParamItem, SystemState},
12    world::{FromWorld, Mut},
13};
14use bevy_log::{debug, error};
15use bevy_platform::collections::{HashMap, HashSet};
16use bevy_render::render_asset::RenderAssetBytesPerFrameLimiter;
17use core::marker::PhantomData;
18use thiserror::Error;
19
20#[derive(Debug, Error)]
21pub enum PrepareAssetError<E: Send + Sync + 'static> {
22    #[error("Failed to prepare asset")]
23    RetryNextUpdate(E),
24    #[error("Failed to build bind group: {0}")]
25    AsBindGroupError(AsBindGroupError),
26}
27
28/// The system set during which we extract modified assets to the render world.
29#[derive(SystemSet, Clone, PartialEq, Eq, Debug, Hash)]
30pub struct AssetExtractionSystems;
31
32/// Describes how an asset gets extracted and prepared for rendering.
33///
34/// In the [`ExtractSchedule`] step the [`ErasedRenderAsset::SourceAsset`] is transferred
35/// from the "main world" into the "render world".
36///
37/// After that in the [`RenderSystems::PrepareAssets`] step the extracted asset
38/// is transformed into its GPU-representation of type [`ErasedRenderAsset`].
39pub trait ErasedRenderAsset: Send + Sync + 'static {
40    /// The representation of the asset in the "main world".
41    type SourceAsset: Asset + Clone;
42    /// The target representation of the asset in the "render world".
43    type ErasedAsset: Send + Sync + 'static + Sized;
44
45    /// Specifies all ECS data required by [`ErasedRenderAsset::prepare_asset`].
46    ///
47    /// For convenience use the [`lifetimeless`](bevy_ecs::system::lifetimeless) [`SystemParam`].
48    type Param: SystemParam;
49
50    /// Whether or not to unload the asset after extracting it to the render world.
51    #[inline]
52    fn asset_usage(_source_asset: &Self::SourceAsset) -> RenderAssetUsages {
53        RenderAssetUsages::default()
54    }
55
56    /// Size of the data the asset will upload to the gpu. Specifying a return value
57    /// will allow the asset to be throttled via [`RenderAssetBytesPerFrameLimiter`].
58    #[inline]
59    #[expect(
60        unused_variables,
61        reason = "The parameters here are intentionally unused by the default implementation; however, putting underscores here will result in the underscores being copied by rust-analyzer's tab completion."
62    )]
63    fn byte_len(erased_asset: &Self::SourceAsset) -> Option<usize> {
64        None
65    }
66
67    /// Prepares the [`ErasedRenderAsset::SourceAsset`] for the GPU by transforming it into a [`ErasedRenderAsset`].
68    ///
69    /// ECS data may be accessed via `param`.
70    fn prepare_asset(
71        source_asset: Self::SourceAsset,
72        asset_id: AssetId<Self::SourceAsset>,
73        param: &mut SystemParamItem<Self::Param>,
74    ) -> Result<Self::ErasedAsset, PrepareAssetError<Self::SourceAsset>>;
75
76    /// Called whenever the [`ErasedRenderAsset::SourceAsset`] has been removed.
77    ///
78    /// You can implement this method if you need to access ECS data (via
79    /// `_param`) in order to perform cleanup tasks when the asset is removed.
80    ///
81    /// The default implementation does nothing.
82    fn unload_asset(
83        _source_asset: AssetId<Self::SourceAsset>,
84        _param: &mut SystemParamItem<Self::Param>,
85    ) {
86    }
87}
88
89/// This plugin extracts the changed assets from the "app world" into the "render world"
90/// and prepares them for the GPU. They can then be accessed from the [`ErasedRenderAssets`] resource.
91///
92/// Therefore it sets up the [`ExtractSchedule`] and
93/// [`RenderSystems::PrepareAssets`] steps for the specified [`ErasedRenderAsset`].
94///
95/// The `AFTER` generic parameter can be used to specify that `A::prepare_asset` should not be run until
96/// `prepare_assets::<AFTER>` has completed. This allows the `prepare_asset` function to depend on another
97/// prepared [`ErasedRenderAsset`], for example `Mesh::prepare_asset` relies on `ErasedRenderAssets::<GpuImage>` for morph
98/// targets, so the plugin is created as `ErasedRenderAssetPlugin::<RenderMesh, GpuImage>::default()`.
99pub struct ErasedRenderAssetPlugin<
100    A: ErasedRenderAsset,
101    AFTER: ErasedRenderAssetDependency + 'static = (),
102> {
103    phantom: PhantomData<fn() -> (A, AFTER)>,
104}
105
106impl<A: ErasedRenderAsset, AFTER: ErasedRenderAssetDependency + 'static> Default
107    for ErasedRenderAssetPlugin<A, AFTER>
108{
109    fn default() -> Self {
110        Self {
111            phantom: Default::default(),
112        }
113    }
114}
115
116impl<A: ErasedRenderAsset, AFTER: ErasedRenderAssetDependency + 'static> Plugin
117    for ErasedRenderAssetPlugin<A, AFTER>
118{
119    fn build(&self, app: &mut App) {
120        app.init_resource::<CachedExtractErasedRenderAssetSystemState<A>>();
121    }
122
123    fn finish(&self, app: &mut App) {
124        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
125            render_app
126                .init_resource::<ExtractedAssets<A>>()
127                .init_resource::<ErasedRenderAssets<A::ErasedAsset>>()
128                .allow_ambiguous_resource::<ErasedRenderAssets<A::ErasedAsset>>()
129                .init_resource::<PrepareNextFrameAssets<A>>()
130                .add_systems(
131                    RenderStartup,
132                    collect_erased_render_assets_to_reextract::<A>,
133                )
134                .add_systems(
135                    ExtractSchedule,
136                    extract_erased_render_asset::<A>.in_set(AssetExtractionSystems),
137                );
138            AFTER::register_system(
139                render_app,
140                prepare_erased_assets::<A>.in_set(RenderSystems::PrepareAssets),
141            );
142        }
143    }
144}
145
146// helper to allow specifying dependencies between render assets
147pub trait ErasedRenderAssetDependency {
148    fn register_system(render_app: &mut SubApp, system: ScheduleConfigs<ScheduleSystem>);
149}
150
151impl ErasedRenderAssetDependency for () {
152    fn register_system(render_app: &mut SubApp, system: ScheduleConfigs<ScheduleSystem>) {
153        render_app.add_systems(Render, system);
154    }
155}
156
157impl<A: ErasedRenderAsset> ErasedRenderAssetDependency for A {
158    fn register_system(render_app: &mut SubApp, system: ScheduleConfigs<ScheduleSystem>) {
159        render_app.add_systems(Render, system.after(prepare_erased_assets::<A>));
160    }
161}
162
163/// Temporarily stores the extracted and removed assets of the current frame.
164#[derive(Resource)]
165pub struct ExtractedAssets<A: ErasedRenderAsset> {
166    /// The assets extracted this frame.
167    ///
168    /// These are assets that were either added or modified this frame.
169    pub extracted: Vec<(AssetId<A::SourceAsset>, A::SourceAsset)>,
170
171    /// IDs of the assets that were removed this frame.
172    ///
173    /// These assets will not be present in [`ExtractedAssets::extracted`].
174    pub removed: HashSet<AssetId<A::SourceAsset>>,
175
176    /// IDs of the assets that were modified this frame.
177    pub modified: HashSet<AssetId<A::SourceAsset>>,
178
179    /// IDs of the assets that were added this frame.
180    pub added: HashSet<AssetId<A::SourceAsset>>,
181}
182
183impl<A: ErasedRenderAsset> Default for ExtractedAssets<A> {
184    fn default() -> Self {
185        Self {
186            extracted: Default::default(),
187            removed: Default::default(),
188            modified: Default::default(),
189            added: Default::default(),
190        }
191    }
192}
193
194/// Stores all GPU representations ([`ErasedRenderAsset`])
195/// of [`ErasedRenderAsset::SourceAsset`] as long as they exist.
196#[derive(Resource)]
197pub struct ErasedRenderAssets<ERA>(HashMap<UntypedAssetId, ERA>);
198
199impl<ERA> Default for ErasedRenderAssets<ERA> {
200    fn default() -> Self {
201        Self(Default::default())
202    }
203}
204
205impl<ERA> ErasedRenderAssets<ERA> {
206    pub fn get(&self, id: impl Into<UntypedAssetId>) -> Option<&ERA> {
207        self.0.get(&id.into())
208    }
209
210    pub fn get_mut(&mut self, id: impl Into<UntypedAssetId>) -> Option<&mut ERA> {
211        self.0.get_mut(&id.into())
212    }
213
214    pub fn insert(&mut self, id: impl Into<UntypedAssetId>, value: ERA) -> Option<ERA> {
215        self.0.insert(id.into(), value)
216    }
217
218    pub fn remove(&mut self, id: impl Into<UntypedAssetId>) -> Option<ERA> {
219        self.0.remove(&id.into())
220    }
221
222    pub fn iter(&self) -> impl Iterator<Item = (UntypedAssetId, &ERA)> {
223        self.0.iter().map(|(k, v)| (*k, v))
224    }
225
226    pub fn iter_mut(&mut self) -> impl Iterator<Item = (UntypedAssetId, &mut ERA)> {
227        self.0.iter_mut().map(|(k, v)| (*k, v))
228    }
229}
230
231#[derive(Resource)]
232struct CachedExtractErasedRenderAssetSystemState<A: ErasedRenderAsset> {
233    state: SystemState<(
234        MessageReader<'static, 'static, AssetEvent<A::SourceAsset>>,
235        ResMut<'static, Assets<A::SourceAsset>>,
236    )>,
237}
238
239impl<A: ErasedRenderAsset> FromWorld for CachedExtractErasedRenderAssetSystemState<A> {
240    fn from_world(world: &mut bevy_ecs::world::World) -> Self {
241        Self {
242            state: SystemState::new(world),
243        }
244    }
245}
246
247/// Resource inserted during [`RenderStartup`] containing asset IDs that need
248/// re-extraction from the main world after device recovery.
249#[derive(Resource)]
250pub(crate) struct ErasedRenderAssetsToReExtract<A: ErasedRenderAsset> {
251    ids: Vec<AssetId<A::SourceAsset>>,
252}
253
254/// Drains all asset IDs from [`ErasedRenderAssets<A>`] to mark for re-extraction.
255fn collect_erased_render_assets_to_reextract<A: ErasedRenderAsset>(
256    mut commands: Commands,
257    mut render_assets: ResMut<ErasedRenderAssets<A::ErasedAsset>>,
258    mut prepare_next_frame: ResMut<PrepareNextFrameAssets<A>>,
259) {
260    let source_type_id = core::any::TypeId::of::<A::SourceAsset>();
261    // ErasedRenderAssets is shared across all material types that produce
262    // the same ErasedAsset type. Drain only the entries matching our SourceAsset.
263    let mut ids = Vec::new();
264    render_assets.0.retain(|untyped_id, _| {
265        if untyped_id.type_id() == source_type_id {
266            ids.push(untyped_id.typed());
267            false
268        } else {
269            true
270        }
271    });
272    prepare_next_frame.assets.clear();
273    if !ids.is_empty() {
274        commands.insert_resource(ErasedRenderAssetsToReExtract::<A> { ids });
275    }
276}
277
278/// Extracts all created or modified assets of the corresponding [`ErasedRenderAsset::SourceAsset`] type
279/// into the "render world", including any assets invalidated by device recovery.
280pub(crate) fn extract_erased_render_asset<A: ErasedRenderAsset>(
281    mut to_reextract: Option<ResMut<ErasedRenderAssetsToReExtract<A>>>,
282    mut extracted_assets: ResMut<ExtractedAssets<A>>,
283    mut main_world: ResMut<MainWorld>,
284    mut needs_extracting: Local<HashSet<AssetId<A::SourceAsset>>>,
285) {
286    extracted_assets.extracted.clear();
287    extracted_assets.removed.clear();
288    extracted_assets.modified.clear();
289    extracted_assets.added.clear();
290    needs_extracting.clear();
291
292    let reextract_ids = to_reextract
293        .as_mut()
294        .map(|r| core::mem::take(&mut r.ids))
295        .filter(|ids| !ids.is_empty());
296
297    main_world.resource_scope(
298        |world, mut cached_state: Mut<CachedExtractErasedRenderAssetSystemState<A>>| {
299            let (mut events, mut assets) = cached_state.state.get_mut(world).unwrap();
300
301            if let Some(reextract_ids) = reextract_ids {
302                needs_extracting.extend(reextract_ids);
303            }
304
305            for event in events.read() {
306                #[expect(
307                    clippy::match_same_arms,
308                    reason = "LoadedWithDependencies is marked as a TODO, so it's likely this will no longer lint soon."
309                )]
310                match event {
311                    AssetEvent::Added { id } => {
312                        needs_extracting.insert(*id);
313                    }
314                    AssetEvent::Modified { id } => {
315                        needs_extracting.insert(*id);
316                        extracted_assets.modified.insert(*id);
317                    }
318                    AssetEvent::Removed { .. } => {
319                        // We don't care that the asset was removed from Assets<T> in the main world.
320                        // An asset is only removed from ErasedRenderAssets<T> when its last handle is dropped (AssetEvent::Unused).
321                    }
322                    AssetEvent::Unused { id } => {
323                        needs_extracting.remove(id);
324                        extracted_assets.modified.remove(id);
325                        extracted_assets.removed.insert(*id);
326                    }
327                    AssetEvent::LoadedWithDependencies { .. } => {
328                        // TODO: handle this
329                    }
330                }
331            }
332
333            for id in needs_extracting.drain() {
334                if let Some(asset) = assets.get(id) {
335                    let asset_usage = A::asset_usage(asset);
336                    if asset_usage.contains(RenderAssetUsages::RENDER_WORLD) {
337                        if asset_usage == RenderAssetUsages::RENDER_WORLD {
338                            if let Some(asset) = assets.remove(id) {
339                                extracted_assets.extracted.push((id, asset));
340                                extracted_assets.added.insert(id);
341                            }
342                        } else {
343                            extracted_assets.extracted.push((id, asset.clone()));
344                            extracted_assets.added.insert(id);
345                        }
346                    }
347                }
348            }
349
350            cached_state.state.apply(world);
351        },
352    );
353}
354
355// TODO: consider storing inside system?
356/// All assets that should be prepared next frame.
357#[derive(Resource)]
358pub struct PrepareNextFrameAssets<A: ErasedRenderAsset> {
359    assets: Vec<(AssetId<A::SourceAsset>, A::SourceAsset)>,
360}
361
362impl<A: ErasedRenderAsset> Default for PrepareNextFrameAssets<A> {
363    fn default() -> Self {
364        Self {
365            assets: Default::default(),
366        }
367    }
368}
369
370/// This system prepares all assets of the corresponding [`ErasedRenderAsset::SourceAsset`] type
371/// which where extracted this frame for the GPU.
372pub fn prepare_erased_assets<A: ErasedRenderAsset>(
373    mut extracted_assets: ResMut<ExtractedAssets<A>>,
374    mut render_assets: ResMut<ErasedRenderAssets<A::ErasedAsset>>,
375    mut prepare_next_frame: ResMut<PrepareNextFrameAssets<A>>,
376    param: StaticSystemParam<<A as ErasedRenderAsset>::Param>,
377    bpf: Res<RenderAssetBytesPerFrameLimiter>,
378) {
379    let mut wrote_asset_count = 0;
380
381    let mut param = param.into_inner();
382    let queued_assets = core::mem::take(&mut prepare_next_frame.assets);
383    for (id, extracted_asset) in queued_assets {
384        if extracted_assets.removed.contains(&id) || extracted_assets.added.contains(&id) {
385            // skip previous frame's assets that have been removed or updated
386            continue;
387        }
388
389        let write_bytes = if let Some(size) = A::byte_len(&extracted_asset) {
390            // we could check if available bytes > byte_len here, but we want to make some
391            // forward progress even if the asset is larger than the max bytes per frame.
392            // this way we always write at least one (sized) asset per frame.
393            // in future we could also consider partial asset uploads.
394            if bpf.exhausted() {
395                prepare_next_frame.assets.push((id, extracted_asset));
396                continue;
397            }
398            size
399        } else {
400            0
401        };
402
403        match A::prepare_asset(extracted_asset, id, &mut param) {
404            Ok(prepared_asset) => {
405                render_assets.insert(id, prepared_asset);
406                bpf.write_bytes(write_bytes);
407                wrote_asset_count += 1;
408            }
409            Err(PrepareAssetError::RetryNextUpdate(extracted_asset)) => {
410                prepare_next_frame.assets.push((id, extracted_asset));
411            }
412            Err(PrepareAssetError::AsBindGroupError(e)) => {
413                error!(
414                    "{} Bind group construction failed: {e}",
415                    core::any::type_name::<A>()
416                );
417            }
418        }
419    }
420
421    for removed in extracted_assets.removed.drain() {
422        render_assets.remove(removed);
423        A::unload_asset(removed, &mut param);
424    }
425
426    for (id, extracted_asset) in extracted_assets.extracted.drain(..) {
427        // we remove previous here to ensure that if we are updating the asset then
428        // any users will not see the old asset after a new asset is extracted,
429        // even if the new asset is not yet ready or we are out of bytes to write.
430        render_assets.remove(id);
431
432        let write_bytes = if let Some(size) = A::byte_len(&extracted_asset) {
433            if bpf.exhausted() {
434                prepare_next_frame.assets.push((id, extracted_asset));
435                continue;
436            }
437            size
438        } else {
439            0
440        };
441
442        match A::prepare_asset(extracted_asset, id, &mut param) {
443            Ok(prepared_asset) => {
444                render_assets.insert(id, prepared_asset);
445                bpf.write_bytes(write_bytes);
446                wrote_asset_count += 1;
447            }
448            Err(PrepareAssetError::RetryNextUpdate(extracted_asset)) => {
449                prepare_next_frame.assets.push((id, extracted_asset));
450            }
451            Err(PrepareAssetError::AsBindGroupError(e)) => {
452                error!(
453                    "{} Bind group construction failed: {e}",
454                    core::any::type_name::<A>()
455                );
456            }
457        }
458    }
459
460    if bpf.exhausted() && !prepare_next_frame.assets.is_empty() {
461        debug!(
462            "{} write budget exhausted with {} assets remaining (wrote {})",
463            core::any::type_name::<A>(),
464            prepare_next_frame.assets.len(),
465            wrote_asset_count
466        );
467    }
468}