首页
/ Bevy 指针事件扁平化迁移指南:从 Pointer<Press> 到 PointerPress 与 PointerEvent Trait

Bevy 指针事件扁平化迁移指南:从 Pointer<Press> 到 PointerPress 与 PointerEvent Trait

2026-09-05 17:59:46作者:柏廷章Berta

本文基于 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 把字段"透传"到内部的 PressClick 等事件上。这种设计存在几个使用层面的问题:

  • 每次访问内部字段(如指针 ID、位置)都要经过 Deref 隐式解引用,字段归属不直观;
  • 泛型约束繁琐,想要写一个"接收任意指针事件"的通用处理器时,需要为 Pointer<E> 声明 Debug + Clone + Reflect 等一长串约束;
  • Pointer 与具体事件耦合在类型参数里,而指针信息本身(ID、目标、位置)其实对所有事件都是同构的。

本次重构将事件直接"打平"(flatten):每种交互对应一个独立的非泛型事件结构体(如 PointerPress),指针信息作为一个 pub pointer: Pointer 字段直接存储在事件上,事件的其他字段(entitybuttonhit 等)也都直接平铺在结构体上。Pointer 不再是泛型,也不再实现 Deref

从当前仓库源码可以确认这一结构:Pointer 定义 是一个非泛型结构体,且全文件不存在对它的 Deref 实现;而 InteractionPlugin 在构建 App 时把 PointerCancelPointerClickPointerPressPointerDrag* 等 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,
}

也就是说,迁移时只需记住:entitypointer 是所有事件的公共字段,其余字段(buttoncountdurationdeltadragged/dropped 等)直接按字段名访问,不再需要解引用中间层。完整的事件清单(与 events 模块文档 的三大类划分一致):

  • 悬停与移动:PointerOverPointerEnterPointerMovePointerLeavePointerOut
  • 点击与按压:PointerPressPointerReleasePointerClick
  • 拖拽与放置:PointerDragStartPointerDragPointerDragEndPointerDragEnterPointerDragOverPointerDragDropPointerDragLeave
  • 其他:PointerScrollPointerCancel

其中几个事件的"额外"字段值得注意(均来自 events.rs 的源码定义):

  • PointerClick 额外带有 duration: Duration(按下到抬起的时长)与 count: u8(连续点击次数,从 1 开始);
  • PointerMove / PointerDrag 带有 delta: Vec2(本次位移)与 distance: Vec2(拖拽累计位移),单位是屏幕像素而非世界坐标,源码注释明确提示需要用 Camera 的相关方法做屏幕到世界的转换;
  • PointerEnteris_in_boundsPointerLeavewas_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,而不是 LocationLocation(包含 target: NormalizedRenderTargetposition: Vec2,定义见 Location 结构体)在大多数使用场景中并非必需,因此扁平化后的 Pointer 把它拆成了两个公开字段:targetposition(见 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 内部的流转,有助于判断迁移后行为是否有变化。

  1. 消息注册InteractionPluginPreUpdate 阶段初始化 HoverMapPreviousHoverMapPointerState 等资源,并注册全部 16 种扁平化事件消息。
  2. 分发系统:核心是 pointer_events 系统。它消费底层 PointerInput 消息流(由鼠标/触摸输入或自定义指针产生,见 PointerInput),结合 HoverMap(当前帧悬停结果)与 PreviousHoverMap(上一帧悬停结果),在 PickingSystems::Hover 集合内与 generate_hovermapupdate_interactions 链式执行。
  3. 双通道派发:每种事件既通过 commands.trigger(...) 触发实体观察器(驱动 On<...> 回调与层级冒泡),又通过 MessageWriter 写入消息流(供 MessageReader 使用)。因此同一个 PointerPress 既能被 .observe(on_press) 收到,也能被系统以 MessageReader<PointerPress> 读取。
  4. 状态缓存PointerState / PointerButtonState 按"指针 ID + 按钮"缓存按压位置、点击次数与拖拽轨迹,用来派生 PointerClick(含 countduration)和 PointerDrag 系列事件;连续点击的判定窗口由 PickingSettingsmulti_click_interval 控制(默认 500ms)。

事件派发顺序在 pointer_events 的文档注释中有严格约定(注释全文):单帧内先发出 PointerOut → PointerLeave → PointerDragLeave,再发 PointerDragEnter → PointerEnter → PointerOver,之后依次处理移动(PointerDragStart → PointerDrag → PointerDragOver → PointerMove)与按键(PointerPressPointerClick → PointerRelease → PointerDragDrop → PointerDragEnd → PointerDragLeave)。这些事件触发时序与扁平化无关——迁移只改变类型组织方式,不改变事件语义。仓库内的单元测试 enter_leave_events 用多帧场景精确验证了 PointerEnter/PointerLeave 在父级、子级间的冒泡去重规则,迁移后可以参照它来核对自己的事件计数是否符合预期。

迁移清单与检查要点

按迁移指南与当前源码,把旧版交互代码迁移到扁平化事件,可执行如下对照:

检查项 旧 API 新 API
事件类型 Pointer<Press>Pointer<Click> PointerPressPointerClick 等 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.positionVec2
需要 Location 时 直接使用内部 Location e.pointer.location()
内部字段访问 依赖 PointerDeref 解引用 全部字段直接平铺,无 Deref
通用处理器 fn f<E: Debug + Clone + Reflect>(e: On<Pointer<E>>) fn f<E: PointerEvent>(e: On<E>),用 e.pointer() 取指针
事件目标实体 Deref 访问 e.entity 直接访问

两个容易踩的坑:

  • 不要再去构造 Location 字段访问路径PointertargetNormalizedRenderTargetpositionVec2,二者可分别访问;location() 方法每次调用会 clone 一次 target,在热路径中高频调用时应留意。
  • 冒泡控制对象不变但字段更严格Pointer 上的 propagatepub(crate),旧版若有绕过 On::propagate 直接操纵内部传播状态的代码,迁移后必须改为 e.propagate(false)(见 crate 文档示例)。

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐