Bevy ECS Change Detection: Filter Systems by Modified Components
Bevy's archetype based ECS stores components in contiguous blocks for cache friendly iteration but structural changes can fragment memory. Change detection queries let systems run only when components actually modify improving performance without sacrificing flexibility.
12 Nov 2025, 05:14 UTC

Archetype Storage and Cache Locality
Bevy stores components that appear together on entities inside archetypes contiguous memory blocks selected when an entity component mask matches an existing layout. When a system iterates over Transform and Velocity components laid out in the same archetype the CPU can stream through the data with predictable cache lines reducing L1 L2 misses that commonly stall frame pacing. This layout is a primary reason Bevy can maintain high entity counts at 60 FPS even on modest hardware because the processor prefetches adjacent component data efficiently.
Change Detection Skips Redundant Work
Running systems every frame regardless of whether component data actually changed is the most common ECS performance anti pattern. Bevy provides a Changed filter that integrates into queries at the archetype level the engine tracks a version counter for each component and a query with Changed Transform only matches entities whose Transform version incremented since the system last ran. This mechanism prevents expensive pathfinding UI recalculations or physics updates from executing on static entities saving cycles every frame.
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, log_movement_if_changed)
.run();
}
#[derive(Component)]
struct Player;
fn log_movement_if_changed(query: Query<(Entity, &Transform), Changed>> {
for (id, transform) in &query {
println!("Entity {:?} moved to {:?}", id, transform.translation);
}
}
fn setup(mut commands: Commands) {
commands.spawn(Transform::from_xyz(0.0, 0.0, 0.0));
commands.spawn(Transform::from_xyz(1.0, 1.0, 1.0));
Parallel Scheduling and Ordering Guarantees
Bevy scheduler analyzes each system read write access to component data and automatically parallelizes independent systems across CPU cores. If System A reads Transform and System B writes mutable Velocity they run simultaneously because their access patterns do not conflict. The scheduler builds a dependency graph per frame assigning systems to threads based on their component access sets. However when a downstream system logically depends on the output of an upstream system the default non deterministic order can introduce frame to frame variance using after or before constraints in app configuration enforces the intended execution sequence.
- .add_systems(Update, (system_b, system_c).after(system_a)) ensures system_a runs before system_b and system_c.
- Pairing these constraints with Changed filters keeps each system scope minimal and its thread assignment predictable.
Trade Off: Archetype Fragmentation
Frequently adding or removing components at runtime forces Bevy to move entities between archetypes which can scatter related component data and reduce cache efficiency over time. Each structural change increments archetype version counters and may trigger memory reallocation. To mitigate this batch component modifications when possible and avoid fine grained per entity component toggling in hot loops. A practical verification step is to profile memory fragmentation with cargo flamegraph or the Bevy profiler to observe archetype distribution across frames.
Closing
By combining archetype aware component layout with change detection filters you can write Bevy systems that are both fast and expressive. Start by identifying systems that run every frame but only need to react to actual changes add the Changed filter to their queries and verify the execution graph with the Bevy trace tool to confirm that independent systems parallelize as expected and that filtered entities are correctly skipped.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.