Skip to content

Assets as entities v0#22939

Open
andriyDev wants to merge 31 commits intobevyengine:mainfrom
andriyDev:asset-v1
Open

Assets as entities v0#22939
andriyDev wants to merge 31 commits intobevyengine:mainfrom
andriyDev:asset-v1

Conversation

@andriyDev
Copy link
Copy Markdown
Contributor

@andriyDev andriyDev commented Feb 13, 2026

Objective

Solution

  • Remove the Assets resource.
  • Turn AssetHandleProvider into a resource that can create any kind of handle.
  • Create an AssetCommands system param that spawns an entity and creates a handle for it.
  • Make AssetServer hold a RemoveAllocator and remote-allocate an entity when loading assets.
  • Create a system to listen for handle drop events and despawn the entity.
  • Create Assets and AssetsMut system params to replicate the old Assets API.

I've done my best to keep this PR focused on the bare minimum for assets as entities. This also means I've had to do some weird back-porting. For example, I've needed to create a type ID to "sender" map to be able to send the appropriate AssetEvent::Unused when despawning an asset. In the future, this should be replaced by just an event, and then we don't need the type ID at all.

Some features we get for free:

  • Always strong: handles are now always strong in essence! UUID handles are now just looked up just before accessing the asset data, but this lookup map stores the asset handle to the actual entity. So assigning a handle to a UUID essentially just treats that UUID as a permanent reference.
  • Fixes Dropping a handle from Assets::get_strong_handle has different behaviour to one from AssetServer::load. #20651: there's now no difference between handles created from the asset server or Assets. This also simplifies our handle dropping stuff.
  • We can load UUID assets now by loading an asset and then assigning that handle to a UUID in the AssetUuidMap! Maybe no one will use this, but it's cool we can do this now!

Some concerns with this feature/implementation.

  • We can no longer "revive" an asset once its handles are dropped. Previously, if all handles for an asset are dropped, but you call AssetServer::load or Assets::get_strong_handle for that asset in the same frame, the asset won't be dropped and you will now have new handles to that asset. Based on discussion in Fix handles being split by asset_server_managed. #22261 (comment), it seems we are ok with that. This could be something to bring back in the future.
  • We can no longer access assets mutably after adding them in the same system. Since assets need to be spawned in order to query them, and spawning (in a normal system) is deferred, we can't really do this. I think this is just a permanent limitation.
  • Systems can now reasonably have two Commands params: one Commands and another AssetCommands. Unfortunately these are applied in param order, not the order that commands are enqueued. So if the order is Commands then AssetCommands, and if a user spawns an asset through AssetCommands, then enqueue a command that uses that entity, the entity will not have been spawned yet! This is discussed more in Shared State for System Param #22885. It's probably not a big deal, but if we care about removing this footrake, we can solve Shared State for System Param #22885.
  • AssetServer::load returns a Handle::Entity which is a remote-allocated entity. It only gets actually allocated during handle_internal_asset_events in PreUpdate. I'm not entirely sure what the behavior is here with every feature: I assume that trying to access this entity (e.g., through World::get_entity_mut) before it's actually allocated will return an error (or for panicking APIs, panic). This could be future work: we could split AssetServer into AssetServer SystemParam (which would allow us to not remote-allocate the entity), and RemoteAssetServer (which would remote-allocate the entity).
  • I've had to re-implement AssetEvent and the implementation is kind of rough. UUID handles in particular are not the most reliable. Changing which asset entity a UUID points to may break things - however this API wasn't even possible before so it's unlikely people will use it. In addition, we expect to delete AssetEvent in the future and replace it with change detection / observers. So this fix is duct tape, but that's ok.
  • You can no longer add assets with things that impl Into<A>. I've done this since we now have untyped AssetCommands::spawn_asset and world.spawn_asset: we can't deduce the type of A from this. So in many cases, we'd need to explicitly specify the asset type which seems silly especially when you do asset_commands.spawn_asset::<StandardMaterial>(StandardMaterial { .. }). This has the effect that things like Cuboid need to be converted into a Mesh, either through .into() or Mesh::from.

Testing

  • I ran a handful of examples and they still seem to work! I tested the solari example also which tests both the changes made to solari, and also the changes made to the text pipelines (since that example contains rendered text). The testbed_ui examples also showed how necessary (for the time being) AssetEvents for UUID assets are.
  • Asset tests all pass.

@andriyDev andriyDev added C-Feature A new feature, making something new possible A-Assets Load files from disk to use for things like images, models, and sounds M-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide D-Complex Quite challenging from either a design or technical perspective. Ask for help! M-Release-Note Work that should be called out in the blog due to impact S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged labels Feb 13, 2026
@github-project-automation github-project-automation bot moved this to Needs SME Triage in Assets Feb 13, 2026
@andriyDev andriyDev force-pushed the asset-v1 branch 7 times, most recently from 917c1a9 to 8448f5f Compare February 14, 2026 01:44
@github-actions
Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-22939

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

1 similar comment
@github-actions
Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-22939

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

@github-actions
Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-22939

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

1 similar comment
@github-actions
Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-22939

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

@github-actions
Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-22939

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

@github-actions
Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-22939

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

@github-actions
Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-22939

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

@github-actions
Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-22939

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

Copy link
Copy Markdown
Contributor

@Trashtalk217 Trashtalk217 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was kinda pushed to the back of my queue, so I'm sorry if it's late. I have two things I'd like to see addressed:

  • Use Mesh::from, over .into() as it makes debugging better
  • Eventually merge AssetCommands and Commands. I think it's technically possible, and the API is workable. Just not in this PR.

Some(task_data) => {
if let Some(mut image) = images.get_mut(image_h) {
*image = task_data.image;
***image = task_data.image;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Triple Deref is a bit cursed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed this by manually implementing Deref/DerefMut and DetectChanges/DetectChangesMut to point directly to the inner A! Much better! This comment made me realize I could do that lol


let embedded = EmbeddedAssetRegistry::default();

let handle_provider = app.world().resource::<AssetHandleProvider>().clone();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this do? What does it replace?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, each Assets resource would have its own handle provider, since each type had its own storage.

Now that we have one storage (the World), we need a shared handle provider that all assets use.

Put another way, previously we were initializing a handle provider per asset type in init_asset (through the Assets constructor). Now since our storage is shared, we don't need a handle provider per-type, we need one global handle provider.

As for why we need to clone here, it's because the AssetServer needs to be able to create handles remotely. Previously in init_asset we registered the asset type to the AssetServer, which would clone the type's handle provider.

let embedded = EmbeddedAssetRegistry::default();

let handle_provider = app.world().resource::<AssetHandleProvider>().clone();
let uuid_map = app.world().resource::<AssetUuidMap>().clone();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, the Assets resource stored a mapping from UUID to Asset. When you lookup a UUID handle, it would go to the Assets resource, go into the UUID storage, lookup the UUID, and give you the asset.

Now, we have the AssetUuidMap which stores a mapping from UUID to EntityHandle. So now when you lookup a UUID handle, it first goes to the AssetUuidMap, finds the EntityHandle, then lookups the Entity, and gives you the asset.

As for why we need to clone here, it's because the AssetServer needs an instance internally to resolve some handles.

pub(crate) drop_receiver: Receiver<DropEvent>,
pub(crate) type_id: TypeId,
#[derive(Component)]
pub struct AssetSelfHandle(pub(crate) Weak<StrongHandle>);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some docs would be nice here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! This definitely needed some docs haha.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-22939

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

.unwrap();
let mut asset = assets.get_mut(id).unwrap();
println!("setting new value for {}", asset.0);
println!("setting new value for {}", (*asset).0);

Check failure

Code scanning / CodeQL

Cleartext logging of sensitive information High

This operation writes
... .resolve_entity(...)
to a log file.
Copy link
Copy Markdown
Contributor Author

@andriyDev andriyDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Trashtalk217 I've replaced all the instances of .into() with *::from. It's possible I've missed one or two, but I literally went through all .into() instances lol.

As for combining Commands and AssetCommands, the regression that causes is way too big. Maybe if everything moves to BSN this won't be a problem? I'm extremely skeptical though.

let embedded = EmbeddedAssetRegistry::default();

let handle_provider = app.world().resource::<AssetHandleProvider>().clone();
let uuid_map = app.world().resource::<AssetUuidMap>().clone();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, the Assets resource stored a mapping from UUID to Asset. When you lookup a UUID handle, it would go to the Assets resource, go into the UUID storage, lookup the UUID, and give you the asset.

Now, we have the AssetUuidMap which stores a mapping from UUID to EntityHandle. So now when you lookup a UUID handle, it first goes to the AssetUuidMap, finds the EntityHandle, then lookups the Entity, and gives you the asset.

As for why we need to clone here, it's because the AssetServer needs an instance internally to resolve some handles.


let embedded = EmbeddedAssetRegistry::default();

let handle_provider = app.world().resource::<AssetHandleProvider>().clone();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, each Assets resource would have its own handle provider, since each type had its own storage.

Now that we have one storage (the World), we need a shared handle provider that all assets use.

Put another way, previously we were initializing a handle provider per asset type in init_asset (through the Assets constructor). Now since our storage is shared, we don't need a handle provider per-type, we need one global handle provider.

As for why we need to clone here, it's because the AssetServer needs to be able to create handles remotely. Previously in init_asset we registered the asset type to the AssetServer, which would clone the type's handle provider.

pub(crate) drop_receiver: Receiver<DropEvent>,
pub(crate) type_id: TypeId,
#[derive(Component)]
pub struct AssetSelfHandle(pub(crate) Weak<StrongHandle>);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! This definitely needed some docs haha.

Copy link
Copy Markdown
Contributor

@Trashtalk217 Trashtalk217 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future reference. Merging Commands and AssetCommands would result in the following code:

commands.spawn((
  // BORROWING COMMANDS TWICE MUTABLY
  Mesh3d(commands.spawn_asset(Mesh::from(Cuboid::default()))),
));

which would break. However, I believe that we can do some API trick (probably with closures) that would allow this to be more manageble. Perhaps with some shortcuts for specific common operations. Also,

let a = Mesh3d(commands.spawn_asset(Mesh::from(Cuboid::default())));
commands.spawn(a);

Is really not that bad.

That all being said, given that there are also some technical hurdles and the current API is already a big improvement, I won't dwell on it any longer. As far as I'm concerned it's good to merge.

(After fixing merge conflicts)

@alice-i-cecile alice-i-cecile added S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Apr 11, 2026
@alice-i-cecile alice-i-cecile added this to the 0.20 milestone Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Assets Load files from disk to use for things like images, models, and sounds C-Feature A new feature, making something new possible D-Complex Quite challenging from either a design or technical perspective. Ask for help! M-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide M-Release-Note Work that should be called out in the blog due to impact S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it X-Needs-SME This type of work requires an SME to approve it.

Projects

Status: Focus
Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

Dropping a handle from Assets::get_strong_handle has different behaviour to one from AssetServer::load.

10 participants