Add AssetChanged query filter (#16810)

# Objective

Implement a new `AssetChanged` query filter that allows users to query
for entities whose related assets may have changed.

- Closes https://github.com/bevyengine/bevy/issues/5069
- Unblocks #16420. Currently, `cold-specialization`, a key rendering
optimization for unlocking ancillary benefits of the retained render
world, is blocked on being unable detect all scenarios in which an
entity's mesh/material changes using events and observers. An
`AssetChanged` filter will drastically simplify our implementation and
be more robust to future changes.

Originally implemented by @nicopap in #5080.

## Solution

- Adds a new `AssetChanged` query filter that initializes a
`AssetChanges<A>` resource that tracks changed assets and ticks in
`asset_events`.
- ~Reverts #13343 and changes the api of `get_state` to accept `impl
Into<UnsafeWorldCell<'w>>` to allow accessing the `AssetChanges<A>`
resource.~
- Adds a `AsAssetId` trait used for newtype handle wrappers (e.g.
`Mesh3d`) that allows associating a component with the underlying
`Asset` it represents.

## Testing

- Tests are added for `AssetChanged`.
- TBD on performance. We are going to add this `Mesh3d` and
`MeshMaterial3d` (etc) in the renderer. Long term wins in render
performance this unblocks should swamp tracking overhead for any
realistic workload.

## Migration Guide

- The `asset_events` system is no longer public. Users should order
their systems relative to the `AssetEvents` system set.

---------

Co-authored-by: Nicola Papale <nico@nicopap.ch>
Co-authored-by: Patrick Walton <pcwalton@mimiga.net>
Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
Co-authored-by: Chris Russell <8494645+chescock@users.noreply.github.com>
This commit is contained in:
charlotte 2024-12-17 10:57:08 -08:00 committed by GitHub
parent cc0f6a8db4
commit df14443db9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 515 additions and 3 deletions

View File

@ -0,0 +1,479 @@
//! Defines the [`AssetChanged`] query filter.
//!
//! Like [`Changed`](bevy_ecs::prelude::Changed), but for [`Asset`]s,
//! and triggers whenever the handle or the underlying asset changes.
use crate::{AsAssetId, Asset, AssetId};
use bevy_ecs::component::Components;
use bevy_ecs::{
archetype::Archetype,
component::{ComponentId, Tick},
prelude::{Entity, Resource, World},
query::{FilteredAccess, QueryFilter, QueryItem, ReadFetch, WorldQuery},
storage::{Table, TableRow},
world::unsafe_world_cell::UnsafeWorldCell,
};
use bevy_utils::tracing::error;
use bevy_utils::HashMap;
use core::marker::PhantomData;
use disqualified::ShortName;
/// A resource that stores the last tick an asset was changed. This is used by
/// the [`AssetChanged`] filter to determine if an asset has changed since the last time
/// a query ran.
///
/// This resource is automatically managed by the [`AssetEvents`](crate::AssetEvents) schedule and
/// should not be exposed to the user in order to maintain safety guarantees. Any additional uses of
/// this resource should be carefully audited to ensure that they do not introduce any safety
/// issues.
#[derive(Resource)]
pub(crate) struct AssetChanges<A: Asset> {
change_ticks: HashMap<AssetId<A>, Tick>,
last_change_tick: Tick,
}
impl<A: Asset> AssetChanges<A> {
pub(crate) fn insert(&mut self, asset_id: AssetId<A>, tick: Tick) {
self.last_change_tick = tick;
self.change_ticks.insert(asset_id, tick);
}
pub(crate) fn remove(&mut self, asset_id: &AssetId<A>) {
self.change_ticks.remove(asset_id);
}
}
impl<A: Asset> Default for AssetChanges<A> {
fn default() -> Self {
Self {
change_ticks: Default::default(),
last_change_tick: Tick::new(0),
}
}
}
struct AssetChangeCheck<'w, A: AsAssetId> {
// This should never be `None` in practice, but we need to handle the case
// where the `AssetChanges` resource was removed.
change_ticks: Option<&'w HashMap<AssetId<A::Asset>, Tick>>,
last_run: Tick,
this_run: Tick,
}
impl<A: AsAssetId> Clone for AssetChangeCheck<'_, A> {
fn clone(&self) -> Self {
*self
}
}
impl<A: AsAssetId> Copy for AssetChangeCheck<'_, A> {}
impl<'w, A: AsAssetId> AssetChangeCheck<'w, A> {
fn new(changes: &'w AssetChanges<A::Asset>, last_run: Tick, this_run: Tick) -> Self {
Self {
change_ticks: Some(&changes.change_ticks),
last_run,
this_run,
}
}
// TODO(perf): some sort of caching? Each check has two levels of indirection,
// which is not optimal.
fn has_changed(&self, handle: &A) -> bool {
let is_newer = |tick: &Tick| tick.is_newer_than(self.last_run, self.this_run);
let id = handle.as_asset_id();
self.change_ticks
.is_some_and(|change_ticks| change_ticks.get(&id).is_some_and(is_newer))
}
}
/// Filter that selects entities with a `A` for an asset that changed
/// after the system last ran, where `A` is a component that implements
/// [`AsAssetId`].
///
/// Unlike `Changed<A>`, this is true whenever the asset for the `A`
/// in `ResMut<Assets<A>>` changed. For example, when a mesh changed through the
/// [`Assets<Mesh>::get_mut`] method, `AssetChanged<Mesh>` will iterate over all
/// entities with the `Handle<Mesh>` for that mesh. Meanwhile, `Changed<Handle<Mesh>>`
/// will iterate over no entities.
///
/// Swapping the actual `A` component is a common pattern. So you
/// should check for _both_ `AssetChanged<A>` and `Changed<A>` with
/// `Or<(Changed<A>, AssetChanged<A>)>`.
///
/// # Quirks
///
/// - Asset changes are registered in the [`AssetEvents`] schedule.
/// - Removed assets are not detected.
///
/// The list of changed assets only gets updated in the
/// [`AssetEvents`] schedule which runs in `Last`. Therefore, `AssetChanged`
/// will only pick up asset changes in schedules following `AssetEvents` or the
/// next frame. Consider adding the system in the `Last` schedule after [`AssetEvents`] if you need
/// to react without frame delay to asset changes.
///
/// # Performance
///
/// When at least one `A` is updated, this will
/// read a hashmap once per entity with a `A` component. The
/// runtime of the query is proportional to how many entities with a `A`
/// it matches.
///
/// If no `A` asset updated since the last time the system ran, then no lookups occur.
///
/// [`AssetEvents`]: crate::AssetEvents
/// [`Assets<Mesh>::get_mut`]: crate::Assets::get_mut
pub struct AssetChanged<A: AsAssetId>(PhantomData<A>);
/// [`WorldQuery`] fetch for [`AssetChanged`].
#[doc(hidden)]
pub struct AssetChangedFetch<'w, A: AsAssetId> {
inner: Option<ReadFetch<'w, A>>,
check: AssetChangeCheck<'w, A>,
}
impl<'w, A: AsAssetId> Clone for AssetChangedFetch<'w, A> {
fn clone(&self) -> Self {
Self {
inner: self.inner,
check: self.check,
}
}
}
/// [`WorldQuery`] state for [`AssetChanged`].
#[doc(hidden)]
pub struct AssetChangedState<A: AsAssetId> {
asset_id: ComponentId,
resource_id: ComponentId,
_asset: PhantomData<fn(A)>,
}
#[allow(unsafe_code)]
/// SAFETY: `ROQueryFetch<Self>` is the same as `QueryFetch<Self>`
unsafe impl<A: AsAssetId> WorldQuery for AssetChanged<A> {
type Item<'w> = ();
type Fetch<'w> = AssetChangedFetch<'w, A>;
type State = AssetChangedState<A>;
fn shrink<'wlong: 'wshort, 'wshort>(_: QueryItem<'wlong, Self>) -> QueryItem<'wshort, Self> {}
fn shrink_fetch<'wlong: 'wshort, 'wshort>(fetch: Self::Fetch<'wlong>) -> Self::Fetch<'wshort> {
fetch
}
unsafe fn init_fetch<'w>(
world: UnsafeWorldCell<'w>,
state: &Self::State,
last_run: Tick,
this_run: Tick,
) -> Self::Fetch<'w> {
// SAFETY:
// - `AssetChanges` is private and only accessed mutably in the `AssetEvents` schedule
// - `resource_id` was obtained from the type ID of `AssetChanges<A::Asset>`.
let Some(changes) = (unsafe {
world
.get_resource_by_id(state.resource_id)
.map(|ptr| ptr.deref::<AssetChanges<A::Asset>>())
}) else {
error!(
"AssetChanges<{ty}> resource was removed, please do not remove \
AssetChanges<{ty}> when using the AssetChanged<{ty}> world query",
ty = ShortName::of::<A>()
);
return AssetChangedFetch {
inner: None,
check: AssetChangeCheck {
change_ticks: None,
last_run,
this_run,
},
};
};
let has_updates = changes.last_change_tick.is_newer_than(last_run, this_run);
AssetChangedFetch {
inner: has_updates.then(||
// SAFETY: We delegate to the inner `init_fetch` for `A`
unsafe {
<&A>::init_fetch(world, &state.asset_id, last_run, this_run)
}),
check: AssetChangeCheck::new(changes, last_run, this_run),
}
}
const IS_DENSE: bool = <&A>::IS_DENSE;
unsafe fn set_archetype<'w>(
fetch: &mut Self::Fetch<'w>,
state: &Self::State,
archetype: &'w Archetype,
table: &'w Table,
) {
if let Some(inner) = &mut fetch.inner {
// SAFETY: We delegate to the inner `set_archetype` for `A`
unsafe {
<&A>::set_archetype(inner, &state.asset_id, archetype, table);
}
}
}
unsafe fn set_table<'w>(fetch: &mut Self::Fetch<'w>, state: &Self::State, table: &'w Table) {
if let Some(inner) = &mut fetch.inner {
// SAFETY: We delegate to the inner `set_table` for `A`
unsafe {
<&A>::set_table(inner, &state.asset_id, table);
}
}
}
unsafe fn fetch<'w>(_: &mut Self::Fetch<'w>, _: Entity, _: TableRow) -> Self::Item<'w> {}
#[inline]
fn update_component_access(state: &Self::State, access: &mut FilteredAccess<ComponentId>) {
<&A>::update_component_access(&state.asset_id, access);
assert!(
!access.access().has_resource_write(state.resource_id),
"AssetChanged<{ty}> requires read-only access to AssetChanges<{ty}>",
ty = ShortName::of::<A>()
);
access.add_resource_read(state.resource_id);
}
fn init_state(world: &mut World) -> AssetChangedState<A> {
let resource_id = world.init_resource::<AssetChanges<A::Asset>>();
let asset_id = world.register_component::<A>();
AssetChangedState {
asset_id,
resource_id,
_asset: PhantomData,
}
}
fn get_state(components: &Components) -> Option<Self::State> {
let resource_id = components.resource_id::<AssetChanges<A::Asset>>()?;
let asset_id = components.component_id::<A>()?;
Some(AssetChangedState {
asset_id,
resource_id,
_asset: PhantomData,
})
}
fn matches_component_set(
state: &Self::State,
set_contains_id: &impl Fn(ComponentId) -> bool,
) -> bool {
set_contains_id(state.asset_id)
}
}
#[allow(unsafe_code)]
/// SAFETY: read-only access
unsafe impl<A: AsAssetId> QueryFilter for AssetChanged<A> {
const IS_ARCHETYPAL: bool = false;
#[inline]
unsafe fn filter_fetch(
fetch: &mut Self::Fetch<'_>,
entity: Entity,
table_row: TableRow,
) -> bool {
fetch.inner.as_mut().map_or(false, |inner| {
// SAFETY: We delegate to the inner `fetch` for `A`
unsafe {
let handle = <&A>::fetch(inner, entity, table_row);
fetch.check.has_changed(handle)
}
})
}
}
#[cfg(test)]
mod tests {
use crate::{self as bevy_asset, AssetEvents, AssetPlugin, Handle};
use core::num::NonZero;
use crate::{AssetApp, Assets};
use bevy_app::{App, AppExit, Last, Startup, Update};
use bevy_core::TaskPoolPlugin;
use bevy_ecs::schedule::IntoSystemConfigs;
use bevy_ecs::{
component::Component,
event::EventWriter,
system::{Commands, IntoSystem, Local, Query, Res, ResMut, Resource},
};
use bevy_reflect::TypePath;
use super::*;
#[derive(Asset, TypePath, Debug)]
struct MyAsset(usize, &'static str);
#[derive(Component)]
struct MyComponent(Handle<MyAsset>);
impl AsAssetId for MyComponent {
type Asset = MyAsset;
fn as_asset_id(&self) -> AssetId<Self::Asset> {
self.0.id()
}
}
fn run_app<Marker>(system: impl IntoSystem<(), (), Marker>) {
let mut app = App::new();
app.add_plugins((TaskPoolPlugin::default(), AssetPlugin::default()))
.init_asset::<MyAsset>()
.add_systems(Update, system);
app.update();
}
// According to a comment in QueryState::new in bevy_ecs, components on filter
// position shouldn't conflict with components on query position.
#[test]
fn handle_filter_pos_ok() {
fn compatible_filter(
_query: Query<&mut MyComponent, AssetChanged<MyComponent>>,
mut exit: EventWriter<AppExit>,
) {
exit.send(AppExit::Error(NonZero::<u8>::MIN));
}
run_app(compatible_filter);
}
#[derive(Default, PartialEq, Debug, Resource)]
struct Counter(Vec<u32>);
fn count_update(
mut counter: ResMut<Counter>,
assets: Res<Assets<MyAsset>>,
query: Query<&MyComponent, AssetChanged<MyComponent>>,
) {
for handle in query.iter() {
let asset = assets.get(&handle.0).unwrap();
counter.0[asset.0] += 1;
}
}
fn update_some(mut assets: ResMut<Assets<MyAsset>>, mut run_count: Local<u32>) {
let mut update_index = |i| {
let id = assets
.iter()
.find_map(|(h, a)| (a.0 == i).then_some(h))
.unwrap();
let asset = assets.get_mut(id).unwrap();
println!("setting new value for {}", asset.0);
asset.1 = "new_value";
};
match *run_count {
0 | 1 => update_index(0),
2 => {}
3 => {
update_index(0);
update_index(1);
}
4.. => update_index(1),
};
*run_count += 1;
}
fn add_some(
mut assets: ResMut<Assets<MyAsset>>,
mut cmds: Commands,
mut run_count: Local<u32>,
) {
match *run_count {
1 => {
cmds.spawn(MyComponent(assets.add(MyAsset(0, "init"))));
}
0 | 2 => {}
3 => {
cmds.spawn(MyComponent(assets.add(MyAsset(1, "init"))));
cmds.spawn(MyComponent(assets.add(MyAsset(2, "init"))));
}
4.. => {
cmds.spawn(MyComponent(assets.add(MyAsset(3, "init"))));
}
};
*run_count += 1;
}
#[track_caller]
fn assert_counter(app: &App, assert: Counter) {
assert_eq!(&assert, app.world().resource::<Counter>());
}
#[test]
fn added() {
let mut app = App::new();
app.add_plugins((TaskPoolPlugin::default(), AssetPlugin::default()))
.init_asset::<MyAsset>()
.insert_resource(Counter(vec![0, 0, 0, 0]))
.add_systems(Update, add_some)
.add_systems(Last, count_update.after(AssetEvents));
// First run of the app, `add_systems(Startup…)` runs.
app.update(); // run_count == 0
assert_counter(&app, Counter(vec![0, 0, 0, 0]));
app.update(); // run_count == 1
assert_counter(&app, Counter(vec![1, 0, 0, 0]));
app.update(); // run_count == 2
assert_counter(&app, Counter(vec![1, 0, 0, 0]));
app.update(); // run_count == 3
assert_counter(&app, Counter(vec![1, 1, 1, 0]));
app.update(); // run_count == 4
assert_counter(&app, Counter(vec![1, 1, 1, 1]));
}
#[test]
fn changed() {
let mut app = App::new();
app.add_plugins((TaskPoolPlugin::default(), AssetPlugin::default()))
.init_asset::<MyAsset>()
.insert_resource(Counter(vec![0, 0]))
.add_systems(
Startup,
|mut cmds: Commands, mut assets: ResMut<Assets<MyAsset>>| {
let asset0 = assets.add(MyAsset(0, "init"));
let asset1 = assets.add(MyAsset(1, "init"));
cmds.spawn(MyComponent(asset0.clone()));
cmds.spawn(MyComponent(asset0));
cmds.spawn(MyComponent(asset1.clone()));
cmds.spawn(MyComponent(asset1.clone()));
cmds.spawn(MyComponent(asset1));
},
)
.add_systems(Update, update_some)
.add_systems(Last, count_update.after(AssetEvents));
// First run of the app, `add_systems(Startup…)` runs.
app.update(); // run_count == 0
// First run: We count the entities that were added in the `Startup` schedule
assert_counter(&app, Counter(vec![2, 3]));
// Second run: `update_once` updates the first asset, which is
// associated with two entities, so `count_update` picks up two updates
app.update(); // run_count == 1
assert_counter(&app, Counter(vec![4, 3]));
// Third run: `update_once` doesn't update anything, same values as last
app.update(); // run_count == 2
assert_counter(&app, Counter(vec![4, 3]));
// Fourth run: We update the two assets (asset 0: 2 entities, asset 1: 3)
app.update(); // run_count == 3
assert_counter(&app, Counter(vec![6, 6]));
// Fifth run: only update second asset
app.update(); // run_count == 4
assert_counter(&app, Counter(vec![6, 9]));
// ibid
app.update(); // run_count == 5
assert_counter(&app, Counter(vec![6, 12]));
}
}

View File

@ -1,3 +1,4 @@
use crate::asset_changed::AssetChanges;
use crate::{
self as bevy_asset, Asset, AssetEvent, AssetHandleProvider, AssetId, AssetServer, Handle,
UntypedHandle,
@ -5,7 +6,7 @@ use crate::{
use alloc::sync::Arc;
use bevy_ecs::{
prelude::EventWriter,
system::{Res, ResMut, Resource},
system::{Res, ResMut, Resource, SystemChangeTick},
};
use bevy_reflect::{Reflect, TypePath};
use bevy_utils::HashMap;
@ -559,7 +560,24 @@ impl<A: Asset> Assets<A> {
/// A system that applies accumulated asset change events to the [`Events`] resource.
///
/// [`Events`]: bevy_ecs::event::Events
pub fn asset_events(mut assets: ResMut<Self>, mut events: EventWriter<AssetEvent<A>>) {
pub(crate) fn asset_events(
mut assets: ResMut<Self>,
mut events: EventWriter<AssetEvent<A>>,
asset_changes: Option<ResMut<AssetChanges<A>>>,
ticks: SystemChangeTick,
) {
use AssetEvent::{Added, LoadedWithDependencies, Modified, Removed};
if let Some(mut asset_changes) = asset_changes {
for new_event in &assets.queued_events {
match new_event {
Removed { id } | AssetEvent::Unused { id } => asset_changes.remove(id),
Added { id } | Modified { id } | LoadedWithDependencies { id } => {
asset_changes.insert(*id, ticks.this_run());
}
};
}
}
events.send_batch(assets.queued_events.drain(..));
}

View File

@ -21,7 +21,7 @@
//! Typically, you'll use the [`AssetServer::load`] method to load an asset from disk, which returns a [`Handle`].
//! Note that this method does not attempt to reload the asset if it has already been loaded: as long as at least one handle has not been dropped,
//! calling [`AssetServer::load`] on the same path will return the same handle.
//! The handle that's returned can be used to instantiate various [`Component`](bevy_ecs::prelude::Component)s that require asset data to function,
//! The handle that's returned can be used to instantiate various [`Component`]s that require asset data to function,
//! which will then be spawned into the world as part of an entity.
//!
//! To avoid assets "popping" into existence, you may want to check that all of the required assets are loaded before transitioning to a new scene.
@ -146,6 +146,7 @@
)]
extern crate alloc;
extern crate core;
pub mod io;
pub mod meta;
@ -157,6 +158,9 @@ pub mod transformer;
///
/// This includes the most common types in this crate, re-exported for your convenience.
pub mod prelude {
#[doc(hidden)]
pub use crate::asset_changed::AssetChanged;
#[doc(hidden)]
pub use crate::{
Asset, AssetApp, AssetEvent, AssetId, AssetMode, AssetPlugin, AssetServer, Assets,
@ -164,6 +168,7 @@ pub mod prelude {
};
}
mod asset_changed;
mod assets;
mod direct_access_ext;
mod event;
@ -203,6 +208,7 @@ use crate::{
};
use alloc::sync::Arc;
use bevy_app::{App, Last, Plugin, PreUpdate};
use bevy_ecs::prelude::Component;
use bevy_ecs::{
reflect::AppTypeRegistry,
schedule::{IntoSystemConfigs, IntoSystemSetConfigs, SystemSet},
@ -398,6 +404,15 @@ impl Plugin for AssetPlugin {
)]
pub trait Asset: VisitAssetDependencies + TypePath + Send + Sync + 'static {}
/// A trait for components that can be used as asset identifiers, e.g. handle wrappers.
pub trait AsAssetId: Component {
/// The underlying asset type.
type Asset: Asset;
/// Retrieves the asset id from this component.
fn as_asset_id(&self) -> AssetId<Self::Asset>;
}
/// This trait defines how to visit the dependencies of an asset.
/// For example, a 3D model might require both textures and meshes to be loaded.
///