
# Objective Closes #18075 In order to enable a number of patterns for dynamic materials in the engine, it's necessary to decouple the renderer from the `Material` trait. This opens the possibility for: - Materials that aren't coupled to `AsBindGroup`. - 2d using the underlying 3d bindless infrastructure. - Dynamic materials that can change their layout at runtime. - Materials that aren't even backed by a Rust struct at all. ## Solution In short, remove all trait bounds from render world material systems and resources. This means moving a bunch of stuff onto `MaterialProperties` and engaging in some hacks to make specialization work. Rather than storing the bind group data in `MaterialBindGroupAllocator`, right now we're storing it in a closure on `MaterialProperties`. TBD if this has bad performance characteristics. ## Benchmarks - `many_cubes`: `cargo run --example many_cubes --release --features=bevy/trace_tracy -- --vary-material-data-per-instance`:  - @DGriffin91's Caldera `cargo run --release --features=bevy/trace_tracy -- --random-materials`  - @DGriffin91's Caldera with 20 unique material types (i.e. `MaterialPlugin<M>`) and random materials per mesh `cargo run --release --features=bevy/trace_tracy -- --random-materials`  ### TODO - We almost certainly lost some parallelization from removing the type params that could be gained back from smarter iteration. - Test all the things that could have broken. - ~Fix meshlets~ ## Showcase See [the example](https://github.com/bevyengine/bevy/pull/19667/files#diff-9d768cfe1c3aa81eff365d250d3cbe5a63e8df63e81dd85f64c3c3cd993f6d94) for a custom material implemented without the use of the `Material` trait and thus `AsBindGroup`.  --------- Co-authored-by: IceSentry <IceSentry@users.noreply.github.com> Co-authored-by: IceSentry <c.giguere42@gmail.com>
432 lines
16 KiB
Rust
432 lines
16 KiB
Rust
use crate::{
|
|
render_resource::AsBindGroupError, ExtractSchedule, MainWorld, Render, RenderApp,
|
|
RenderSystems, Res,
|
|
};
|
|
use bevy_app::{App, Plugin, SubApp};
|
|
pub use bevy_asset::RenderAssetUsages;
|
|
use bevy_asset::{Asset, AssetEvent, AssetId, Assets, UntypedAssetId};
|
|
use bevy_ecs::{
|
|
prelude::{Commands, EventReader, IntoScheduleConfigs, ResMut, Resource},
|
|
schedule::{ScheduleConfigs, SystemSet},
|
|
system::{ScheduleSystem, StaticSystemParam, SystemParam, SystemParamItem, SystemState},
|
|
world::{FromWorld, Mut},
|
|
};
|
|
use bevy_platform::collections::{HashMap, HashSet};
|
|
use bevy_render::render_asset::RenderAssetBytesPerFrameLimiter;
|
|
use core::marker::PhantomData;
|
|
use thiserror::Error;
|
|
use tracing::{debug, error};
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum PrepareAssetError<E: Send + Sync + 'static> {
|
|
#[error("Failed to prepare asset")]
|
|
RetryNextUpdate(E),
|
|
#[error("Failed to build bind group: {0}")]
|
|
AsBindGroupError(AsBindGroupError),
|
|
}
|
|
|
|
/// The system set during which we extract modified assets to the render world.
|
|
#[derive(SystemSet, Clone, PartialEq, Eq, Debug, Hash)]
|
|
pub struct AssetExtractionSystems;
|
|
|
|
/// Deprecated alias for [`AssetExtractionSystems`].
|
|
#[deprecated(since = "0.17.0", note = "Renamed to `AssetExtractionSystems`.")]
|
|
pub type ExtractAssetsSet = AssetExtractionSystems;
|
|
|
|
/// Describes how an asset gets extracted and prepared for rendering.
|
|
///
|
|
/// In the [`ExtractSchedule`] step the [`ErasedRenderAsset::SourceAsset`] is transferred
|
|
/// from the "main world" into the "render world".
|
|
///
|
|
/// After that in the [`RenderSystems::PrepareAssets`] step the extracted asset
|
|
/// is transformed into its GPU-representation of type [`ErasedRenderAsset`].
|
|
pub trait ErasedRenderAsset: Send + Sync + 'static {
|
|
/// The representation of the asset in the "main world".
|
|
type SourceAsset: Asset + Clone;
|
|
/// The target representation of the asset in the "render world".
|
|
type ErasedAsset: Send + Sync + 'static + Sized;
|
|
|
|
/// Specifies all ECS data required by [`ErasedRenderAsset::prepare_asset`].
|
|
///
|
|
/// For convenience use the [`lifetimeless`](bevy_ecs::system::lifetimeless) [`SystemParam`].
|
|
type Param: SystemParam;
|
|
|
|
/// Whether or not to unload the asset after extracting it to the render world.
|
|
#[inline]
|
|
fn asset_usage(_source_asset: &Self::SourceAsset) -> RenderAssetUsages {
|
|
RenderAssetUsages::default()
|
|
}
|
|
|
|
/// Size of the data the asset will upload to the gpu. Specifying a return value
|
|
/// will allow the asset to be throttled via [`RenderAssetBytesPerFrameLimiter`].
|
|
#[inline]
|
|
#[expect(
|
|
unused_variables,
|
|
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."
|
|
)]
|
|
fn byte_len(erased_asset: &Self::SourceAsset) -> Option<usize> {
|
|
None
|
|
}
|
|
|
|
/// Prepares the [`ErasedRenderAsset::SourceAsset`] for the GPU by transforming it into a [`ErasedRenderAsset`].
|
|
///
|
|
/// ECS data may be accessed via `param`.
|
|
fn prepare_asset(
|
|
source_asset: Self::SourceAsset,
|
|
asset_id: AssetId<Self::SourceAsset>,
|
|
param: &mut SystemParamItem<Self::Param>,
|
|
) -> Result<Self::ErasedAsset, PrepareAssetError<Self::SourceAsset>>;
|
|
|
|
/// Called whenever the [`ErasedRenderAsset::SourceAsset`] has been removed.
|
|
///
|
|
/// You can implement this method if you need to access ECS data (via
|
|
/// `_param`) in order to perform cleanup tasks when the asset is removed.
|
|
///
|
|
/// The default implementation does nothing.
|
|
fn unload_asset(
|
|
_source_asset: AssetId<Self::SourceAsset>,
|
|
_param: &mut SystemParamItem<Self::Param>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
/// This plugin extracts the changed assets from the "app world" into the "render world"
|
|
/// and prepares them for the GPU. They can then be accessed from the [`ErasedRenderAssets`] resource.
|
|
///
|
|
/// Therefore it sets up the [`ExtractSchedule`] and
|
|
/// [`RenderSystems::PrepareAssets`] steps for the specified [`ErasedRenderAsset`].
|
|
///
|
|
/// The `AFTER` generic parameter can be used to specify that `A::prepare_asset` should not be run until
|
|
/// `prepare_assets::<AFTER>` has completed. This allows the `prepare_asset` function to depend on another
|
|
/// prepared [`ErasedRenderAsset`], for example `Mesh::prepare_asset` relies on `ErasedRenderAssets::<GpuImage>` for morph
|
|
/// targets, so the plugin is created as `ErasedRenderAssetPlugin::<RenderMesh, GpuImage>::default()`.
|
|
pub struct ErasedRenderAssetPlugin<
|
|
A: ErasedRenderAsset,
|
|
AFTER: ErasedRenderAssetDependency + 'static = (),
|
|
> {
|
|
phantom: PhantomData<fn() -> (A, AFTER)>,
|
|
}
|
|
|
|
impl<A: ErasedRenderAsset, AFTER: ErasedRenderAssetDependency + 'static> Default
|
|
for ErasedRenderAssetPlugin<A, AFTER>
|
|
{
|
|
fn default() -> Self {
|
|
Self {
|
|
phantom: Default::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<A: ErasedRenderAsset, AFTER: ErasedRenderAssetDependency + 'static> Plugin
|
|
for ErasedRenderAssetPlugin<A, AFTER>
|
|
{
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<CachedExtractErasedRenderAssetSystemState<A>>();
|
|
}
|
|
|
|
fn finish(&self, app: &mut App) {
|
|
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
|
|
render_app
|
|
.init_resource::<ExtractedAssets<A>>()
|
|
.init_resource::<ErasedRenderAssets<A::ErasedAsset>>()
|
|
.init_resource::<PrepareNextFrameAssets<A>>()
|
|
.add_systems(
|
|
ExtractSchedule,
|
|
extract_erased_render_asset::<A>.in_set(AssetExtractionSystems),
|
|
);
|
|
AFTER::register_system(
|
|
render_app,
|
|
prepare_erased_assets::<A>.in_set(RenderSystems::PrepareAssets),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// helper to allow specifying dependencies between render assets
|
|
pub trait ErasedRenderAssetDependency {
|
|
fn register_system(render_app: &mut SubApp, system: ScheduleConfigs<ScheduleSystem>);
|
|
}
|
|
|
|
impl ErasedRenderAssetDependency for () {
|
|
fn register_system(render_app: &mut SubApp, system: ScheduleConfigs<ScheduleSystem>) {
|
|
render_app.add_systems(Render, system);
|
|
}
|
|
}
|
|
|
|
impl<A: ErasedRenderAsset> ErasedRenderAssetDependency for A {
|
|
fn register_system(render_app: &mut SubApp, system: ScheduleConfigs<ScheduleSystem>) {
|
|
render_app.add_systems(Render, system.after(prepare_erased_assets::<A>));
|
|
}
|
|
}
|
|
|
|
/// Temporarily stores the extracted and removed assets of the current frame.
|
|
#[derive(Resource)]
|
|
pub struct ExtractedAssets<A: ErasedRenderAsset> {
|
|
/// The assets extracted this frame.
|
|
///
|
|
/// These are assets that were either added or modified this frame.
|
|
pub extracted: Vec<(AssetId<A::SourceAsset>, A::SourceAsset)>,
|
|
|
|
/// IDs of the assets that were removed this frame.
|
|
///
|
|
/// These assets will not be present in [`ExtractedAssets::extracted`].
|
|
pub removed: HashSet<AssetId<A::SourceAsset>>,
|
|
|
|
/// IDs of the assets that were modified this frame.
|
|
pub modified: HashSet<AssetId<A::SourceAsset>>,
|
|
|
|
/// IDs of the assets that were added this frame.
|
|
pub added: HashSet<AssetId<A::SourceAsset>>,
|
|
}
|
|
|
|
impl<A: ErasedRenderAsset> Default for ExtractedAssets<A> {
|
|
fn default() -> Self {
|
|
Self {
|
|
extracted: Default::default(),
|
|
removed: Default::default(),
|
|
modified: Default::default(),
|
|
added: Default::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stores all GPU representations ([`ErasedRenderAsset`])
|
|
/// of [`ErasedRenderAsset::SourceAsset`] as long as they exist.
|
|
#[derive(Resource)]
|
|
pub struct ErasedRenderAssets<ERA>(HashMap<UntypedAssetId, ERA>);
|
|
|
|
impl<ERA> Default for ErasedRenderAssets<ERA> {
|
|
fn default() -> Self {
|
|
Self(Default::default())
|
|
}
|
|
}
|
|
|
|
impl<ERA> ErasedRenderAssets<ERA> {
|
|
pub fn get(&self, id: impl Into<UntypedAssetId>) -> Option<&ERA> {
|
|
self.0.get(&id.into())
|
|
}
|
|
|
|
pub fn get_mut(&mut self, id: impl Into<UntypedAssetId>) -> Option<&mut ERA> {
|
|
self.0.get_mut(&id.into())
|
|
}
|
|
|
|
pub fn insert(&mut self, id: impl Into<UntypedAssetId>, value: ERA) -> Option<ERA> {
|
|
self.0.insert(id.into(), value)
|
|
}
|
|
|
|
pub fn remove(&mut self, id: impl Into<UntypedAssetId>) -> Option<ERA> {
|
|
self.0.remove(&id.into())
|
|
}
|
|
|
|
pub fn iter(&self) -> impl Iterator<Item = (UntypedAssetId, &ERA)> {
|
|
self.0.iter().map(|(k, v)| (*k, v))
|
|
}
|
|
|
|
pub fn iter_mut(&mut self) -> impl Iterator<Item = (UntypedAssetId, &mut ERA)> {
|
|
self.0.iter_mut().map(|(k, v)| (*k, v))
|
|
}
|
|
}
|
|
|
|
#[derive(Resource)]
|
|
struct CachedExtractErasedRenderAssetSystemState<A: ErasedRenderAsset> {
|
|
state: SystemState<(
|
|
EventReader<'static, 'static, AssetEvent<A::SourceAsset>>,
|
|
ResMut<'static, Assets<A::SourceAsset>>,
|
|
)>,
|
|
}
|
|
|
|
impl<A: ErasedRenderAsset> FromWorld for CachedExtractErasedRenderAssetSystemState<A> {
|
|
fn from_world(world: &mut bevy_ecs::world::World) -> Self {
|
|
Self {
|
|
state: SystemState::new(world),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// This system extracts all created or modified assets of the corresponding [`ErasedRenderAsset::SourceAsset`] type
|
|
/// into the "render world".
|
|
pub(crate) fn extract_erased_render_asset<A: ErasedRenderAsset>(
|
|
mut commands: Commands,
|
|
mut main_world: ResMut<MainWorld>,
|
|
) {
|
|
main_world.resource_scope(
|
|
|world, mut cached_state: Mut<CachedExtractErasedRenderAssetSystemState<A>>| {
|
|
let (mut events, mut assets) = cached_state.state.get_mut(world);
|
|
|
|
let mut needs_extracting = <HashSet<_>>::default();
|
|
let mut removed = <HashSet<_>>::default();
|
|
let mut modified = <HashSet<_>>::default();
|
|
|
|
for event in events.read() {
|
|
#[expect(
|
|
clippy::match_same_arms,
|
|
reason = "LoadedWithDependencies is marked as a TODO, so it's likely this will no longer lint soon."
|
|
)]
|
|
match event {
|
|
AssetEvent::Added { id } => {
|
|
needs_extracting.insert(*id);
|
|
}
|
|
AssetEvent::Modified { id } => {
|
|
needs_extracting.insert(*id);
|
|
modified.insert(*id);
|
|
}
|
|
AssetEvent::Removed { .. } => {
|
|
// We don't care that the asset was removed from Assets<T> in the main world.
|
|
// An asset is only removed from ErasedRenderAssets<T> when its last handle is dropped (AssetEvent::Unused).
|
|
}
|
|
AssetEvent::Unused { id } => {
|
|
needs_extracting.remove(id);
|
|
modified.remove(id);
|
|
removed.insert(*id);
|
|
}
|
|
AssetEvent::LoadedWithDependencies { .. } => {
|
|
// TODO: handle this
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut extracted_assets = Vec::new();
|
|
let mut added = <HashSet<_>>::default();
|
|
for id in needs_extracting.drain() {
|
|
if let Some(asset) = assets.get(id) {
|
|
let asset_usage = A::asset_usage(asset);
|
|
if asset_usage.contains(RenderAssetUsages::RENDER_WORLD) {
|
|
if asset_usage == RenderAssetUsages::RENDER_WORLD {
|
|
if let Some(asset) = assets.remove(id) {
|
|
extracted_assets.push((id, asset));
|
|
added.insert(id);
|
|
}
|
|
} else {
|
|
extracted_assets.push((id, asset.clone()));
|
|
added.insert(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
commands.insert_resource(ExtractedAssets::<A> {
|
|
extracted: extracted_assets,
|
|
removed,
|
|
modified,
|
|
added,
|
|
});
|
|
cached_state.state.apply(world);
|
|
},
|
|
);
|
|
}
|
|
|
|
// TODO: consider storing inside system?
|
|
/// All assets that should be prepared next frame.
|
|
#[derive(Resource)]
|
|
pub struct PrepareNextFrameAssets<A: ErasedRenderAsset> {
|
|
assets: Vec<(AssetId<A::SourceAsset>, A::SourceAsset)>,
|
|
}
|
|
|
|
impl<A: ErasedRenderAsset> Default for PrepareNextFrameAssets<A> {
|
|
fn default() -> Self {
|
|
Self {
|
|
assets: Default::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// This system prepares all assets of the corresponding [`ErasedRenderAsset::SourceAsset`] type
|
|
/// which where extracted this frame for the GPU.
|
|
pub fn prepare_erased_assets<A: ErasedRenderAsset>(
|
|
mut extracted_assets: ResMut<ExtractedAssets<A>>,
|
|
mut render_assets: ResMut<ErasedRenderAssets<A::ErasedAsset>>,
|
|
mut prepare_next_frame: ResMut<PrepareNextFrameAssets<A>>,
|
|
param: StaticSystemParam<<A as ErasedRenderAsset>::Param>,
|
|
bpf: Res<RenderAssetBytesPerFrameLimiter>,
|
|
) {
|
|
let mut wrote_asset_count = 0;
|
|
|
|
let mut param = param.into_inner();
|
|
let queued_assets = core::mem::take(&mut prepare_next_frame.assets);
|
|
for (id, extracted_asset) in queued_assets {
|
|
if extracted_assets.removed.contains(&id) || extracted_assets.added.contains(&id) {
|
|
// skip previous frame's assets that have been removed or updated
|
|
continue;
|
|
}
|
|
|
|
let write_bytes = if let Some(size) = A::byte_len(&extracted_asset) {
|
|
// we could check if available bytes > byte_len here, but we want to make some
|
|
// forward progress even if the asset is larger than the max bytes per frame.
|
|
// this way we always write at least one (sized) asset per frame.
|
|
// in future we could also consider partial asset uploads.
|
|
if bpf.exhausted() {
|
|
prepare_next_frame.assets.push((id, extracted_asset));
|
|
continue;
|
|
}
|
|
size
|
|
} else {
|
|
0
|
|
};
|
|
|
|
match A::prepare_asset(extracted_asset, id, &mut param) {
|
|
Ok(prepared_asset) => {
|
|
render_assets.insert(id, prepared_asset);
|
|
bpf.write_bytes(write_bytes);
|
|
wrote_asset_count += 1;
|
|
}
|
|
Err(PrepareAssetError::RetryNextUpdate(extracted_asset)) => {
|
|
prepare_next_frame.assets.push((id, extracted_asset));
|
|
}
|
|
Err(PrepareAssetError::AsBindGroupError(e)) => {
|
|
error!(
|
|
"{} Bind group construction failed: {e}",
|
|
core::any::type_name::<A>()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for removed in extracted_assets.removed.drain() {
|
|
render_assets.remove(removed);
|
|
A::unload_asset(removed, &mut param);
|
|
}
|
|
|
|
for (id, extracted_asset) in extracted_assets.extracted.drain(..) {
|
|
// we remove previous here to ensure that if we are updating the asset then
|
|
// any users will not see the old asset after a new asset is extracted,
|
|
// even if the new asset is not yet ready or we are out of bytes to write.
|
|
render_assets.remove(id);
|
|
|
|
let write_bytes = if let Some(size) = A::byte_len(&extracted_asset) {
|
|
if bpf.exhausted() {
|
|
prepare_next_frame.assets.push((id, extracted_asset));
|
|
continue;
|
|
}
|
|
size
|
|
} else {
|
|
0
|
|
};
|
|
|
|
match A::prepare_asset(extracted_asset, id, &mut param) {
|
|
Ok(prepared_asset) => {
|
|
render_assets.insert(id, prepared_asset);
|
|
bpf.write_bytes(write_bytes);
|
|
wrote_asset_count += 1;
|
|
}
|
|
Err(PrepareAssetError::RetryNextUpdate(extracted_asset)) => {
|
|
prepare_next_frame.assets.push((id, extracted_asset));
|
|
}
|
|
Err(PrepareAssetError::AsBindGroupError(e)) => {
|
|
error!(
|
|
"{} Bind group construction failed: {e}",
|
|
core::any::type_name::<A>()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if bpf.exhausted() && !prepare_next_frame.assets.is_empty() {
|
|
debug!(
|
|
"{} write budget exhausted with {} assets remaining (wrote {})",
|
|
core::any::type_name::<A>(),
|
|
prepare_next_frame.assets.len(),
|
|
wrote_asset_count
|
|
);
|
|
}
|
|
}
|