bevy/examples/3d/manual_material.rs
charlotte 🌸 e6ba9a6d18
Type erased materials (#19667)
# 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`:
![Screenshot 2025-06-26
235426](https://github.com/user-attachments/assets/10a0ee29-9932-4f91-ab43-33518b117ac5)

- @DGriffin91's Caldera
`cargo run --release --features=bevy/trace_tracy -- --random-materials`

![image](https://github.com/user-attachments/assets/ef91ba6a-8e88-4922-a73f-acb0af5b0dbc)


- @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`
![Screenshot 2025-06-27
000425](https://github.com/user-attachments/assets/9561388b-881d-46cf-8c3d-b15b3e9aedc7)


### 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`.


![image](https://github.com/user-attachments/assets/e3fcca7c-e04e-4a4e-9d89-39d697a9e3b8)

---------

Co-authored-by: IceSentry <IceSentry@users.noreply.github.com>
Co-authored-by: IceSentry <c.giguere42@gmail.com>
2025-06-27 22:57:24 +00:00

316 lines
11 KiB
Rust

//! A simple 3D scene with light shining over a cube sitting on a plane.
use bevy::{
asset::{AsAssetId, AssetEventSystems},
core_pipeline::core_3d::Opaque3d,
ecs::system::{
lifetimeless::{SRes, SResMut},
SystemChangeTick, SystemParamItem,
},
pbr::{
DrawMaterial, EntitiesNeedingSpecialization, EntitySpecializationTicks,
MaterialBindGroupAllocator, MaterialBindGroupAllocators, MaterialDrawFunction,
MaterialFragmentShader, MaterialProperties, PreparedMaterial, RenderMaterialBindings,
RenderMaterialInstance, RenderMaterialInstances, SpecializedMaterialPipelineCache,
},
platform::collections::hash_map::Entry,
prelude::*,
render::{
erased_render_asset::{ErasedRenderAsset, ErasedRenderAssetPlugin, PrepareAssetError},
render_asset::RenderAssets,
render_phase::DrawFunctions,
render_resource::{
binding_types::{sampler, texture_2d},
AsBindGroup, BindGroupLayout, BindGroupLayoutEntries, BindingResources,
OwnedBindingResource, Sampler, SamplerBindingType, SamplerDescriptor, ShaderStages,
TextureSampleType, TextureViewDimension, UnpreparedBindGroup,
},
renderer::RenderDevice,
sync_world::MainEntity,
texture::GpuImage,
view::ExtractedView,
Extract, RenderApp,
},
utils::Parallel,
};
use std::{any::TypeId, sync::Arc};
const SHADER_ASSET_PATH: &str = "shaders/manual_material.wgsl";
fn main() {
App::new()
.add_plugins((DefaultPlugins, ImageMaterialPlugin))
.add_systems(Startup, setup)
.run();
}
struct ImageMaterialPlugin;
impl Plugin for ImageMaterialPlugin {
fn build(&self, app: &mut App) {
app.init_asset::<ImageMaterial>()
.add_plugins(ErasedRenderAssetPlugin::<ImageMaterial>::default())
.add_systems(
PostUpdate,
check_entities_needing_specialization.after(AssetEventSystems),
)
.init_resource::<EntitiesNeedingSpecialization<ImageMaterial>>();
}
fn finish(&self, app: &mut App) {
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app.add_systems(
ExtractSchedule,
(
extract_image_materials,
extract_image_materials_needing_specialization,
),
);
render_app.world_mut().resource_scope(
|world: &mut World, mut bind_group_allocators: Mut<MaterialBindGroupAllocators>| {
world.resource_scope(|world: &mut World, render_device: Mut<RenderDevice>| {
let bind_group_layout = render_device.create_bind_group_layout(
"image_material_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
texture_2d(TextureSampleType::Float { filterable: false }),
sampler(SamplerBindingType::NonFiltering),
),
),
);
let sampler = render_device.create_sampler(&SamplerDescriptor::default());
world.insert_resource(ImageMaterialBindGroupLayout(bind_group_layout.clone()));
world.insert_resource(ImageMaterialBindGroupSampler(sampler));
bind_group_allocators.insert(
TypeId::of::<ImageMaterial>(),
MaterialBindGroupAllocator::new(
&render_device,
None,
None,
bind_group_layout,
None,
),
);
});
},
);
}
}
#[derive(Resource)]
struct ImageMaterialBindGroupLayout(BindGroupLayout);
#[derive(Resource)]
struct ImageMaterialBindGroupSampler(Sampler);
#[derive(Component)]
struct ImageMaterial3d(Handle<ImageMaterial>);
impl AsAssetId for ImageMaterial3d {
type Asset = ImageMaterial;
fn as_asset_id(&self) -> AssetId<Self::Asset> {
self.0.id()
}
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
struct ImageMaterial {
image: Handle<Image>,
}
impl ErasedRenderAsset for ImageMaterial {
type SourceAsset = ImageMaterial;
type ErasedAsset = PreparedMaterial;
type Param = (
SRes<DrawFunctions<Opaque3d>>,
SRes<ImageMaterialBindGroupLayout>,
SRes<AssetServer>,
SResMut<MaterialBindGroupAllocators>,
SResMut<RenderMaterialBindings>,
SRes<RenderAssets<GpuImage>>,
SRes<ImageMaterialBindGroupSampler>,
);
fn prepare_asset(
source_asset: Self::SourceAsset,
asset_id: AssetId<Self::SourceAsset>,
(
opaque_draw_functions,
material_layout,
asset_server,
bind_group_allocators,
render_material_bindings,
gpu_images,
image_material_sampler,
): &mut SystemParamItem<Self::Param>,
) -> std::result::Result<Self::ErasedAsset, PrepareAssetError<Self::SourceAsset>> {
let material_layout = material_layout.0.clone();
let draw_function_id = opaque_draw_functions.read().id::<DrawMaterial>();
let bind_group_allocator = bind_group_allocators
.get_mut(&TypeId::of::<ImageMaterial>())
.unwrap();
let Some(image) = gpu_images.get(&source_asset.image) else {
return Err(PrepareAssetError::RetryNextUpdate(source_asset));
};
let unprepared = UnpreparedBindGroup {
bindings: BindingResources(vec![
(
0,
OwnedBindingResource::TextureView(
TextureViewDimension::D2,
image.texture_view.clone(),
),
),
(
1,
OwnedBindingResource::Sampler(
SamplerBindingType::NonFiltering,
image_material_sampler.0.clone(),
),
),
]),
};
let binding = match render_material_bindings.entry(asset_id.into()) {
Entry::Occupied(mut occupied_entry) => {
bind_group_allocator.free(*occupied_entry.get());
let new_binding =
bind_group_allocator.allocate_unprepared(unprepared, &material_layout);
*occupied_entry.get_mut() = new_binding;
new_binding
}
Entry::Vacant(vacant_entry) => *vacant_entry
.insert(bind_group_allocator.allocate_unprepared(unprepared, &material_layout)),
};
let mut properties = MaterialProperties {
material_layout: Some(material_layout),
..Default::default()
};
properties.add_draw_function(MaterialDrawFunction, draw_function_id);
properties.add_shader(MaterialFragmentShader, asset_server.load(SHADER_ASSET_PATH));
Ok(PreparedMaterial {
binding,
properties: Arc::new(properties),
})
}
}
/// set up a simple 3D scene
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ImageMaterial>>,
asset_server: Res<AssetServer>,
) {
// cube
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(2.0, 2.0, 2.0))),
ImageMaterial3d(materials.add(ImageMaterial {
image: asset_server.load("branding/icon.png"),
})),
Transform::from_xyz(0.0, 0.5, 0.0),
));
// light
commands.spawn((
PointLight {
shadows_enabled: true,
..default()
},
Transform::from_xyz(4.0, 8.0, 4.0),
));
// camera
commands.spawn((
Camera3d::default(),
Transform::from_xyz(-2.5, 4.5, 9.0).looking_at(Vec3::ZERO, Vec3::Y),
));
}
fn extract_image_materials(
mut material_instances: ResMut<RenderMaterialInstances>,
changed_meshes_query: Extract<
Query<
(Entity, &ViewVisibility, &ImageMaterial3d),
Or<(Changed<ViewVisibility>, Changed<ImageMaterial3d>)>,
>,
>,
) {
let last_change_tick = material_instances.current_change_tick;
for (entity, view_visibility, material) in &changed_meshes_query {
if view_visibility.get() {
material_instances.instances.insert(
entity.into(),
RenderMaterialInstance {
asset_id: material.0.id().untyped(),
last_change_tick,
},
);
} else {
material_instances
.instances
.remove(&MainEntity::from(entity));
}
}
}
fn check_entities_needing_specialization(
needs_specialization: Query<
Entity,
(
Or<(
Changed<Mesh3d>,
AssetChanged<Mesh3d>,
Changed<ImageMaterial3d>,
AssetChanged<ImageMaterial3d>,
)>,
With<ImageMaterial3d>,
),
>,
mut par_local: Local<Parallel<Vec<Entity>>>,
mut entities_needing_specialization: ResMut<EntitiesNeedingSpecialization<ImageMaterial>>,
) {
entities_needing_specialization.clear();
needs_specialization
.par_iter()
.for_each(|entity| par_local.borrow_local_mut().push(entity));
par_local.drain_into(&mut entities_needing_specialization);
}
fn extract_image_materials_needing_specialization(
entities_needing_specialization: Extract<Res<EntitiesNeedingSpecialization<ImageMaterial>>>,
mut entity_specialization_ticks: ResMut<EntitySpecializationTicks>,
mut removed_mesh_material_components: Extract<RemovedComponents<ImageMaterial3d>>,
mut specialized_material_pipeline_cache: ResMut<SpecializedMaterialPipelineCache>,
views: Query<&ExtractedView>,
ticks: SystemChangeTick,
) {
// Clean up any despawned entities, we do this first in case the removed material was re-added
// the same frame, thus will appear both in the removed components list and have been added to
// the `EntitiesNeedingSpecialization` collection by triggering the `Changed` filter
for entity in removed_mesh_material_components.read() {
entity_specialization_ticks.remove(&MainEntity::from(entity));
for view in views {
if let Some(cache) =
specialized_material_pipeline_cache.get_mut(&view.retained_view_entity)
{
cache.remove(&MainEntity::from(entity));
}
}
}
for entity in entities_needing_specialization.iter() {
// Update the entity's specialization tick with this run's tick
entity_specialization_ticks.insert((*entity).into(), ticks.this_run());
}
}