Bevy 指针事件扁平化迁移指南:从 Pointer<Press> 到 PointerPress 与 PointerEvent Trait
本文基于 Bevy 官方迁移指南(对应 PR #25337)讲解指针事件系统(Pointer Events)的"扁平化"重构:Pointer<Press> 等泛型事件被替换为 PointerPress 等独立结构体,Pointer 从泛型参数变为事件上的非泛型字段,position 直接暴露为 Vec2。读完本文,你将能够把旧版 Bevy 中基于 Pointer<T> 与 Deref 的交互代码平滑迁移到新 API,并用新的 PointerEvent trait 编写与具体事件类型无关的通用指针处理逻辑,同时理解这些扁平化事件在 bevy_picking 内部是如何生成与冒泡的。
变更背景:为什么要"扁平化"指针事件
旧版 Bevy 中,指针交互事件以泛型形式组织:Pointer<Press>、Pointer<Click>、Pointer<Drag>……其中 Pointer 是一个泛型包装结构,通过实现 Deref 把字段"透传"到内部的 Press、Click 等事件上。这种设计存在几个使用层面的问题:
- 每次访问内部字段(如指针 ID、位置)都要经过
Deref隐式解引用,字段归属不直观; - 泛型约束繁琐,想要写一个"接收任意指针事件"的通用处理器时,需要为
Pointer<E>声明Debug + Clone + Reflect等一长串约束; Pointer与具体事件耦合在类型参数里,而指针信息本身(ID、目标、位置)其实对所有事件都是同构的。
本次重构将事件直接"打平"(flatten):每种交互对应一个独立的非泛型事件结构体(如 PointerPress),指针信息作为一个 pub pointer: Pointer 字段直接存储在事件上,事件的其他字段(entity、button、hit 等)也都直接平铺在结构体上。Pointer 不再是泛型,也不再实现 Deref。
从当前仓库源码可以确认这一结构:Pointer 定义 是一个非泛型结构体,且全文件不存在对它的 Deref 实现;而 InteractionPlugin 在构建 App 时把 PointerCancel、PointerClick、PointerPress、PointerDrag* 等 16 个扁平化事件逐一注册为消息(add_message),说明每个事件类型现在都是独立的一等公民。
事件类型重命名与字段扁平化
迁移指南给出的最直接的前后对比如下:
// Before
fn on_press(press: On<Pointer<Press>>) {
info!("pressed {} {:?} {:?}", press.entity, press.pointer_id, press.pointer_location.position);
}
// After
fn on_press(press: On<PointerPress>) {
info!("pressed {} {:?} {:?}", press.entity, press.pointer.id, press.pointer.position);
}
可以看到三处对应的迁移点:
| 旧写法 | 新写法 | 说明 |
|---|---|---|
On<Pointer<Press>> |
On<PointerPress> |
事件类型去泛型化、扁平化 |
press.pointer_id |
press.pointer.id |
指针 ID 移入 Pointer 字段 |
press.pointer_location.position |
press.pointer.position |
位置同样移入 Pointer,且直接就是 Vec2 |
在 crates/bevy_picking/src/events.rs 中,每个扁平化事件的字段布局高度一致。以 PointerPress 为例:
pub struct PointerPress {
/// The entity this pointer event happened for.
pub entity: Entity,
/// The pointer that triggered this event
pub pointer: Pointer,
/// Pointer button pressed to trigger this event.
pub button: PointerButton,
/// Information about the picking intersection.
pub hit: HitData,
/// Number of consecutive presses, starting at `1`.
pub count: u8,
}
也就是说,迁移时只需记住:entity 与 pointer 是所有事件的公共字段,其余字段(button、count、duration、delta、dragged/dropped 等)直接按字段名访问,不再需要解引用中间层。完整的事件清单(与 events 模块文档 的三大类划分一致):
- 悬停与移动:
PointerOver、PointerEnter、PointerMove、PointerLeave、PointerOut - 点击与按压:
PointerPress、PointerRelease、PointerClick - 拖拽与放置:
PointerDragStart、PointerDrag、PointerDragEnd、PointerDragEnter、PointerDragOver、PointerDragDrop、PointerDragLeave - 其他:
PointerScroll、PointerCancel
其中几个事件的"额外"字段值得注意(均来自 events.rs 的源码定义):
PointerClick额外带有duration: Duration(按下到抬起的时长)与count: u8(连续点击次数,从 1 开始);PointerMove/PointerDrag带有delta: Vec2(本次位移)与distance: Vec2(拖拽累计位移),单位是屏幕像素而非世界坐标,源码注释明确提示需要用Camera的相关方法做屏幕到世界的转换;PointerEnter有is_in_bounds、PointerLeave有was_in_bounds,用于区分指针是直接进出实体边界、还是经由超出父级边界的子实体触发,行为与 Web 的mouseenter/mouseleave对齐。
事件如何被触发:Observer 与冒泡
由于事件本身实现了 EntityEvent,它们既可以绑定到实体上通过 observe 处理,也可以直接对事件消息做观察。bevy_picking 的 crate 文档 给出的官方示例展示了新 API 下最典型的用法:
world.spawn(MyComponent)
.observe(|mut click: On<PointerClick>| {
// Read the underlying pointer event data
println!("Pointer {:?} was just clicked!", click.pointer.id);
// Stop the event from bubbling up the entity hierarchy
click.propagate(false);
});
注意示例中读取指针信息用的是 click.pointer.id——Pointer 就是普通字段。事件还会沿实体层级向上冒泡,直到调用 propagate(false) 或到达根部;冒泡路径由 PointerTraversal 定义:优先沿 ChildOf 关系向上,若实体没有父级则传播到指针所在窗口实体后停止。这个实现细节解释了为什么 Pointer 结构体中有一个 pub(crate) 的 propagate 字段(见 Pointer 定义):它由事件系统内部管理,用户不可见。
一个真实的项目内用例可以参考 examples/picking/simple_picking.rs:用 On<PointerClick> 生成立方体、用 On<PointerDrag> 的 drag.delta 旋转模型,全程不需要任何 Deref。
Pointer 字段:position 直接是 Vec2,Location 按需重建
迁移指南明确指出:pointer.position 现在就是一个 Vec2,而不是 Location。Location(包含 target: NormalizedRenderTarget 与 position: Vec2,定义见 Location 结构体)在大多数使用场景中并非必需,因此扁平化后的 Pointer 把它拆成了两个公开字段:target 与 position(见 Pointer)。
对于确实需要完整 Location 的消费者(例如要把位置换算到世界坐标、或在自定义事件中携带位置信息),Pointer 提供了 location() 方法按需重建,实现见 Pointer::location:
/// Returns the [`Location`] of this pointer.
pub fn location(&self) -> Location {
Location {
position: self.position,
target: self.target.clone(),
}
}
仓库中 examples/asset/asset_saving.rs 就是这一用法的现成范例——它在处理拖拽事件时把 pointer.location() 作为自定义事件的字段传出:
fn on_drag_start(drag_start: On<PointerDragStart>, mut commands: Commands) {
commands.trigger(TryPlot {
entity: drag_start.entity,
location: drag_start.pointer.location(),
});
}
fn on_drag(drag: On<PointerDrag>, mut commands: Commands) {
commands.trigger(TryPlot {
entity: drag.entity,
location: drag.pointer.location(),
});
}
迁移建议:
- 只需要坐标的地方,直接用
event.pointer.position,避免不必要的clone(); - 需要"目标 + 坐标"整体(如喂给
Location::is_in_viewport或自定义EntityEvent)时,再调用event.pointer.location(); - 指针身份判别(鼠标/触摸/自定义指针)通过
event.pointer.id完成,它是 PointerId 枚举(Mouse/Touch(u64)/Custom(Uuid)),可直接配合is_mouse()、is_touch()等辅助方法。
通用指针事件代码:迁移到 PointerEvent Trait
迁移指南给出了第二个 before/after:想要写与具体指针事件类型解耦的通用处理器时,旧写法需要对 Pointer<E> 声明 Debug + Clone + Reflect 等约束,新写法改用 PointerEvent trait:
// Before
fn on_pointer_event<E: Debug + Clone + Reflect>(event: On<Pointer<E>>) {
}
// After
fn on_pointer_event<E: PointerEvent>(event: On<E>) {
}
该 trait 定义在 crates/bevy_picking/src/events.rs#L126-L130:
/// An [`EntityEvent`] that contains a [`Pointer`].
pub trait PointerEvent: EntityEvent {
/// Returns the [`Pointer`] stored on this [`EntityEvent`].
fn pointer(&self) -> &Pointer;
}
它要求实现者必须是 EntityEvent(即绑定实体的事件),并暴露统一的 pointer() 访问器。所有 16 个扁平化事件都通过内部宏统一实现该 trait(见 impl_pointer_event! 宏):
macro_rules! impl_pointer_event {
($event:ident) => {
impl PointerEvent for $event {
fn pointer(&self) -> &Pointer {
&self.pointer
}
}
};
}
这意味着你在通用处理器里可以这样取用指针信息,而不需要关心具体是 PointerOver 还是 PointerDrag:
fn log_any_pointer_event(event: On<impl PointerEvent>) {
let pointer = event.pointer();
info!(
"entity {:?} hit by pointer {:?} at {:?} (target {:?})",
event.entity, pointer.id, pointer.position, pointer.target
);
}
对比旧 API,约束从"手动罗列 Debug + Clone + Reflect"收敛为一个 trait 上界,语义也更准确:它表达的是"这个事件携带一个 Pointer",而不是"这个类型恰好能 Reflect"。
底层原理:扁平化事件的生成与分发链路
理解了事件结构,再看一下它们在 bevy_picking 内部的流转,有助于判断迁移后行为是否有变化。
- 消息注册:InteractionPlugin 在
PreUpdate阶段初始化HoverMap、PreviousHoverMap、PointerState等资源,并注册全部 16 种扁平化事件消息。 - 分发系统:核心是 pointer_events 系统。它消费底层
PointerInput消息流(由鼠标/触摸输入或自定义指针产生,见 PointerInput),结合HoverMap(当前帧悬停结果)与PreviousHoverMap(上一帧悬停结果),在PickingSystems::Hover集合内与generate_hovermap、update_interactions链式执行。 - 双通道派发:每种事件既通过
commands.trigger(...)触发实体观察器(驱动On<...>回调与层级冒泡),又通过MessageWriter写入消息流(供MessageReader使用)。因此同一个PointerPress既能被.observe(on_press)收到,也能被系统以MessageReader<PointerPress>读取。 - 状态缓存:
PointerState/ PointerButtonState 按"指针 ID + 按钮"缓存按压位置、点击次数与拖拽轨迹,用来派生PointerClick(含count、duration)和PointerDrag系列事件;连续点击的判定窗口由 PickingSettings 的multi_click_interval控制(默认 500ms)。
事件派发顺序在 pointer_events 的文档注释中有严格约定(注释全文):单帧内先发出 PointerOut → PointerLeave → PointerDragLeave,再发 PointerDragEnter → PointerEnter → PointerOver,之后依次处理移动(PointerDragStart → PointerDrag → PointerDragOver → PointerMove)与按键(PointerPress 或 PointerClick → PointerRelease → PointerDragDrop → PointerDragEnd → PointerDragLeave)。这些事件触发时序与扁平化无关——迁移只改变类型组织方式,不改变事件语义。仓库内的单元测试 enter_leave_events 用多帧场景精确验证了 PointerEnter/PointerLeave 在父级、子级间的冒泡去重规则,迁移后可以参照它来核对自己的事件计数是否符合预期。
迁移清单与检查要点
按迁移指南与当前源码,把旧版交互代码迁移到扁平化事件,可执行如下对照:
| 检查项 | 旧 API | 新 API |
|---|---|---|
| 事件类型 | Pointer<Press>、Pointer<Click> 等 |
PointerPress、PointerClick 等 16 个独立类型 |
| 观察器签名 | fn f(e: On<Pointer<Press>>) |
fn f(e: On<PointerPress>) |
| 指针 ID | e.pointer_id |
e.pointer.id |
| 指针位置 | e.pointer_location.position |
e.pointer.position(Vec2) |
| 需要 Location 时 | 直接使用内部 Location |
e.pointer.location() |
| 内部字段访问 | 依赖 Pointer 的 Deref 解引用 |
全部字段直接平铺,无 Deref |
| 通用处理器 | fn f<E: Debug + Clone + Reflect>(e: On<Pointer<E>>) |
fn f<E: PointerEvent>(e: On<E>),用 e.pointer() 取指针 |
| 事件目标实体 | 经 Deref 访问 |
e.entity 直接访问 |
两个容易踩的坑:
- 不要再去构造
Location字段访问路径:Pointer的target是NormalizedRenderTarget,position是Vec2,二者可分别访问;location()方法每次调用会clone一次target,在热路径中高频调用时应留意。 - 冒泡控制对象不变但字段更严格:
Pointer上的propagate是pub(crate),旧版若有绕过On::propagate直接操纵内部传播状态的代码,迁移后必须改为e.propagate(false)(见 crate 文档示例)。
延伸阅读
- 事件类型定义与分发系统:crates/bevy_picking/src/events.rs
- 指针、位置与输入事件:crates/bevy_picking/src/pointer.rs
- 插件装配与系统排序:crates/bevy_picking/src/lib.rs
- 可运行的最小示例:examples/picking/simple_picking.rs、examples/picking/dragdrop_picking.rs
location()的真实用法:examples/asset/asset_saving.rs
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00