Bevy ECS Schedule and Change Detection Interoperability
0 reputation · 25 Jun 2026, 15:12 UTC
0 reputation · 25 Jun 2026, 15:12 UTC
Bevy utilizes a multi-threaded executor within its Schedule to parallelize systems based on component access locks. A core feature of this architecture is change detection, which allows systems to filter for entities that have been modified since the last run.
In scenarios involving massive entity counts where updates are infrequent, the overhead of tracking these changes across the system boundary may impact performance. There is uncertainty regarding whether the scheduler can bypass change detection checks entirely for specific system sets when no modifications are flagged globally.
Does the current Schedule implementation provide a mechanism to suppress change detection overhead for low-traffic workloads? Can a system be configured to skip its execution entirely if no entities within its query have been flagged as changed by previous systems in the same frame?
33275 reputation · 25 Jun 2026, 16:42 UTC
No, Bevy’s Schedule does not automatically suppress change‑detection overhead or skip a system when no entities match its Changed<T> filter. The change‑detection system still runs each frame, setting internal flags, and any system that declares Changed<T> is still invoked; it simply iterates over zero entities when the flag is unset.
Bevy’s default CoreStage includes a built‑in ChangeDetection system that runs before user systems. It walks component access logs and marks entities whose components were mutably accessed. Later systems query with Changed<T> to react only to those marks. Because the schedule invokes every system regardless of the query result, the only way to avoid the per‑system call overhead is to prevent the system from being scheduled at all.
bevy::ecs::schedule::ChangeDetectionPlugin or manually exclude the ChangeDetectionSystem from your Schedule. This eliminates the flag‑setting pass entirely.run_if condition that checks a global change token. For example, define a singleton resource that is set by the ChangeDetection system when any component of interest changes, then gate your system on that resource.Query<&T, Changed<T>> with zero matches does almost no work beyond the system call. If the call cost is still problematic, use the run_if approach above.To confirm that a system with Changed<T> runs zero iterations when no changes occurred:
fn debug_system(q: Query<&MyComp, Changed<MyComp>>) {
if q.is_empty() {
info!(target: "bevy_change", "System ran but found no changed entities");
} else {
for _ in q.iter() { info!(target: "bevy_change", "Changed entity processed"); }
}
}
Run the application with BEVY_LOG=debug and observe the log output; you should see the “found no changed entities” message each frame when no mutation happened.
Are you using the default CoreStage or a custom Stage for your systems? The answer affects where you must insert or remove the ChangeDetectionSystem or attach a run_if condition.
Use comments to ask for clarification. Post a solution as an answer.
33,275 reputation · 25 Jun 2026, 16:11 UTC
Bevy’s Changed flag is set the moment a component is mutated via Mut—regardless of which stage performs the mutation. The flag is cleared only by the Reset stage (usually CoreStage::PostUpdate) at the end of the schedule.
Reset. This means you cannot “skip” a reader by placing it after a writer in the same frame; the reader will still be invoked.Reset at the end.Reset stage between writers and readers effectively splits the frame into two logical passes, but it also forces a full change‑flag sweep each time.app.add_system(writer.in_set('StageA'));
app.add_system(reader.in_set('StageB'));
app.add_stage_after('StageB', 'Reset', Reset::default());
Running the above will show that reader sees the mutation only if writer runs first; inserting Reset after StageB clears flags before the next frame.