指针系统
EnginePointer 把浏览器原生的鼠标/触控事件与 Three.js 的 raycaster 粘合起来:每次事件发生时,pointer 都会做一次命中测试,把结果派发给命中实体;点到空白处则触发全局的 miss 事件。
概述
- 事件聚合 — 原生事件被排入帧队列,下一帧开始时一次性 flush,相同类型的连续事件只保留最新值(例如 60fps 帧内可能来 100 次
pointermove,最终只分发 1 次); - raycaster 命中 — 用当前相机 + 归一化坐标生成射线,与场景里被 entity 持有的
Object3D求交; - 实体派发 — 按命中路径从最近到最远(或在冒泡事件里由叶到根)把事件投递给实体上绑定的 handler;
- miss 路径 — 点击/右键落空时触发
onMissed,拿到的是与命中事件对称的PointerMissedEvent。
事件类型
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
pointerdown | PointerEventType | — | 按下。属于 press 族,用于开始拖拽、按下高亮等 |
pointerup | PointerEventType | — | 抬起。属于 press 族 |
pointermove | PointerEventType | — | 移动。属于 hover 族,仅派发给直接命中的实体(不冒泡) |
pointerenter | PointerEventType | — | 光标进入实体。由 hover 状态机在 hit 切换时自动补发 |
pointerleave | PointerEventType | — | 光标离开实体。由 hover 状态机在 hit 切换或 canvas 失焦时自动补发 |
click | PointerEventType | — | 单击。属于 activate 族,支持冒泡 |
dblclick | PointerEventType | — | 双击。属于 activate 族,支持冒泡 |
contextmenu | PointerEventType | — | 右键菜单。属于 activate 族,支持冒泡 |
PointerEventFamily
命中测试按 族(family) 过滤候选对象——不同族的实体可以只响应自己关心的事件类型,避免「只想 hover 不想点击」的实体被误触。
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
'all' | PointerEventFamily | — | 不过滤,返回所有可命中对象。用于手动 hitTest |
'hover' | PointerEventFamily | — | 用于 pointermove / pointerenter / pointerleave |
'press' | PointerEventFamily | — | 用于 pointerdown / pointerup |
'activate' | PointerEventFamily | — | 用于 click / dblclick / contextmenu |
在实体上绑定
entity.onPointer(type, handler) 返回一个取消函数。通常在 Script 的 onInit 里注册,在 onDestroy 里调用取消——避免实体销毁后 handler 的闭包仍持有引用。
import { createScript } from '@tripo3d/engine';
const HighlightOnClick = createScript('HighlightOnClick', ({ entity }) => {
let unsubscribe: (() => void) | null = null;
return {
onInit() {
unsubscribe = entity.onPointer('click', (event) => {
// event.targetEntity === entity 时表示直接点中,否则是冒泡来的
console.log('clicked', event.point);
event.stopPropagation();
});
},
onDestroy() {
unsubscribe?.();
unsubscribe = null;
},
};
});handler 接受 EnginePointerEvent,关键字段如下:
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
type | PointerEventType | — | 事件类型,与绑定时传入一致 |
targetEntity | Entity | — | raycaster 最近命中的实体(冒泡链的叶子) |
currentEntity | Entity | — | 当前 handler 所在实体;冒泡过程中会变化 |
intersection | EngineIntersection | null | — | 最近一次相交的完整信息(distance / uv / face 等) |
intersections | EngineIntersection[] | — | 本次射线的全部相交结果,按距离从近到远 |
point | Vector3 | null | — | 世界空间命中点(来自 intersection.point) |
pointer | Vector2 | — | 归一化设备坐标(NDC) |
nativeEvent | MouseEvent | PointerEvent | — | 原生事件,用于读取按键修饰等 |
stopPropagation | () => void | — | 停止当前事件在实体树上继续冒泡 |
冒泡
click / dblclick / contextmenu / pointerdown / pointerup 会沿实体树从叶向根冒泡——命中叶子实体后,事件依次派发给它的每个祖先(若祖先也注册了同名 handler)。在 handler 里调用 event.stopPropagation() 即可截断。
// 父实体
groupEntity.onPointer('click', (event) => {
console.log('group click', event.currentEntity.name);
});
// 子实体:截断冒泡
childEntity.onPointer('click', (event) => {
event.stopPropagation();
console.log('only child handles this');
});pointermove / pointerenter / pointerleave 则只派发给直接命中的实体,不沿树冒泡——因为 hover 语义上属于「当前指向的那一个」。
Miss 事件
点到空白区域时,click / dblclick / contextmenu 会触发全局 miss 路径;pointerdown / pointerup / pointermove 不会。
const unsubscribe = engine.pointer.onMissed((event) => {
if (event.sourceType === 'click') {
selection.clear();
}
});
// 取消订阅
unsubscribe();PointerMissedEvent 的关键字段:
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
type | 'pointermissed' | — | 固定字面量 |
sourceType | 'click' | 'contextmenu' | 'dblclick' | — | 触发来源的原生事件类型 |
pointer | Vector2 | — | NDC 坐标 |
nativeEvent | MouseEvent | — | 原生事件 |
camera / raycaster | Camera / Raycaster | — | 构建射线时使用的相机与 raycaster,便于二次命中测试 |
手动命中测试
当你需要主动探测某个屏幕坐标下命中了什么(例如右键菜单定位、拖放预览、框选辅助),用 engine.pointer.hitTest:
const result = engine.pointer.hitTest(event.clientX, event.clientY, 'all');
if (result.targetEntity) {
showContextMenu(result.targetEntity, result.intersection?.point);
}返回的是 EnginePointerHitResult:
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
targetEntity | Entity | null | — | 最近命中的实体;空白区域为 null |
targetObject | Object3D | null | — | 命中实体中真正被射线命中的 Three.js 对象 |
intersection | EngineIntersection | null | — | 最近一次相交;可读 distance / point / uv / face |
intersections | EngineIntersection[] | — | 全部相交结果,按距离从近到远 |
family 省略时默认 'all',不做族过滤。
暂停分发
engine.pointer.suspend() 返回一个 release 函数——暂停期间所有事件都不会派发到实体,只有 pointerleave 会被转成 hover 释放以保持状态一致。典型用例是 transform gizmo 拖拽期间:避免鼠标穿过被拖动的物体误触其它实体。
// 开始拖拽
const release = engine.pointer.suspend();
// ...拖拽过程...
// 结束:释放后事件恢复
release();suspend 可以嵌套——内部维护计数器,只有全部 release 调用完才真正恢复。重复 release() 同一个函数是安全的(幂等)。
启用状态
engine.pointer.enabled 是一个 StateCell<boolean>,既能直接读写也能订阅。关闭后所有事件停止分发(效果与 suspend 类似,但更彻底——用于「临时完全关闭交互」的场景)。
// 读
console.log(engine.pointer.enabled.value);
// 写
engine.pointer.enabled.value = false;
// 订阅
const unsubscribe = engine.pointer.enabled.subscribe((next, prev) => {
console.log('enabled changed', prev, '->', next);
});EnginePointer
| 字段 / 方法 | 签名 | 说明 |
|---|---|---|
enabled | StateCell<boolean> | 启用状态;可读写 .value 或 subscribe |
hitTest | (clientX: number, clientY: number, family?: PointerEventFamily) => EnginePointerHitResult | 按客户端坐标做一次命中测试 |
onMissed | (handler: (event: PointerMissedEvent) => void) => () => void | 注册空点击回调,返回取消函数 |
suspend | () => () => void | 暂停事件分发,返回 release 函数(嵌套安全) |
完整示例
给实体绑点击回调高亮自身,点到空白处取消选中:
import { createScript } from '@tripo3d/engine';
const Selectable = createScript('Selectable', ({ entity, engine }) => {
const unsubscribers: Array<() => void> = [];
let selected = false;
function apply() {
// 你的高亮逻辑,例如切换材质 / 描边
entity.object.scale.setScalar(selected ? 1.1 : 1);
}
return {
onInit() {
unsubscribers.push(
entity.onPointer('click', (event) => {
event.stopPropagation();
selected = true;
apply();
}),
);
unsubscribers.push(
engine.pointer.onMissed((event) => {
if (event.sourceType !== 'click') return;
selected = false;
apply();
}),
);
},
onDestroy() {
unsubscribers.splice(0).forEach((off) => off());
},
};
});最佳实践
- hover 用 enter/leave,别用 move。pointermove 每帧都可能派发,里面做昂贵计算(改材质、发 raycast、算 BBox)会立刻拖垮帧率。只在进/出时切状态,move 内若无必要一律不注册。
- handler 必须可取消。总是把
entity.onPointer(...)/pointer.onMissed(...)的返回值收好,在 Script 的onDestroy里调用——否则实体销毁后 handler 里的闭包会把整个上下文留在内存里。 - 冒泡可截断。子实体处理完的 click 记得
event.stopPropagation(),避免父级重复响应(特别是选择框、拖拽手柄这类「点自己不是点场景」的场景)。 - gizmo / 拖拽期间 suspend。transform 控件、相机拖动过程里,用
pointer.suspend()避免穿透点击到场景实体。结束后一定要调用 release。 - 手动 hitTest 时选对 family。点选用
'activate'、悬浮辅助用'hover'、按压指示用'press';只有需要忽略族过滤时才用'all'。 - 与 UI 交互。DOM 层的交互元素(HUD、工具栏)本身会消费原生事件,不会走到 pointer 系统;但半透明 overlay 需要自己
pointer-events: none,否则 canvas 收不到原生事件。