Transform Propagation Optimization: Static Subtree Marking (#18589)

# Objective

- Optimize static scene performance by marking unchanged subtrees.
-
[bef0209](bef0209de1)
fixes #18255 and #18363.
- Closes #18365 
- Includes change from #18321

## Solution

- Mark hierarchy subtrees with dirty bits to avoid transform propagation
where not needed
- This causes a performance regression when spawning many entities, or
when the scene is entirely dynamic.
- This results in massive speedups for largely static scenes.
- In the future we could allow the user to change this behavior, or add
some threshold based on how dynamic the scene is?

## Testing

- Caldera Hotel scene
This commit is contained in:
Aevyrie 2025-03-29 19:43:39 -07:00 committed by François Mockers
parent f8f7dfe792
commit c4139fe296
4 changed files with 139 additions and 133 deletions

View File

@ -63,7 +63,11 @@ fn assert_is_normalized(message: &str, length_squared: f32) {
/// [transform_example]: https://github.com/bevyengine/bevy/blob/latest/examples/transforms/transform.rs /// [transform_example]: https://github.com/bevyengine/bevy/blob/latest/examples/transforms/transform.rs
#[derive(Debug, PartialEq, Clone, Copy)] #[derive(Debug, PartialEq, Clone, Copy)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "bevy-support", derive(Component), require(GlobalTransform))] #[cfg_attr(
feature = "bevy-support",
derive(Component),
require(GlobalTransform, TransformTreeChanged)
)]
#[cfg_attr( #[cfg_attr(
feature = "bevy_reflect", feature = "bevy_reflect",
derive(Reflect), derive(Reflect),
@ -644,3 +648,20 @@ impl Mul<Vec3> for Transform {
self.transform_point(value) self.transform_point(value)
} }
} }
/// An optimization for transform propagation. This ZST marker component uses change detection to
/// mark all entities of the hierarchy as "dirty" if any of their descendants have a changed
/// `Transform`. If this component is *not* marked `is_changed()`, propagation will halt.
#[derive(Clone, Copy, Default, PartialEq, Debug)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "bevy-support", derive(Component))]
#[cfg_attr(
feature = "bevy_reflect",
derive(Reflect),
reflect(Component, Default, PartialEq, Debug)
)]
#[cfg_attr(
all(feature = "bevy_reflect", feature = "serialize"),
reflect(Serialize, Deserialize)
)]
pub struct TransformTreeChanged;

View File

@ -1,6 +1,4 @@
use crate::systems::{ use crate::systems::{mark_dirty_trees, propagate_parent_transforms, sync_simple_transforms};
compute_transform_leaves, propagate_parent_transforms, sync_simple_transforms,
};
use bevy_app::{App, Plugin, PostStartup, PostUpdate}; use bevy_app::{App, Plugin, PostStartup, PostUpdate};
use bevy_ecs::schedule::{IntoScheduleConfigs, SystemSet}; use bevy_ecs::schedule::{IntoScheduleConfigs, SystemSet};
@ -17,43 +15,33 @@ pub struct TransformPlugin;
impl Plugin for TransformPlugin { impl Plugin for TransformPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// A set for `propagate_transforms` to mark it as ambiguous with `sync_simple_transforms`.
// Used instead of the `SystemTypeSet` as that would not allow multiple instances of the system.
#[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)]
struct PropagateTransformsSet;
#[cfg(feature = "bevy_reflect")] #[cfg(feature = "bevy_reflect")]
app.register_type::<crate::components::Transform>() app.register_type::<crate::components::Transform>()
.register_type::<crate::components::TransformTreeChanged>()
.register_type::<crate::components::GlobalTransform>(); .register_type::<crate::components::GlobalTransform>();
app.configure_sets( app
PostStartup, // add transform systems to startup so the first update is "correct"
PropagateTransformsSet.in_set(TransformSystem::TransformPropagate), .add_systems(
) PostStartup,
// add transform systems to startup so the first update is "correct" (
.add_systems( mark_dirty_trees,
PostStartup, propagate_parent_transforms,
( sync_simple_transforms,
propagate_parent_transforms, )
(compute_transform_leaves, sync_simple_transforms) .chain()
.ambiguous_with(TransformSystem::TransformPropagate), .in_set(TransformSystem::TransformPropagate),
) )
.chain() .add_systems(
.in_set(PropagateTransformsSet), PostUpdate,
) (
.configure_sets( mark_dirty_trees,
PostUpdate, propagate_parent_transforms,
PropagateTransformsSet.in_set(TransformSystem::TransformPropagate), // TODO: Adjust the internal parallel queries to make this system more efficiently share and fill CPU time.
) sync_simple_transforms,
.add_systems( )
PostUpdate, .chain()
( .in_set(TransformSystem::TransformPropagate),
propagate_parent_transforms, );
(compute_transform_leaves, sync_simple_transforms) // TODO: Adjust the internal parallel queries to make these parallel systems more efficiently share and fill CPU time.
.ambiguous_with(TransformSystem::TransformPropagate),
)
.chain()
.in_set(PropagateTransformsSet),
);
} }
} }

View File

@ -1,6 +1,5 @@
use crate::components::{GlobalTransform, Transform}; use crate::components::{GlobalTransform, Transform, TransformTreeChanged};
use bevy_ecs::prelude::*; use bevy_ecs::prelude::*;
#[cfg(feature = "std")] #[cfg(feature = "std")]
pub use parallel::propagate_parent_transforms; pub use parallel::propagate_parent_transforms;
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
@ -9,7 +8,7 @@ pub use serial::propagate_parent_transforms;
/// Update [`GlobalTransform`] component of entities that aren't in the hierarchy /// Update [`GlobalTransform`] component of entities that aren't in the hierarchy
/// ///
/// Third party plugins should ensure that this is used in concert with /// Third party plugins should ensure that this is used in concert with
/// [`propagate_parent_transforms`] and [`compute_transform_leaves`]. /// [`propagate_parent_transforms`] and [`mark_dirty_trees`].
pub fn sync_simple_transforms( pub fn sync_simple_transforms(
mut query: ParamSet<( mut query: ParamSet<(
Query< Query<
@ -41,28 +40,33 @@ pub fn sync_simple_transforms(
} }
} }
/// Compute leaf [`GlobalTransform`]s in parallel. /// Optimization for static scenes. Propagates a "dirty bit" up the hierarchy towards ancestors.
/// /// Transform propagation can ignore entire subtrees of the hierarchy if it encounters an entity
/// This is run after [`propagate_parent_transforms`], to ensure the parents' [`GlobalTransform`]s /// without the dirty bit.
/// have been computed. This makes computing leaf nodes at different levels of the hierarchy much pub fn mark_dirty_trees(
/// more cache friendly, because data can be iterated over densely from the same archetype. changed_transforms: Query<
pub fn compute_transform_leaves( Entity,
parents: Query<Ref<GlobalTransform>, With<Children>>, Or<(Changed<Transform>, Changed<ChildOf>, Added<GlobalTransform>)>,
mut leaves: Query<(Ref<Transform>, &mut GlobalTransform, &ChildOf), Without<Children>>, >,
mut orphaned: RemovedComponents<ChildOf>,
mut transforms: Query<(Option<&ChildOf>, &mut TransformTreeChanged)>,
) { ) {
leaves for entity in changed_transforms.iter().chain(orphaned.read()) {
.par_iter_mut() let mut next = entity;
.for_each(|(transform, mut global_transform, child_of)| { while let Ok((parent, mut tree)) = transforms.get_mut(next) {
let Ok(parent_transform) = parents.get(child_of.parent) else { if tree.is_changed() && !tree.is_added() {
return; // If the component was changed, this part of the tree has already been processed.
}; // Ignore this if the change was caused by the component being added.
if parent_transform.is_changed() break;
|| transform.is_changed()
|| global_transform.is_added()
{
*global_transform = parent_transform.mul_transform(*transform);
} }
}); tree.set_changed();
if let Some(parent) = parent.map(|p| p.parent) {
next = parent;
} else {
break;
};
}
}
} }
// TODO: This serial implementation isn't actually serial, it parallelizes across the roots. // TODO: This serial implementation isn't actually serial, it parallelizes across the roots.
@ -91,7 +95,7 @@ mod serial {
/// ///
/// Third party plugins should ensure that this is used in concert with /// Third party plugins should ensure that this is used in concert with
/// [`sync_simple_transforms`](super::sync_simple_transforms) and /// [`sync_simple_transforms`](super::sync_simple_transforms) and
/// [`compute_transform_leaves`](super::compute_transform_leaves). /// [`mark_dirty_trees`](super::mark_dirty_trees).
pub fn propagate_parent_transforms( pub fn propagate_parent_transforms(
mut root_query: Query< mut root_query: Query<
(Entity, &Children, Ref<Transform>, &mut GlobalTransform), (Entity, &Children, Ref<Transform>, &mut GlobalTransform),
@ -100,7 +104,7 @@ mod serial {
mut orphaned: RemovedComponents<ChildOf>, mut orphaned: RemovedComponents<ChildOf>,
transform_query: Query< transform_query: Query<
(Ref<Transform>, &mut GlobalTransform, Option<&Children>), (Ref<Transform>, &mut GlobalTransform, Option<&Children>),
(With<ChildOf>, With<Children>), With<ChildOf>,
>, >,
child_query: Query<(Entity, Ref<ChildOf>), With<GlobalTransform>>, child_query: Query<(Entity, Ref<ChildOf>), With<GlobalTransform>>,
mut orphaned_entities: Local<Vec<Entity>>, mut orphaned_entities: Local<Vec<Entity>>,
@ -168,7 +172,7 @@ mod serial {
parent: &GlobalTransform, parent: &GlobalTransform,
transform_query: &Query< transform_query: &Query<
(Ref<Transform>, &mut GlobalTransform, Option<&Children>), (Ref<Transform>, &mut GlobalTransform, Option<&Children>),
(With<ChildOf>, With<Children>), With<ChildOf>,
>, >,
child_query: &Query<(Entity, Ref<ChildOf>), With<GlobalTransform>>, child_query: &Query<(Entity, Ref<ChildOf>), With<GlobalTransform>>,
entity: Entity, entity: Entity,
@ -245,12 +249,12 @@ mod serial {
#[cfg(feature = "std")] #[cfg(feature = "std")]
mod parallel { mod parallel {
use crate::prelude::*; use crate::prelude::*;
// TODO: this implementation could be used in no_std if there are equivalents of these.
use alloc::{sync::Arc, vec::Vec}; use alloc::{sync::Arc, vec::Vec};
use bevy_ecs::{entity::UniqueEntityIter, prelude::*, system::lifetimeless::Read}; use bevy_ecs::{entity::UniqueEntityIter, prelude::*, system::lifetimeless::Read};
use bevy_tasks::{ComputeTaskPool, TaskPool}; use bevy_tasks::{ComputeTaskPool, TaskPool};
use bevy_utils::Parallel; use bevy_utils::Parallel;
use core::sync::atomic::{AtomicI32, Ordering}; use core::sync::atomic::{AtomicI32, Ordering};
// TODO: this implementation could be used in no_std if there are equivalents of these.
use std::sync::{ use std::sync::{
mpsc::{Receiver, Sender}, mpsc::{Receiver, Sender},
Mutex, Mutex,
@ -261,32 +265,20 @@ mod parallel {
/// ///
/// Third party plugins should ensure that this is used in concert with /// Third party plugins should ensure that this is used in concert with
/// [`sync_simple_transforms`](super::sync_simple_transforms) and /// [`sync_simple_transforms`](super::sync_simple_transforms) and
/// [`compute_transform_leaves`](super::compute_transform_leaves). /// [`mark_dirty_trees`](super::mark_dirty_trees).
pub fn propagate_parent_transforms( pub fn propagate_parent_transforms(
mut queue: Local<WorkQueue>, mut queue: Local<WorkQueue>,
mut orphaned: RemovedComponents<ChildOf>,
mut orphans: Local<Vec<Entity>>,
mut roots: Query< mut roots: Query<
(Entity, Ref<Transform>, &mut GlobalTransform, &Children), (Entity, Ref<Transform>, &mut GlobalTransform, &Children),
Without<ChildOf>, (Without<ChildOf>, Changed<TransformTreeChanged>),
>, >,
nodes: NodeQuery, nodes: NodeQuery,
) { ) {
// Orphans
orphans.clear();
orphans.extend(orphaned.read());
orphans.sort_unstable();
// Process roots in parallel, seeding the work queue // Process roots in parallel, seeding the work queue
roots.par_iter_mut().for_each_init( roots.par_iter_mut().for_each_init(
|| queue.local_queue.borrow_local_mut(), || queue.local_queue.borrow_local_mut(),
|outbox, (parent, transform, mut parent_transform, children)| { |outbox, (parent, transform, mut parent_transform, children)| {
if transform.is_changed() *parent_transform = GlobalTransform::from(*transform);
|| parent_transform.is_added()
|| orphans.binary_search(&parent).is_ok()
{
*parent_transform = GlobalTransform::from(*transform);
}
// SAFETY: the parent entities passed into this function are taken from iterating // SAFETY: the parent entities passed into this function are taken from iterating
// over the root entity query. Queries iterate over disjoint entities, preventing // over the root entity query. Queries iterate over disjoint entities, preventing
@ -315,6 +307,18 @@ mod parallel {
// number of channel sends by avoiding sending partial batches. // number of channel sends by avoiding sending partial batches.
queue.send_batches(); queue.send_batches();
if let Ok(rx) = queue.receiver.try_lock() {
if let Some(task) = rx.try_iter().next() {
// This is a bit silly, but the only way to see if there is any work is to grab a
// task. Peeking will remove the task even if you don't call `next`, resulting in
// dropping a task. What we do here is grab the first task if there is one, then
// immediately send it to the back of the queue.
queue.sender.send(task).ok();
} else {
return; // No work, don't bother spawning any tasks
}
}
// Spawn workers on the task pool to recursively propagate the hierarchy in parallel. // Spawn workers on the task pool to recursively propagate the hierarchy in parallel.
let task_pool = ComputeTaskPool::get_or_init(TaskPool::default); let task_pool = ComputeTaskPool::get_or_init(TaskPool::default);
task_pool.scope(|s| { task_pool.scope(|s| {
@ -373,12 +377,12 @@ mod parallel {
// the hierarchy, guaranteeing unique access. // the hierarchy, guaranteeing unique access.
#[expect(unsafe_code, reason = "Mutating disjoint entities in parallel")] #[expect(unsafe_code, reason = "Mutating disjoint entities in parallel")]
unsafe { unsafe {
let (_, (_, p_global_transform), (p_children, _)) = let (_, (_, p_global_transform, _), (p_children, _)) =
nodes.get_unchecked(parent).unwrap(); nodes.get_unchecked(parent).unwrap();
propagate_descendants_unchecked( propagate_descendants_unchecked(
parent, parent,
p_global_transform, p_global_transform,
p_children, p_children.unwrap(), // All entities in the queue should have children
nodes, nodes,
&mut outbox, &mut outbox,
queue, queue,
@ -395,12 +399,8 @@ mod parallel {
} }
} }
/// Propagate transforms from `parent` to its non-leaf `children`, pushing updated child /// Propagate transforms from `parent` to its `children`, pushing updated child entities to the
/// entities to the `outbox`. Propagation does not visit leaf nodes; instead, they are computed /// `outbox`. This function will continue propagating transforms to descendants in a depth-first
/// in [`compute_transform_leaves`](super::compute_transform_leaves), which can optimize much
/// more efficiently.
///
/// This function will continue propagating transforms to descendants in a depth-first
/// traversal, while simultaneously pushing unvisited branches to the outbox, for other threads /// traversal, while simultaneously pushing unvisited branches to the outbox, for other threads
/// to take when idle. /// to take when idle.
/// ///
@ -440,38 +440,29 @@ mod parallel {
// visiting disjoint entities in parallel, which is safe. // visiting disjoint entities in parallel, which is safe.
#[expect(unsafe_code, reason = "Mutating disjoint entities in parallel")] #[expect(unsafe_code, reason = "Mutating disjoint entities in parallel")]
let children_iter = unsafe { let children_iter = unsafe {
// Performance note: iter_many tests every child to see if it meets the query. For
// leaf nodes, this unfortunately means we have the pay the price of checking every
// child, even if it is a leaf node and is skipped.
//
// To ensure this is still the fastest design, I tried removing the second pass
// (`compute_transform_leaves`) and instead simply doing that here. However, that
// proved to be much slower than two pass for a few reasons:
// - it's less cache friendly and is outright slower than the tight loop in the
// second pass
// - it prevents parallelism, as all children must be iterated in series
//
// The only way I can see to make this faster when there are many leaf nodes is to
// speed up archetype checking to make the iterator skip leaf entities more quickly,
// or encoding the hierarchy level as a component. That, or use some kind of change
// detection to mark dirty subtrees when the transform is mutated.
nodes.iter_many_unique_unsafe(UniqueEntityIter::from_iterator_unchecked( nodes.iter_many_unique_unsafe(UniqueEntityIter::from_iterator_unchecked(
p_children.iter(), p_children.iter(),
)) ))
}; };
let mut last_child = None; let mut last_child = None;
let new_children = children_iter.map( let new_children = children_iter.filter_map(
|(child, (transform, mut global_transform), (children, child_of))| { |(child, (transform, mut global_transform, tree), (children, child_of))| {
assert_eq!(child_of.parent, parent); if !tree.is_changed() && !p_global_transform.is_changed() {
if p_global_transform.is_changed() // Static scene optimization
|| transform.is_changed() return None;
|| global_transform.is_added()
{
*global_transform = p_global_transform.mul_transform(*transform);
} }
last_child = Some((child, global_transform, children)); assert_eq!(child_of.parent, parent);
child
// Transform prop is expensive - this helps avoid updating entire subtrees if
// the GlobalTransform is unchanged, at the cost of an added equality check.
global_transform.set_if_neq(p_global_transform.mul_transform(*transform));
children.map(|children| {
// Only continue propagation if the entity has children.
last_child = Some((child, global_transform, children));
child
})
}, },
); );
outbox.extend(new_children); outbox.extend(new_children);
@ -497,14 +488,18 @@ mod parallel {
} }
/// Alias for a large, repeatedly used query. Queries for transform entities that have both a /// Alias for a large, repeatedly used query. Queries for transform entities that have both a
/// parent and children, thus they are neither roots nor leaves. /// parent and possibly children, thus they are not roots.
type NodeQuery<'w, 's> = Query< type NodeQuery<'w, 's> = Query<
'w, 'w,
's, 's,
( (
Entity, Entity,
(Ref<'static, Transform>, Mut<'static, GlobalTransform>), (
(Read<Children>, Read<ChildOf>), Ref<'static, Transform>,
Mut<'static, GlobalTransform>,
Ref<'static, TransformTreeChanged>,
),
(Option<Read<Children>>, Read<ChildOf>),
), ),
>; >;
@ -579,9 +574,9 @@ mod test {
let mut schedule = Schedule::default(); let mut schedule = Schedule::default();
schedule.add_systems( schedule.add_systems(
( (
mark_dirty_trees,
sync_simple_transforms, sync_simple_transforms,
propagate_parent_transforms, propagate_parent_transforms,
compute_transform_leaves,
) )
.chain(), .chain(),
); );
@ -637,9 +632,9 @@ mod test {
let mut schedule = Schedule::default(); let mut schedule = Schedule::default();
schedule.add_systems( schedule.add_systems(
( (
mark_dirty_trees,
sync_simple_transforms, sync_simple_transforms,
propagate_parent_transforms, propagate_parent_transforms,
compute_transform_leaves,
) )
.chain(), .chain(),
); );
@ -674,9 +669,9 @@ mod test {
let mut schedule = Schedule::default(); let mut schedule = Schedule::default();
schedule.add_systems( schedule.add_systems(
( (
mark_dirty_trees,
sync_simple_transforms, sync_simple_transforms,
propagate_parent_transforms, propagate_parent_transforms,
compute_transform_leaves,
) )
.chain(), .chain(),
); );
@ -713,9 +708,9 @@ mod test {
let mut schedule = Schedule::default(); let mut schedule = Schedule::default();
schedule.add_systems( schedule.add_systems(
( (
mark_dirty_trees,
sync_simple_transforms, sync_simple_transforms,
propagate_parent_transforms, propagate_parent_transforms,
compute_transform_leaves,
) )
.chain(), .chain(),
); );
@ -793,9 +788,9 @@ mod test {
app.add_systems( app.add_systems(
Update, Update,
( (
mark_dirty_trees,
sync_simple_transforms, sync_simple_transforms,
propagate_parent_transforms, propagate_parent_transforms,
compute_transform_leaves,
) )
.chain(), .chain(),
); );
@ -847,12 +842,10 @@ mod test {
app.add_systems( app.add_systems(
Update, Update,
( // It is unsound for this unsafe system to encounter a cycle without panicking. This
propagate_parent_transforms, // requirement only applies to systems with unsafe parallel traversal that result in
sync_simple_transforms, // aliased mutability during a cycle.
compute_transform_leaves, propagate_parent_transforms,
)
.chain(),
); );
fn setup_world(world: &mut World) -> (Entity, Entity) { fn setup_world(world: &mut World) -> (Entity, Entity) {
@ -912,11 +905,14 @@ mod test {
// Create transform propagation schedule // Create transform propagation schedule
let mut schedule = Schedule::default(); let mut schedule = Schedule::default();
schedule.add_systems(( schedule.add_systems(
sync_simple_transforms, (
propagate_parent_transforms, mark_dirty_trees,
compute_transform_leaves, propagate_parent_transforms,
)); sync_simple_transforms,
)
.chain(),
);
// Spawn a `Transform` entity with a local translation of `Vec3::ONE` // Spawn a `Transform` entity with a local translation of `Vec3::ONE`
let mut spawn_transform_bundle = let mut spawn_transform_bundle =

View File

@ -353,9 +353,10 @@ mod tests {
use bevy_math::{Rect, UVec2, Vec2}; use bevy_math::{Rect, UVec2, Vec2};
use bevy_platform_support::collections::HashMap; use bevy_platform_support::collections::HashMap;
use bevy_render::{camera::ManualTextureViews, prelude::Camera}; use bevy_render::{camera::ManualTextureViews, prelude::Camera};
use bevy_transform::systems::mark_dirty_trees;
use bevy_transform::{ use bevy_transform::{
prelude::GlobalTransform, prelude::GlobalTransform,
systems::{compute_transform_leaves, propagate_parent_transforms, sync_simple_transforms}, systems::{propagate_parent_transforms, sync_simple_transforms},
}; };
use bevy_utils::prelude::default; use bevy_utils::prelude::default;
use bevy_window::{ use bevy_window::{
@ -408,9 +409,9 @@ mod tests {
update_ui_context_system, update_ui_context_system,
ApplyDeferred, ApplyDeferred,
ui_layout_system, ui_layout_system,
mark_dirty_trees,
sync_simple_transforms, sync_simple_transforms,
propagate_parent_transforms, propagate_parent_transforms,
compute_transform_leaves,
) )
.chain(), .chain(),
); );