Skip to content

Commit 384929b

Browse files
authored
Fix a crash that occurs when an off-screen entity's material type changes and the material then becomes visible. (#21410)
PR #20993 attempted to fix a crash that would occur with the following sequence of events: 1. An entity's material changes from type A to type B. (The material *type* must change, not just the material asset.) 2. The `extract_entities_needs_specialization<B>` system runs and adds a new specialization change tick to the entity. 3. The `extract_entities_needs_specialization<A>` system runs and removes the specialization change tick. 4. We crash in rendering because no specialization change tick was present for the entity. Unfortunately, that PR used the presence of the entity in `RenderMaterialInstances` to detect whether the entity is safe to delete from the specialization change tick table. This is incorrect for meshes that change material types while not visible, because only visible meshes are present in `RenderMaterialInstances`. So the above race can still occur if the mesh changes materials while off-screen, which will lead to a crash when the mesh becomes visible. This PR fixes the issue by dividing the process of adding new specialization ticks and the process of removing old specialization ticks into two systems. First, all specialization ticks for all materials are updated; alongside that, we store the *material instance tick*, which is a tick that's updated once per frame. After that, we run `sweep_entities_needing_specialization`, which traverses the `RemovedComponents` list for each material and prunes dead entities from the table if and only if their material instance ticks haven't changed since the last frame. This ensures that the above race can't happen, regardless of whether the meshes are present in `RenderMaterialInstances`. Having to have two separate specialization ticks, one being the standard Bevy system tick and one being a special material instance tick that's updated once per frame, is unfortunate, but it seemed to me like the least bad option. We should be able to get rid of all of this when we have untyped materials.
1 parent c6bc114 commit 384929b

File tree

5 files changed

+179
-31
lines changed

5 files changed

+179
-31
lines changed

crates/bevy_pbr/src/material.rs

Lines changed: 117 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -387,9 +387,16 @@ where
387387
early_sweep_material_instances::<M>
388388
.after(MaterialExtractionSystems)
389389
.before(late_sweep_material_instances),
390+
// See the comments in
391+
// `sweep_entities_needing_specialization` for an
392+
// explanation of why the systems are ordered this way.
390393
extract_entities_needs_specialization::<M>
394+
.in_set(MaterialExtractEntitiesNeedingSpecializationSystems),
395+
sweep_entities_needing_specialization::<M>
396+
.after(MaterialExtractEntitiesNeedingSpecializationSystems)
397+
.after(MaterialExtractionSystems)
391398
.after(extract_cameras)
392-
.after(MaterialExtractionSystems),
399+
.before(late_sweep_material_instances),
393400
),
394401
);
395402
}
@@ -604,6 +611,11 @@ pub struct RenderMaterialInstance {
604611
#[derive(SystemSet, Clone, PartialEq, Eq, Debug, Hash)]
605612
pub struct MaterialExtractionSystems;
606613

614+
/// A [`SystemSet`] that contains all `extract_entities_needs_specialization`
615+
/// systems.
616+
#[derive(SystemSet, Clone, PartialEq, Eq, Debug, Hash)]
617+
pub struct MaterialExtractEntitiesNeedingSpecializationSystems;
618+
607619
/// Deprecated alias for [`MaterialExtractionSystems`].
608620
#[deprecated(since = "0.17.0", note = "Renamed to `MaterialExtractionSystems`.")]
609621
pub type ExtractMaterialsSet = MaterialExtractionSystems;
@@ -750,10 +762,10 @@ fn early_sweep_material_instances<M>(
750762
/// Removes mesh materials from [`RenderMaterialInstances`] when their
751763
/// [`ViewVisibility`] components are removed.
752764
///
753-
/// This runs after all invocations of [`early_sweep_material_instances`] and is
765+
/// This runs after all invocations of `early_sweep_material_instances` and is
754766
/// responsible for bumping [`RenderMaterialInstances::current_change_tick`] in
755767
/// preparation for a new frame.
756-
pub(crate) fn late_sweep_material_instances(
768+
pub fn late_sweep_material_instances(
757769
mut material_instances: ResMut<RenderMaterialInstances>,
758770
mut removed_meshes_query: Extract<RemovedComponents<Mesh3d>>,
759771
) {
@@ -777,7 +789,39 @@ pub(crate) fn late_sweep_material_instances(
777789

778790
pub fn extract_entities_needs_specialization<M>(
779791
entities_needing_specialization: Extract<Res<EntitiesNeedingSpecialization<M>>>,
780-
material_instances: Res<RenderMaterialInstances>,
792+
mut entity_specialization_ticks: ResMut<EntitySpecializationTicks>,
793+
render_material_instances: Res<RenderMaterialInstances>,
794+
ticks: SystemChangeTick,
795+
) where
796+
M: Material,
797+
{
798+
for entity in entities_needing_specialization.iter() {
799+
// Update the entity's specialization tick with this run's tick
800+
entity_specialization_ticks.insert(
801+
(*entity).into(),
802+
EntitySpecializationTickPair {
803+
system_tick: ticks.this_run(),
804+
material_instances_tick: render_material_instances.current_change_tick,
805+
},
806+
);
807+
}
808+
}
809+
810+
/// A system that runs after all instances of
811+
/// [`extract_entities_needs_specialization`] in order to delete specialization
812+
/// ticks for entities that are no longer renderable.
813+
///
814+
/// We delete entities from the [`EntitySpecializationTicks`] table *after*
815+
/// updating it with newly-discovered renderable entities in order to handle the
816+
/// case in which a single entity changes material types. If we naïvely removed
817+
/// entities from that table when their [`MeshMaterial3d<M>`] components were
818+
/// removed, and an entity changed material types, we might end up adding a new
819+
/// set of [`EntitySpecializationTickPair`] for the new material and then
820+
/// deleting it upon detecting the removed component for the old material.
821+
/// Deferring [`sweep_entities_needing_specialization`] to the end allows us to
822+
/// detect the case in which another material type updated the entity
823+
/// specialization ticks this frame and avoid deleting it if so.
824+
pub fn sweep_entities_needing_specialization<M>(
781825
mut entity_specialization_ticks: ResMut<EntitySpecializationTicks>,
782826
mut removed_mesh_material_components: Extract<RemovedComponents<MeshMaterial3d<M>>>,
783827
mut specialized_material_pipeline_cache: ResMut<SpecializedMaterialPipelineCache>,
@@ -787,24 +831,31 @@ pub fn extract_entities_needs_specialization<M>(
787831
mut specialized_shadow_material_pipeline_cache: Option<
788832
ResMut<SpecializedShadowMaterialPipelineCache>,
789833
>,
834+
render_material_instances: Res<RenderMaterialInstances>,
790835
views: Query<&ExtractedView>,
791-
ticks: SystemChangeTick,
792836
) where
793837
M: Material,
794838
{
795839
// Clean up any despawned entities, we do this first in case the removed material was re-added
796840
// the same frame, thus will appear both in the removed components list and have been added to
797841
// the `EntitiesNeedingSpecialization` collection by triggering the `Changed` filter
798842
//
799-
// Additionally, we need to make sure that we are careful about materials that could have changed
800-
// type, e.g. from a `StandardMaterial` to a `CustomMaterial`, as this will also appear in the
801-
// removed components list. As such, we make sure that this system runs after `MaterialExtractionSystems`
802-
// so that the `RenderMaterialInstances` bookkeeping has already been done, and we can check if the entity
803-
// still has a valid material instance.
843+
// Additionally, we need to make sure that we are careful about materials
844+
// that could have changed type, e.g. from a `StandardMaterial` to a
845+
// `CustomMaterial`, as this will also appear in the removed components
846+
// list. As such, we make sure that this system runs after
847+
// `extract_entities_needs_specialization` so that the entity specialization
848+
// tick bookkeeping has already been done, and we can check if the entity's
849+
// tick was updated this frame.
804850
for entity in removed_mesh_material_components.read() {
805-
if material_instances
806-
.instances
807-
.contains_key(&MainEntity::from(entity))
851+
// If the entity's specialization tick was updated this frame, that
852+
// means that that entity changed materials this frame. Don't remove the
853+
// entity from the table in that case.
854+
if entity_specialization_ticks
855+
.get(&MainEntity::from(entity))
856+
.is_some_and(|ticks| {
857+
ticks.material_instances_tick == render_material_instances.current_change_tick
858+
})
808859
{
809860
continue;
810861
}
@@ -830,11 +881,6 @@ pub fn extract_entities_needs_specialization<M>(
830881
}
831882
}
832883
}
833-
834-
for entity in entities_needing_specialization.iter() {
835-
// Update the entity's specialization tick with this run's tick
836-
entity_specialization_ticks.insert((*entity).into(), ticks.this_run());
837-
}
838884
}
839885

840886
#[derive(Resource, Deref, DerefMut, Clone, Debug)]
@@ -853,10 +899,58 @@ impl<M> Default for EntitiesNeedingSpecialization<M> {
853899
}
854900
}
855901

902+
/// Stores ticks specifying the last time Bevy specialized the pipelines of each
903+
/// entity.
904+
///
905+
/// Every entity that has a mesh and material must be present in this table,
906+
/// even if that mesh isn't visible.
856907
#[derive(Resource, Deref, DerefMut, Default, Clone, Debug)]
857908
pub struct EntitySpecializationTicks {
909+
/// A mapping from each main entity to ticks that specify the last time this
910+
/// entity's pipeline was specialized.
911+
///
912+
/// Every entity that has a mesh and material must be present in this table,
913+
/// even if that mesh isn't visible.
858914
#[deref]
859-
pub entities: MainEntityHashMap<Tick>,
915+
pub entities: MainEntityHashMap<EntitySpecializationTickPair>,
916+
}
917+
918+
/// Ticks that specify the last time an entity's pipeline was specialized.
919+
///
920+
/// We need two different types of ticks here for a subtle reason. First, we
921+
/// need the [`Self::system_tick`], which maps to Bevy's [`SystemChangeTick`],
922+
/// because that's what we use in [`specialize_material_meshes`] to check
923+
/// whether pipelines need specialization. But we also need
924+
/// [`Self::material_instances_tick`], which maps to the
925+
/// [`RenderMaterialInstances::current_change_tick`]. That's because the latter
926+
/// only changes once per frame, which is a guarantee we need to handle the
927+
/// following case:
928+
///
929+
/// 1. The app removes material A from a mesh and replaces it with material B.
930+
/// Both A and B are of different [`Material`] types entirely.
931+
///
932+
/// 2. [`extract_entities_needs_specialization`] runs for material B and marks
933+
/// the mesh as up to date by recording the current tick.
934+
///
935+
/// 3. [`sweep_entities_needing_specialization`] runs for material A and checks
936+
/// to ensure it's safe to remove the [`EntitySpecializationTickPair`] for the mesh
937+
/// from the [`EntitySpecializationTicks`]. To do this, it needs to know
938+
/// whether [`extract_entities_needs_specialization`] for some *different*
939+
/// material (in this case, material B) ran earlier in the frame and updated the
940+
/// change tick, and to skip removing the [`EntitySpecializationTickPair`] if so.
941+
/// It can't reliably use the [`Self::system_tick`] to determine this because
942+
/// the [`SystemChangeTick`] can be updated multiple times in the same frame.
943+
/// Instead, it needs a type of tick that's updated only once per frame, after
944+
/// all materials' versions of [`sweep_entities_needing_specialization`] have
945+
/// run. The [`RenderMaterialInstances`] tick satisfies this criterion, and so
946+
/// that's what [`sweep_entities_needing_specialization`] uses.
947+
#[derive(Clone, Copy, Debug)]
948+
pub struct EntitySpecializationTickPair {
949+
/// The standard Bevy system tick.
950+
pub system_tick: Tick,
951+
/// The tick in [`RenderMaterialInstances`], which is updated in
952+
/// `late_sweep_material_instances`.
953+
pub material_instances_tick: Tick,
860954
}
861955

862956
/// Stores the [`SpecializedMaterialViewPipelineCache`] for each view.
@@ -966,7 +1060,10 @@ pub fn specialize_material_meshes(
9661060
else {
9671061
continue;
9681062
};
969-
let entity_tick = entity_specialization_ticks.get(visible_entity).unwrap();
1063+
let entity_tick = entity_specialization_ticks
1064+
.get(visible_entity)
1065+
.unwrap()
1066+
.system_tick;
9701067
let last_specialized_tick = view_specialized_material_pipeline_cache
9711068
.get(visible_entity)
9721069
.map(|(tick, _)| *tick);

crates/bevy_pbr/src/prepass/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -877,7 +877,10 @@ pub fn specialize_prepass_material_meshes(
877877
else {
878878
continue;
879879
};
880-
let entity_tick = entity_specialization_ticks.get(visible_entity).unwrap();
880+
let entity_tick = entity_specialization_ticks
881+
.get(visible_entity)
882+
.unwrap()
883+
.system_tick;
881884
let last_specialized_tick = view_specialized_material_pipeline_cache
882885
.get(visible_entity)
883886
.map(|(tick, _)| *tick);

crates/bevy_pbr/src/render/light.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1844,7 +1844,9 @@ pub fn specialize_shadows(
18441844
.map(|(tick, _)| *tick);
18451845
let needs_specialization = last_specialized_tick.is_none_or(|tick| {
18461846
view_tick.is_newer_than(tick, ticks.this_run())
1847-
|| entity_tick.is_newer_than(tick, ticks.this_run())
1847+
|| entity_tick
1848+
.system_tick
1849+
.is_newer_than(tick, ticks.this_run())
18481850
});
18491851
if !needs_specialization {
18501852
continue;

crates/bevy_sprite_render/src/mesh2d/material.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ where
283283

284284
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
285285
render_app
286-
.init_resource::<EntitySpecializationTicks<M>>()
286+
.init_resource::<EntitySpecializationTickPair<M>>()
287287
.init_resource::<SpecializedMaterial2dPipelineCache<M>>()
288288
.add_render_command::<Opaque2d, DrawMaterial2d<M>>()
289289
.add_render_command::<AlphaMask2d, DrawMaterial2d<M>>()
@@ -566,7 +566,7 @@ pub const fn tonemapping_pipeline_key(tonemapping: Tonemapping) -> Mesh2dPipelin
566566

567567
pub fn extract_entities_needs_specialization<M>(
568568
entities_needing_specialization: Extract<Res<EntitiesNeedingSpecialization<M>>>,
569-
mut entity_specialization_ticks: ResMut<EntitySpecializationTicks<M>>,
569+
mut entity_specialization_ticks: ResMut<EntitySpecializationTickPair<M>>,
570570
mut removed_mesh_material_components: Extract<RemovedComponents<MeshMaterial2d<M>>>,
571571
mut specialized_material2d_pipeline_cache: ResMut<SpecializedMaterial2dPipelineCache<M>>,
572572
views: Query<&MainEntity, With<ExtractedView>>,
@@ -608,13 +608,13 @@ impl<M> Default for EntitiesNeedingSpecialization<M> {
608608
}
609609

610610
#[derive(Clone, Resource, Deref, DerefMut, Debug)]
611-
pub struct EntitySpecializationTicks<M> {
611+
pub struct EntitySpecializationTickPair<M> {
612612
#[deref]
613613
pub entities: MainEntityHashMap<Tick>,
614614
_marker: PhantomData<M>,
615615
}
616616

617-
impl<M> Default for EntitySpecializationTicks<M> {
617+
impl<M> Default for EntitySpecializationTickPair<M> {
618618
fn default() -> Self {
619619
Self {
620620
entities: MainEntityHashMap::default(),
@@ -702,7 +702,7 @@ pub fn specialize_material2d_meshes<M: Material2d>(
702702
alpha_mask_render_phases: Res<ViewBinnedRenderPhases<AlphaMask2d>>,
703703
views: Query<(&MainEntity, &ExtractedView, &RenderVisibleEntities)>,
704704
view_key_cache: Res<ViewKeyCache>,
705-
entity_specialization_ticks: Res<EntitySpecializationTicks<M>>,
705+
entity_specialization_ticks: Res<EntitySpecializationTickPair<M>>,
706706
view_specialization_ticks: Res<ViewSpecializationTicks>,
707707
ticks: SystemChangeTick,
708708
mut specialized_material_pipeline_cache: ResMut<SpecializedMaterial2dPipelineCache<M>>,

examples/3d/manual_material.rs

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ use bevy::{
88
SystemChangeTick, SystemParamItem,
99
},
1010
pbr::{
11-
DrawMaterial, EntitiesNeedingSpecialization, EntitySpecializationTicks,
12-
MaterialBindGroupAllocator, MaterialBindGroupAllocators, MaterialDrawFunction,
11+
late_sweep_material_instances, DrawMaterial, EntitiesNeedingSpecialization,
12+
EntitySpecializationTickPair, EntitySpecializationTicks, MaterialBindGroupAllocator,
13+
MaterialBindGroupAllocators, MaterialDrawFunction,
14+
MaterialExtractEntitiesNeedingSpecializationSystems, MaterialExtractionSystems,
1315
MaterialFragmentShader, MaterialProperties, PreparedMaterial, RenderMaterialBindings,
1416
RenderMaterialInstance, RenderMaterialInstances, SpecializedMaterialPipelineCache,
1517
},
@@ -66,7 +68,12 @@ impl Plugin for ImageMaterialPlugin {
6668
ExtractSchedule,
6769
(
6870
extract_image_materials,
69-
extract_image_materials_needing_specialization,
71+
extract_image_materials_needing_specialization
72+
.in_set(MaterialExtractEntitiesNeedingSpecializationSystems),
73+
sweep_image_materials_needing_specialization
74+
.after(MaterialExtractEntitiesNeedingSpecializationSystems)
75+
.after(MaterialExtractionSystems)
76+
.before(late_sweep_material_instances),
7077
),
7178
);
7279
}
@@ -285,6 +292,7 @@ fn extract_image_materials_needing_specialization(
285292
mut entity_specialization_ticks: ResMut<EntitySpecializationTicks>,
286293
mut removed_mesh_material_components: Extract<RemovedComponents<ImageMaterial3d>>,
287294
mut specialized_material_pipeline_cache: ResMut<SpecializedMaterialPipelineCache>,
295+
render_material_instances: Res<RenderMaterialInstances>,
288296
views: Query<&ExtractedView>,
289297
ticks: SystemChangeTick,
290298
) {
@@ -304,6 +312,44 @@ fn extract_image_materials_needing_specialization(
304312

305313
for entity in entities_needing_specialization.iter() {
306314
// Update the entity's specialization tick with this run's tick
307-
entity_specialization_ticks.insert((*entity).into(), ticks.this_run());
315+
entity_specialization_ticks.insert(
316+
(*entity).into(),
317+
EntitySpecializationTickPair {
318+
system_tick: ticks.this_run(),
319+
material_instances_tick: render_material_instances.current_change_tick,
320+
},
321+
);
322+
}
323+
}
324+
325+
fn sweep_image_materials_needing_specialization(
326+
mut entity_specialization_ticks: ResMut<EntitySpecializationTicks>,
327+
mut removed_mesh_material_components: Extract<RemovedComponents<ImageMaterial3d>>,
328+
mut specialized_material_pipeline_cache: ResMut<SpecializedMaterialPipelineCache>,
329+
render_material_instances: Res<RenderMaterialInstances>,
330+
views: Query<&ExtractedView>,
331+
) {
332+
// Clean up any despawned entities, we do this first in case the removed material was re-added
333+
// the same frame, thus will appear both in the removed components list and have been added to
334+
// the `EntitiesNeedingSpecialization` collection by triggering the `Changed` filter
335+
for entity in removed_mesh_material_components.read() {
336+
if entity_specialization_ticks
337+
.get(&MainEntity::from(entity))
338+
.is_some_and(|ticks| {
339+
ticks.material_instances_tick == render_material_instances.current_change_tick
340+
})
341+
{
342+
continue;
343+
}
344+
345+
entity_specialization_ticks.remove(&MainEntity::from(entity));
346+
347+
for view in views {
348+
if let Some(cache) =
349+
specialized_material_pipeline_cache.get_mut(&view.retained_view_entity)
350+
{
351+
cache.remove(&MainEntity::from(entity));
352+
}
353+
}
308354
}
309355
}

0 commit comments

Comments
 (0)