ECS 进阶
概述
学习目标:
- 掌握变更检测(Change Detection)的使用方法
- 理解组件生命周期钩子(Component Hooks)的应用场景
- 学会使用关系系统(Relationships)建立实体间的关系
- 掌握并行查询(Parallel Queries)的性能优化技巧
- 理解查询组合(Query Combinations)的使用方法
- 学会使用观察者模式(Observers)响应组件变化
- 了解消息系统、错误处理、动态 ECS 等高级功能
前置知识要求:
- 核心编程框架(ECS)
- 组件(Components)
- 实体(Entities)
- 系统(Systems)
- 查询(Queries)
- 资源(Resources)
- 系统调度(Schedule & App)
核心概念
为什么需要高级 ECS 功能?
在复杂的游戏开发中,我们需要:
- 性能优化:通过并行查询提高系统执行效率
- 变更响应:检测组件和资源的变化,及时响应
- 关系管理:建立和维护实体间的复杂关系
- 生命周期管理:在组件的生命周期关键点执行逻辑
- 事件驱动:使用消息系统和观察者模式实现事件驱动逻辑
ECS 进阶功能概览
- 变更检测:检测组件和资源的变化
- 组件生命周期钩子:在组件添加、插入、替换、移除时执行逻辑
- 关系系统:建立自定义的实体关系
- 并行查询:使用并行迭代器提高性能
- 查询组合:处理实体间的交互
- 观察者模式:响应组件生命周期事件和自定义事件
- 消息系统:使用消息实现系统间通信
- 错误处理:处理系统执行中的错误
- 动态 ECS:动态创建组件和实体
基础用法
变更检测(Change Detection)
变更检测用于检测组件和资源的变化,是响应式编程的基础。
源代码文件:bevy/examples/ecs/change_detection.rs
代码示例:
use bevy::prelude::*;
#[derive(Component, PartialEq, Debug)]
struct MyComponent(f32);
#[derive(Resource, PartialEq, Debug)]
struct MyResource(f32);
fn change_component(time: Res<Time>, mut query: Query<(Entity, &mut MyComponent)>) {
for (entity, mut component) in &mut query {
if rand::rng().random_bool(0.1) {
let new_component = MyComponent(time.elapsed_secs().round());
info!("New value: {new_component:?} {entity}");
// 变更检测发生在可变解引用时,不考虑值是否实际相等
// 为了避免在值未实际改变时触发变更检测,可以使用 `set_if_neq` 方法
// 该方法要求组件实现 PartialEq
component.set_if_neq(new_component);
}
}
}
fn change_detection(
changed_components: Query<Ref<MyComponent>, Changed<MyComponent>>,
my_resource: Res<MyResource>,
) {
for component in &changed_components {
// 默认情况下,你只能知道组件被改变了
// 但如果有多个系统修改同一个组件,如何知道是哪个系统导致的改变?
warn!(
"Change detected!\n\t-> value: {:?}\n\t-> added: {}\n\t-> changed: {}\n\t-> changed by: {}",
component,
component.is_added(),
component.is_changed(),
// 如果启用 `track_location` 特性,可以解锁 `changed_by()` 方法
// 它返回组件或资源被改变的文件和行号
// 不建议在发布的游戏中使用,但对调试很有用!
component.changed_by()
);
}
if my_resource.is_changed() {
warn!(
"Change detected!\n\t-> value: {:?}\n\t-> added: {}\n\t-> changed: {}\n\t-> changed by: {}",
my_resource,
my_resource.is_added(),
my_resource.is_changed(),
my_resource.changed_by() // 与组件一样,需要 `track_location` 特性
);
}
}关键要点:
- 使用
Changed<T>过滤器查询已变更的组件 - 使用
Added<T>过滤器查询新添加的组件 - 使用
Ref<T>系统参数访问变更检测信息,但不过滤查询 - 使用
set_if_neq()方法避免不必要的变更检测 - 使用
is_changed()和is_added()检查变更状态 - 使用
changed_by()方法(需要track_location特性)获取变更位置
说明:
变更检测是 Bevy ECS 的核心功能之一。当组件或资源被修改时,Bevy 会自动跟踪这些变更。使用 Changed<T> 过滤器可以只查询已变更的组件,这对于性能优化很重要。set_if_neq() 方法可以避免在值未实际改变时触发变更检测,这对于实现 PartialEq 的组件很有用。
组件生命周期钩子(Component Hooks)
组件生命周期钩子允许在组件的生命周期关键点执行逻辑。
源代码文件:bevy/examples/ecs/component_hooks.rs
代码示例:
use bevy::{
ecs::component::{Mutable, StorageType},
ecs::lifecycle::{ComponentHook, HookContext},
prelude::*,
};
use std::collections::HashMap;
#[derive(Debug)]
struct MyComponent(KeyCode);
impl Component for MyComponent {
const STORAGE_TYPE: StorageType = StorageType::Table;
type Mutability = Mutable;
fn on_add() -> Option<ComponentHook> {
// 如果没有 on_add 钩子,返回 None
// 注意这是不实现钩子时的默认行为
None
}
}
fn setup(world: &mut World) {
// 为了注册组件钩子,组件必须:
// - 当前未被世界中的任何实体使用
// - 尚未注册该类型的钩子
// 这是为了防止覆盖插件和其他 crate 中定义的钩子,并保持性能
world
.register_component_hooks::<MyComponent>()
// 有 4 种组件生命周期钩子:`on_add`、`on_insert`、`on_replace` 和 `on_remove`
// 钩子有 2 个参数:
// - 一个 `DeferredWorld`,允许访问资源和组件数据以及 `Commands`
// - 一个 `HookContext`,提供以下上下文信息:
// - 触发钩子的实体
// - 触发组件的组件 ID,主要用于动态组件
// - 导致钩子触发的代码位置
//
// `on_add` 将在组件插入到没有该组件的实体上时触发
.on_add(
|mut world,
HookContext {
entity,
component_id,
caller,
..
}| {
// 可以在钩子内访问组件数据
let value = world.get::<MyComponent>(entity).unwrap().0;
println!(
"{component_id:?} added to {entity} with value {value:?}{}",
caller
.map(|location| format!("due to {location}"))
.unwrap_or_default()
);
// 或访问资源
world
.resource_mut::<MyComponentIndex>()
.insert(value, entity);
},
)
// `on_insert` 将在组件插入到实体上时触发,无论实体是否已有该组件
// 如果 `on_add` 运行了,则在 `on_add` 之后运行
.on_insert(|world, _| {
println!("Current Index: {:?}", world.resource::<MyComponentIndex>());
})
// `on_replace` 将在组件插入到已有该组件的实体上时触发
// 在值被替换之前运行
// 当组件从实体移除时也会触发,在 `on_remove` 之前运行
.on_replace(|mut world, context| {
let value = world.get::<MyComponent>(context.entity).unwrap().0;
world.resource_mut::<MyComponentIndex>().remove(&value);
})
// `on_remove` 将在组件从实体移除时触发
// 由于它在组件移除之前运行,你仍然可以访问组件数据
.on_remove(
|mut world,
HookContext {
entity,
component_id,
caller,
..
}| {
let value = world.get::<MyComponent>(entity).unwrap().0;
println!(
"{component_id:?} removed from {entity} with value {value:?}{}",
caller
.map(|location| format!("due to {location}"))
.unwrap_or_default()
);
// 也可以通过 `.commands()` 发出命令
world.commands().entity(entity).despawn();
},
);
}关键要点:
- 有 4 种组件生命周期钩子:
on_add、on_insert、on_replace、on_remove - 钩子可以访问
DeferredWorld和HookContext - 钩子可以访问组件数据、资源和
Commands - 钩子可以发送消息
- 组件必须未被使用且未注册钩子才能注册钩子
说明: 组件生命周期钩子用于在组件的生命周期关键点执行逻辑。它们对于维护索引、强制执行结构规则等场景很有用。但要注意,尽可能使用 Bevy 的变更检测或事件来响应组件变化,因为事件通常提供更好的性能和更灵活的集成。
关系系统(Relationships)
关系系统允许建立自定义的实体关系,类似于内置的 ChildOf/Children 关系。
源代码文件:bevy/examples/ecs/relationships.rs
代码示例:
use bevy::prelude::*;
/// 此实体正在瞄准的实体
///
/// 这是关系的真实来源,可以直接修改以改变目标
#[derive(Component, Debug)]
#[relationship(relationship_target = TargetedBy)]
struct Targeting(Entity);
/// 所有正在瞄准此实体的实体
///
/// 此组件使用派生 [`Relationship`] trait 引入的组件钩子进行响应式更新
/// 我们不应该直接修改此组件,但可以安全地读取其字段
#[derive(Component, Debug)]
#[relationship_target(relationship = Targeting)]
struct TargetedBy(Vec<Entity>);
fn spawning_entities_with_relationships(mut commands: Commands) {
// 调用 `.id()` 在生成实体后将返回生成实体的 `Entity` 标识符
// 即使实体本身尚未在世界中实例化
let alice = commands.spawn(Name::new("Alice")).id();
// 关系只是组件,所以我们可以将它们添加到正在生成的 bundle 中
let bob = commands.spawn((Name::new("Bob"), Targeting(alice))).id();
// `with_related` 和 `with_related_entities` 辅助方法可以更符合人体工程学地添加关系
let charlie = commands
.spawn((Name::new("Charlie"), Targeting(bob)))
// `with_related` 方法将生成一个带有 `Targeting` 关系的 bundle
.with_related::<Targeting>(Name::new("James"))
// `with_related_entities` 方法将自动将 `Targeting` 组件添加到闭包内生成的任何实体
.with_related_entities::<Targeting>(|related_spawner_commands| {
// 我们可以在这里生成多个实体,它们都会瞄准 `charlie`
related_spawner_commands.spawn(Name::new("Devon"));
})
.id();
// 简单地插入 `Targeting` 组件将自动创建并更新目标实体上的 `TargetedBy` 组件
// 我们可以在任何时候这样做;不仅仅是在实体生成时
commands.entity(alice).insert(Targeting(charlie));
}
fn mutate_relationships(name_query: Query<(Entity, &Name)>, mut commands: Commands) {
// 关系组件是不可变的!我们不能可变地查询 `Targeting` 组件并直接修改它
// 但我们可以插入一个新的 `Targeting` 组件来替换旧的
// 这允许 `Targeting` 组件上的钩子正确更新 `TargetedBy` 组件
// `TargetedBy` 组件将自动更新!
let devon = name_query
.iter()
.find(|(_entity, name)| name.as_str() == "Devon")
.unwrap()
.0;
let alice = name_query
.iter()
.find(|(_entity, name)| name.as_str() == "Alice")
.unwrap()
.0;
println!("Making Devon target Alice.\n");
commands.entity(devon).insert(Targeting(alice));
}关键要点:
- 使用
#[relationship(relationship_target = TargetedBy)]定义关系组件 - 使用
#[relationship_target(relationship = Targeting)]定义关系目标组件 - 关系组件是真实来源,可以直接修改
- 关系目标组件是响应式更新的,不应直接修改
- 使用
with_related和with_related_entities辅助方法添加关系 - 插入关系组件会自动更新关系目标组件
说明:
关系系统允许建立自定义的实体关系。Bevy 内置了 ChildOf/Children 关系用于变换和可见性传播,但你可以定义自己的关系。关系组件是真实来源,可以直接修改,而关系目标组件是响应式更新的,不应直接修改。
并行查询(Parallel Queries)
并行查询使用并行迭代器提高系统执行效率。
源代码文件:bevy/examples/ecs/parallel_query.rs
代码示例:
use bevy::{ecs::batching::BatchingStrategy, prelude::*};
#[derive(Component, Deref)]
struct Velocity(Vec2);
// 根据速度移动精灵
fn move_system(mut sprites: Query<(&mut Transform, &Velocity)>) {
// 在 ComputeTaskPool 上并行计算每个精灵的新位置
//
// 此示例仅用于演示目的。对于像加法这样便宜的操作,在只有 128 个元素时
// 使用 ParallelIterator 通常不会比使用普通 Iterator 更快
// 有关何时使用或不使用 ParallelIterator 的更多信息,请参阅 ParallelIterator 文档
sprites
.par_iter_mut()
.for_each(|(mut transform, velocity)| {
transform.translation += velocity.extend(0.0);
});
}
// 在窗口外反弹精灵
fn bounce_system(window: Query<&Window>, mut sprites: Query<(&Transform, &mut Velocity)>) {
let Ok(window) = window.single() else {
return;
};
let width = window.width();
let height = window.height();
// 也可以覆盖默认批次大小
// 在这种情况下,选择批次大小为 32 以限制 ParallelIterator 的开销
sprites
.par_iter_mut()
.batching_strategy(BatchingStrategy::fixed(32))
.for_each(|(transform, mut v)| {
// ...
});
}关键要点:
- 使用
par_iter_mut()获取并行迭代器 - 使用
batching_strategy()自定义批处理策略 - 并行查询适用于计算密集型操作
- 对于简单操作,并行查询可能不会更快
注意事项:
- 并行查询适用于计算密集型操作
- 对于简单操作,并行查询可能不会更快
- 可以使用
batching_strategy()自定义批处理策略
最佳实践:
- 只在计算密集型操作时使用并行查询
- 对于简单操作,使用普通查询
- 根据操作复杂度调整批处理策略
查询组合(Query Combinations)
查询组合用于处理实体间的交互,如碰撞检测。
源代码文件:bevy/examples/ecs/iter_combinations.rs
代码示例:
use bevy::prelude::*;
const GRAVITY_CONSTANT: f32 = 0.001;
#[derive(Component, Default)]
struct Mass(f32);
#[derive(Component, Default)]
struct Acceleration(Vec3);
fn interact_bodies(mut query: Query<(&Mass, &GlobalTransform, &mut Acceleration)>) {
let mut iter = query.iter_combinations_mut();
while let Some([(Mass(m1), transform1, mut acc1), (Mass(m2), transform2, mut acc2)]) =
iter.fetch_next()
{
let delta = transform2.translation() - transform1.translation();
let distance_sq: f32 = delta.length_squared();
let f = GRAVITY_CONSTANT / distance_sq;
let force_unit_mass = delta * f;
acc1.0 += force_unit_mass * *m2;
acc2.0 -= force_unit_mass * *m1;
}
}关键要点:
- 使用
iter_combinations_mut()获取可变组合迭代器 - 使用
fetch_next()获取下一对实体 - 查询组合会跳过重复的组合(如 (A, B) 和 (B, A))
- 查询组合用于处理实体间的成对交互
注意事项:
- 查询组合用于处理实体间的成对交互
- 查询组合会跳过重复的组合
- 查询组合对于大量实体可能较慢
最佳实践:
- 使用查询组合处理碰撞检测、物理交互等场景
- 注意性能影响,对于大量实体考虑使用空间分区
- 考虑使用并行查询组合提高性能
观察者模式(Observers)
观察者模式用于响应组件生命周期事件和自定义事件。
源代码文件:bevy/examples/ecs/observers.rs
代码示例:
use bevy::prelude::*;
#[derive(Component)]
struct Mine {
pos: Vec2,
size: f32,
}
/// 这是一个普通的 [`Event`]。任何观察它的观察者都会在它被触发时运行
#[derive(Event)]
struct ExplodeMines {
pos: Vec2,
radius: f32,
}
/// [`EntityEvent`] 是一种专门的 [`Event`] 类型,可以针对特定实体
/// 除了在触发时运行正常的"顶级"观察者(针对任何爆炸的实体)外
/// 它还会运行针对该事件的特定实体的任何观察者
#[derive(EntityEvent)]
struct Explode {
entity: Entity,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, (draw_shapes, handle_click))
// 观察者是在事件被"触发"时运行的系统
// 此观察者在 `ExplodeMines` 被触发时运行
.add_observer(
|explode_mines: On<ExplodeMines>,
mines: Query<&Mine>,
index: Res<SpatialIndex>,
mut commands: Commands| {
// 访问资源
for entity in index.get_nearby(explode_mines.pos) {
// 运行查询
let mine = mines.get(entity).unwrap();
if mine.pos.distance(explode_mines.pos) < mine.size + explode_mines.radius {
// 并排队命令,包括触发其他事件
// 这里我们为实体 `e` 触发 `Explode` 事件
commands.trigger(Explode { entity });
}
}
},
)
// 此观察者在 `Mine` 组件添加到实体时运行,并将其放置在简单的空间索引中
.add_observer(on_add_mine)
// 此观察者在 `Mine` 组件从实体移除时运行(包括销毁它)
// 并将其从空间索引中移除
.add_observer(on_remove_mine)
.run();
}
fn on_add_mine(add: On<Add, Mine>, query: Query<&Mine>, mut index: ResMut<SpatialIndex>) {
let mine = query.get(add.entity).unwrap();
let tile = (
(mine.pos.x / CELL_SIZE).floor() as i32,
(mine.pos.y / CELL_SIZE).floor() as i32,
);
index.map.entry(tile).or_default().insert(add.entity);
}
fn on_remove_mine(remove: On<Remove, Mine>, query: Query<&Mine>, mut index: ResMut<SpatialIndex>) {
let mine = query.get(remove.entity).unwrap();
let tile = (
(mine.pos.x / CELL_SIZE).floor() as i32,
(mine.pos.y / CELL_SIZE).floor() as i32,
);
index.map.entry(tile).and_modify(|set| {
set.remove(&remove.entity);
});
}关键要点:
- 观察者用于响应组件生命周期事件和自定义事件
- 使用
On<Add, T>响应组件添加 - 使用
On<Remove, T>响应组件移除 - 使用
On<Event>响应自定义事件 - 观察者可以访问资源、运行查询、发出命令
注意事项:
- 观察者用于响应组件生命周期事件和自定义事件
- 观察者可以访问资源、运行查询、发出命令
- 观察者比组件钩子更灵活,但开销更大
最佳实践:
- 使用观察者响应组件生命周期事件
- 使用观察者响应自定义事件
- 考虑性能影响,观察者可能比组件钩子更灵活但开销更大
进阶用法
消息系统(Messages)
消息系统用于实现系统间通信。
源代码文件:bevy/examples/ecs/message.rs
代码示例:
use bevy::prelude::*;
/// 这是一个 [`Message`],任何观察它的观察者都会在它被激活时运行
#[derive(Message)]
struct MyMessage;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_message::<MyMessage>()
.add_systems(Startup, setup)
.add_systems(Update, send_message)
.add_observer(receive_message)
.run();
}
fn send_message(mut commands: Commands) {
// 发送消息
commands.write_message(MyMessage);
}
fn receive_message(_message: On<MyMessage>) {
info!("Message received!");
}关键要点:
- 使用
Message派生宏定义消息 - 使用
add_message()注册消息 - 使用
write_message()发送消息 - 使用观察者接收消息
注意事项:
- 消息系统用于实现系统间通信
- 消息可以用于事件驱动逻辑
- 注意消息系统的性能影响
最佳实践:
- 对于系统间通信,使用消息系统
- 对于事件驱动逻辑,使用消息系统
- 注意消息系统的性能影响
错误处理
系统可以返回 Result 来处理错误。
源代码文件:bevy/examples/ecs/error_handling.rs
代码示例:
use bevy::ecs::error::warn;
fn main() {
let mut app = App::new();
// 默认情况下,返回错误的可失败系统会 panic
//
// 我们可以通过设置自定义错误处理器来改变这一点
// 它适用于整个应用
// 这里我们使用内置错误处理器之一
app.set_error_handler(warn);
app.add_plugins(DefaultPlugins);
app.add_systems(Startup, setup);
// 单个系统也可以通过管道输出结果来处理:
app.add_systems(
PostStartup,
failing_system.pipe(|result: In<Result>| {
let _ = result.0.inspect_err(|err| info!("captured error: {err}"));
}),
);
app.run();
}
/// 这个系统总是失败验证,因为我们从未创建同时具有 `Player` 和 `Enemy` 组件的实体
fn failing_system(world: &mut World) -> Result {
world
// `get_resource` 返回 `Option<T>`,所以我们使用 `ok_or` 将其转换为 `Result`
// 然后我们可以调用 `?` 来传播错误
.get_resource::<UninitializedResource>()
// 我们可以在这里提供 `str`,因为 `BevyError` 实现了 `From<&str>`
.ok_or("Resource not initialized")?;
Ok(())
}关键要点:
- 系统可以返回
Result<(), BevyError>来处理错误 - 使用
set_error_handler()设置错误处理器 - 使用
.pipe()方法处理系统错误 - 可失败系统可以用于错误处理
注意事项:
- 默认情况下,返回错误的系统会 panic
- 可以设置自定义错误处理器
- 系统错误可以通过管道处理
最佳实践:
- 对于可能失败的操作,使用可失败系统
- 设置适当的错误处理器
- 使用系统管道处理错误
动态 ECS
动态 ECS 允许动态创建组件和实体。
源代码文件:bevy/examples/ecs/dynamic.rs
代码示例:
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, query_dynamic_components)
.run();
}
fn setup(mut commands: Commands, mut world: &mut World) {
// 动态创建组件
let component_id = world.register_component_with_descriptor(ComponentDescriptor::new(
"MyDynamicComponent",
StorageType::Table,
));
// 使用动态组件创建实体
let entity = commands.spawn_empty().id();
commands.entity(entity).insert_by_id(component_id, MyData(42));
}
fn query_dynamic_components(world: &mut World, component_id: ComponentId) {
// 查询动态组件
let query = Query::new((component_id,));
for (data,) in query.iter(world) {
// ...
}
}关键要点:
- 使用
register_component_with_descriptor()动态创建组件 - 使用
insert_by_id()插入动态组件 - 使用
Query查询动态组件 - 动态 ECS 可以用于运行时创建组件
注意事项:
- 动态 ECS 允许运行时创建组件
- 动态 ECS 可以用于插件系统
- 注意动态 ECS 的性能影响
最佳实践:
- 对于需要运行时创建组件的场景,使用动态 ECS
- 对于插件系统,使用动态 ECS
- 注意动态 ECS 的性能影响
实际应用
在游戏开发中的应用场景
ECS 进阶功能在游戏开发中有广泛的应用:
- 性能优化:使用并行查询提高系统执行效率
- 变更响应:使用变更检测和观察者响应组件变化
- 关系管理:使用关系系统建立实体间的复杂关系
- 生命周期管理:使用组件钩子管理组件的生命周期
- 事件驱动:使用消息系统和观察者实现事件驱动逻辑
常见问题
问题 1:何时使用变更检测,何时使用观察者?
解决方案:
- 变更检测适用于需要查询已变更组件的场景
- 观察者适用于需要响应组件生命周期事件的场景
- 对于简单场景,优先使用变更检测
问题 2:并行查询何时更快?
解决方案:
- 并行查询适用于计算密集型操作
- 对于简单操作,并行查询可能不会更快
- 需要根据实际情况测试性能
问题 3:如何优化查询组合的性能?
解决方案:
- 使用空间分区减少需要检查的实体对
- 使用查询过滤器减少查询的实体数量
- 考虑使用并行查询组合
性能考虑
- 并行查询:只在计算密集型操作时使用
- 查询组合:注意性能影响,考虑使用空间分区
- 观察者:考虑性能开销,优先使用变更检测
- 组件钩子:比观察者开销更小,但灵活性较低
相关资源
相关源代码文件:
bevy/examples/ecs/change_detection.rs- 变更检测示例bevy/examples/ecs/component_hooks.rs- 组件生命周期钩子示例bevy/examples/ecs/relationships.rs- 关系系统示例bevy/examples/ecs/parallel_query.rs- 并行查询示例bevy/examples/ecs/iter_combinations.rs- 查询组合示例bevy/examples/ecs/observers.rs- 观察者模式示例bevy/examples/ecs/removal_detection.rs- 移除检测示例bevy/examples/ecs/message.rs- 消息系统示例bevy/examples/ecs/error_handling.rs- 错误处理示例bevy/examples/ecs/dynamic.rs- 动态 ECS 示例
官方文档链接:
进一步学习建议:
- 学习 Bevy 的其他高级功能,如自定义系统参数、系统调度等
- 阅读 Bevy ECS 源码,深入理解实现原理
- 实践编写自己的高级 ECS 系统,加深理解
索引:返回上级目录