Skip to content

指针系统

EnginePointer 把浏览器原生的鼠标/触控事件与 Three.js 的 raycaster 粘合起来:每次事件发生时,pointer 都会做一次命中测试,把结果派发给命中实体;点到空白处则触发全局的 miss 事件。

概述

  • 事件聚合 — 原生事件被排入帧队列,下一帧开始时一次性 flush,相同类型的连续事件只保留最新值(例如 60fps 帧内可能来 100 次 pointermove,最终只分发 1 次);
  • raycaster 命中 — 用当前相机 + 归一化坐标生成射线,与场景里被 entity 持有的 Object3D 求交;
  • 实体派发 — 按命中路径从最近到最远(或在冒泡事件里由叶到根)把事件投递给实体上绑定的 handler;
  • miss 路径 — 点击/右键落空时触发 onMissed,拿到的是与命中事件对称的 PointerMissedEvent

事件类型

名称 类型 默认值 说明
pointerdownPointerEventType按下。属于 press 族,用于开始拖拽、按下高亮等
pointerupPointerEventType抬起。属于 press 族
pointermovePointerEventType移动。属于 hover 族,仅派发给直接命中的实体(不冒泡)
pointerenterPointerEventType光标进入实体。由 hover 状态机在 hit 切换时自动补发
pointerleavePointerEventType光标离开实体。由 hover 状态机在 hit 切换或 canvas 失焦时自动补发
clickPointerEventType单击。属于 activate 族,支持冒泡
dblclickPointerEventType双击。属于 activate 族,支持冒泡
contextmenuPointerEventType右键菜单。属于 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 的闭包仍持有引用。

ts
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,关键字段如下:

名称 类型 默认值 说明
typePointerEventType事件类型,与绑定时传入一致
targetEntityEntityraycaster 最近命中的实体(冒泡链的叶子)
currentEntityEntity当前 handler 所在实体;冒泡过程中会变化
intersectionEngineIntersection | null最近一次相交的完整信息(distance / uv / face 等)
intersectionsEngineIntersection[]本次射线的全部相交结果,按距离从近到远
pointVector3 | null世界空间命中点(来自 intersection.point)
pointerVector2归一化设备坐标(NDC)
nativeEventMouseEvent | PointerEvent原生事件,用于读取按键修饰等
stopPropagation() => void停止当前事件在实体树上继续冒泡

冒泡

click / dblclick / contextmenu / pointerdown / pointerup 会沿实体树从叶向根冒泡——命中叶子实体后,事件依次派发给它的每个祖先(若祖先也注册了同名 handler)。在 handler 里调用 event.stopPropagation() 即可截断。

ts
// 父实体
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 不会。

ts
const unsubscribe = engine.pointer.onMissed((event) => {
  if (event.sourceType === 'click') {
    selection.clear();
  }
});

// 取消订阅
unsubscribe();

PointerMissedEvent 的关键字段:

名称 类型 默认值 说明
type'pointermissed'固定字面量
sourceType'click' | 'contextmenu' | 'dblclick'触发来源的原生事件类型
pointerVector2NDC 坐标
nativeEventMouseEvent原生事件
camera / raycasterCamera / Raycaster构建射线时使用的相机与 raycaster,便于二次命中测试

手动命中测试

当你需要主动探测某个屏幕坐标下命中了什么(例如右键菜单定位、拖放预览、框选辅助),用 engine.pointer.hitTest:

ts
const result = engine.pointer.hitTest(event.clientX, event.clientY, 'all');

if (result.targetEntity) {
  showContextMenu(result.targetEntity, result.intersection?.point);
}

返回的是 EnginePointerHitResult:

名称 类型 默认值 说明
targetEntityEntity | null最近命中的实体;空白区域为 null
targetObjectObject3D | null命中实体中真正被射线命中的 Three.js 对象
intersectionEngineIntersection | null最近一次相交;可读 distance / point / uv / face
intersectionsEngineIntersection[]全部相交结果,按距离从近到远

family 省略时默认 'all',不做族过滤。

暂停分发

engine.pointer.suspend() 返回一个 release 函数——暂停期间所有事件都不会派发到实体,只有 pointerleave 会被转成 hover 释放以保持状态一致。典型用例是 transform gizmo 拖拽期间:避免鼠标穿过被拖动的物体误触其它实体。

ts
// 开始拖拽
const release = engine.pointer.suspend();

// ...拖拽过程...

// 结束:释放后事件恢复
release();

suspend 可以嵌套——内部维护计数器,只有全部 release 调用完才真正恢复。重复 release() 同一个函数是安全的(幂等)。

启用状态

engine.pointer.enabled 是一个 StateCell<boolean>,既能直接读写也能订阅。关闭后所有事件停止分发(效果与 suspend 类似,但更彻底——用于「临时完全关闭交互」的场景)。

ts
// 读
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

字段 / 方法签名说明
enabledStateCell<boolean>启用状态;可读写 .value 或 subscribe
hitTest(clientX: number, clientY: number, family?: PointerEventFamily) => EnginePointerHitResult按客户端坐标做一次命中测试
onMissed(handler: (event: PointerMissedEvent) => void) => () => void注册空点击回调,返回取消函数
suspend() => () => void暂停事件分发,返回 release 函数(嵌套安全)

完整示例

给实体绑点击回调高亮自身,点到空白处取消选中:

ts
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 收不到原生事件。

基于 MIT 协议发布(内部使用)