首页
/ three.js InteractiveGroup 交互分组指南:统一鼠标、触控与 XR 控制器拾取事件

three.js InteractiveGroup 交互分组指南:统一鼠标、触控与 XR 控制器拾取事件

2026-09-07 19:38:45作者:曹令琨Iris

导读

在 three.js 项目中,为 3D 物体添加可交互能力通常需要自行处理指针坐标换算、Raycaster 射线投射与命中检测,同时还要考虑 WebXR 手柄射线与鼠标输入两套完全不同的输入模型。InteractiveGroup 正是为此设计的容器类:把多个需要交互的 3D 对象放进同一个分组,即可让该分组统一监听 Pointer / Mouse 事件或 XR 手柄事件,并在命中后自动把对应的交互事件分发给命中的子对象。读完本篇你将在当前 three.js 仓库源码与真实示例的佐证下,掌握 InteractiveGroup 的导入方式、构造函数、全部属性与方法、事件映射底层原理,以及结合 HTMLMesh 在 VR 场景中驱动真实 DOM 控件(GUI 面板、统计面板)的完整实战套路。

InteractiveGroup 属于 three.js 的 addon(附加组件),不打包进核心库,需要显式导入,其唯一实现文件为 InteractiveGroup.js

InteractiveGroup 是什么

InteractiveGroup 继承自 Group(类继承链为 EventDispatcher → Object3D → Group → InteractiveGroup),因此它可以像普通 Group 一样被加入 Scene,也可以把任意 Object3D(Mesh、Sprite、Lines 等)加入其中。它的特殊之处在于:分组自身监听输入事件,把事件换算为射线后对自身子对象做拾取测试,若命中就把事件原样转发给被命中的对象

官方语义说明见 InteractiveGroup.html.md:它可以用于将 3D 对象分组到一个交互组中,分组自身监听 Pointer、Mouse 或 XR 手柄事件来检测对后代 3D 对象的选中;当某个 3D 对象被选中时,相应事件会被派发(dispatch)给它。

这一设计的核心价值在于解耦:

  • 应用层只需把交互对象 group.add(mesh) 并预先在 mesh 上注册事件监听;
  • 事件源(桌面鼠标、触屏 Pointer、XR 手柄)由 InteractiveGroup 统一归一化,上层无需区分输入来自哪里。

导入 InteractiveGroup

由于该组件位于 addons 目录,需要按官方安装指南中 "Addons" 一节的方式显式导入。在当前仓库中,组件源文件与打包出口如下:

import { InteractiveGroup } from 'three/addons/interactive/InteractiveGroup.js';

配套的构建类型模块(examples/jsm/...)属于模块化源码,可直接被 ES Module 工程引用,或经打包器处理。

快速上手

官方文档给出了最精简的接入示例,这里给出可直接运行上下文更完整的版本:

import * as THREE from 'three';
import { InteractiveGroup } from 'three/addons/interactive/InteractiveGroup.js';

// 1. 创建交互组
const group = new InteractiveGroup();

// 2. 绑定渲染器画布与相机 —— 桌面指针/鼠标输入将作用于 renderer.domElement
group.listenToPointerEvents( renderer, camera );

// 3.(可选)绑定 XR 手柄 —— 每个手柄注册一次
group.listenToXRControllerEvents( controller1 );
group.listenToXRControllerEvents( controller2 );

// 4. 把交互组加入场景
scene.add( group );

// 5. 把要参与拾取的子对象加入组中
group.add( mesh1, mesh2, mesh3 );

之后,任意一个被命中的子对象会收到 pointerdown / pointerup / pointermove / mousedown / mouseup / mousemove / click 等事件(来自鼠标输入),或由 XR 事件映射出的同名鼠标风格事件。例如让 mesh1 响应点击:

mesh1.addEventListener( 'click', ( event ) => {
	console.log( 'mesh1 被选中,命中点 UV 坐标为:', event.data.x, event.data.y );
} );

事件对象复用 EventDispatcher 标准格式:event.type 为事件名,event.data 为命中点的 UV 坐标(THREE.Vector2)。

构造函数

new InteractiveGroup()

无参构造。构造时(见 InteractiveGroup.js)会初始化如下内部状态:

  • this.raycaster = new Raycaster():创建一个专用射线投射器,供指针事件拾取使用;
  • this.element = null:待绑定的 DOM 目标(renderer.domElement),默认 null
  • this.camera = null:射线投射所用相机,默认 null,须通过 listenToPointerEvents 注入;
  • this.controllers = []:已绑定的 XR 手柄数组,默认空;
  • 并把 onPointerEvent / onXRControllerEvent 两个内部处理器绑定到实例,供后续 add/removeEventListener 复用同一引用。

属性

.camera : Camera

用于射线投射的相机。默认 null。它由 listenToPointerEvents( renderer, camera ) 写入;Raycaster.setFromCamera() 依赖该相机把屏幕坐标(NDC)转换为世界空间射线。

.element : HTMLElement

交互组所挂载的 DOM 元素,实际指向渲染器的 renderer.domElement。默认 null。所有 Pointer 与 Mouse 监听器都注册在该元素上(实现见 InteractiveGroup.js)。事件处理时也通过 element.getBoundingClientRect() 把客户端坐标换算为 NDC,因此画布在页面中的位置偏移不会造成拾取偏差。

注意:three.js 官方 API 页面中对该属性的说明文案为 "The internal raycaster.",但从源码构造函数注释与赋值逻辑可明确其真实职责是保存渲染器 DOM 元素,供坐标换算与事件挂载使用。

.controllers : Array.

已通过 listenToXRControllerEvents 注册的 XR 手柄(Group)数组。可用于遍历或调试当前绑定了哪些手柄。

.raycaster : Raycaster

组内指针拾取所使用的内部射线投射器。射线的起点/方向来自 setFromCamera。默认阈值(Raycasterfar 等参数)可直接修改该属性以定制拾取范围。

方法

.listenToPointerEvents( renderer : WebGPURenderer | WebGLRenderer, camera : Camera )

让交互组监听 Pointer 与 Mouse 事件,事件目标为 renderer.domElementcamera 用于内部射线投射,以便基于事件检测 3D 对象。

  • renderer:WebGPU 或 WebGL 渲染器实例,方法内部取 renderer.domElement 作为监听目标并写入 this.element
  • camera:当前用于渲染的相机,写入 this.camera

从源码看,实际注册的事件有七个(InteractiveGroup.js):

事件 触发场景
pointerdown 指针按下(鼠标/触控/笔统一)
pointerup 指针抬起
pointermove 指针移动
mousedown 鼠标按下(兼容非 PointerEvent 环境)
mouseup 鼠标抬起
mousemove 鼠标移动
click 点击完成

监听以 addEventListener 挂接在 domElement 上,故只有发生在画布范围内的指针操作才会被处理。

.listenToXRControllerEvents( controller : Group )

让交互组监听指定 XR 手柄的事件。每把手柄需调用一次;重复调用会把该手柄再次 push 进 controllers 并重复注册,通常每个手柄只注册一次。

  • controller:由 WebXRController / XRControllerModelFactory 等生成的 XR 手柄对象(Group)。

从源码(InteractiveGroup.js)可见,它为手柄注册了 moveselectselectstartselectend 四个事件,对应 WebXR 输入源的选中交互序列。

.disconnectionPointerEvents()

断开交互组与全部 Pointer / Mouse 事件的连接。从源码(InteractiveGroup.js)看,它会把 listenToPointerEvents 中注册的七个事件逐一 removeEventListener(仅在 element !== null 时执行)。注意该方法是官方命名,拼写为 disconnection 而非 disconnect

.disconnectXrControllerEvents()

断开交互组与所有 XR 手柄的连接(InteractiveGroup.js):遍历 this.controllers,逐一移除 move/select/selectstart/selectend 监听。

.disconnect()

一次性断开全部连接并清理状态(InteractiveGroup.js):内部先调用 disconnectionPointerEvents()disconnectXrControllerEvents(),随后将 cameraelement 置为 nullcontrollers 清空。适合在页面卸载、场景销毁、组件隐藏时调用,避免 DOM 与 XR 会话句柄残留导致的内存泄漏或重复触发。

底层原理:从指针事件到命中分发

理解其行为需拆解指针分支的核心逻辑(对应源码 onPointerEvent,见 InteractiveGroup.js),流程分四步:

  1. 停止冒泡:先调用 event.stopPropagation(),防止原始 DOM 事件继续冒泡干扰页面其他逻辑;
  2. 坐标换算为 NDC:用 element.getBoundingClientRect()clientX/clientY 换算到 [-1, 1] 归一化设备坐标:
    _pointer.x = ( event.clientX - rect.left ) / rect.width * 2 - 1;
    _pointer.y = - ( event.clientY - rect.top ) / rect.height * 2 + 1;
    
  3. 射线投射raycaster.setFromCamera( _pointer, camera ) 生成从相机经该屏幕点发出的射线,再 intersectObjects( this.children, false )组直属子对象求交,false 表示不做递归(即默认不穿透到孙级对象);
  4. 命中分发:若存在交点,仅取最近的一个intersects[0]),构造事件 { type: event.type, data: new Vector2(uv.x, 1 - uv.y) },通过 object.dispatchEvent( event ) 派发给命中的子对象。这里把 UV 的 V 轴翻转,使纹理坐标与常见的自上而下坐标系对齐。

由此得到三个可推断的实现约束,使用时可据此规划对象层级:

  • 交互拾取针对的是 InteractiveGroup直接子对象intersectObjects(children, false)),若要拾取更深层级的对象,需将它们作为直接子对象加入组内;
  • 同一时刻仅分发最近命中的那一个对象,遮挡关系按射线距离自然处理;
  • 事件携带的是命中点 UV(event.dataVector2),适合与纹理/平面映射类交互(如 HTML 平面)配合。

底层原理:XR 事件向鼠标事件的一一映射

手柄分支(源码 onXRControllerEvent,见 InteractiveGroup.js)做了关键归一化:把 XR 手柄事件映射为"标准"鼠标风格事件。映射表定义在源码顶部(InteractiveGroup.js):

XR 手柄事件 派发给对象的映射事件
move mousemove
select click
selectstart mousedown
selectend mouseup

处理流程为:先从 event.target 取出发射事件的手柄,用 _raycaster.setFromXRController( controller ) 以手柄自身位姿与指向生成射线(three.js 内置方法会依据手柄的方向自动构建拾取射线),再对子对象求交并分发映射后的事件。

这意味着:只要对象上的交互逻辑监听的是鼠标风格事件(mousemove / mousedown / mouseup / click),它就能在桌面端由鼠标、在头显端由手柄射线无缝复用同一套代码——这正是该组件统一两套输入模型的核心价值。

实战案例:VR 场景中用 InteractiveGroup 驱动 HTML 控件

仓库内 webxr_vr_sandbox.html 是官方提供的最完整实战范例,它把 InteractiveGroupHTMLMesh 配合使用,实现在 VR 里操作真实 DOM 控件(lil-gui 参数面板与 stats.js 统计面板)。

核心代码摘录(对应 webxr_vr_sandbox.html):

const group = new InteractiveGroup();
group.listenToPointerEvents( renderer, camera );            // 桌面调试用
group.listenToXRControllerEvents( controller1 );            // 左手柄
group.listenToXRControllerEvents( controller2 );            // 右手柄
scene.add( group );

// GUI 面板以 HTMLMesh 形式挂在 3D 空间中
const mesh = new HTMLMesh( gui.domElement );
mesh.position.set( - 0.75, 1.5, - 0.5 );
mesh.rotation.y = Math.PI / 4;
mesh.scale.setScalar( 2 );
group.add( mesh );

// stats 统计面板同理
const statsMesh = new HTMLMesh( stats.dom );
group.add( statsMesh );

事件链路为:InteractiveGroup 拾取命中 HTMLMesh 所属 Mesh → 向该 Mesh 派发 click 等事件 → 该 Mesh 内部监听了 mousedown/mousemove/mouseup/click(见 HTMLMesh.js)→ 调用 material.map.dispatchDOMEvent( event )HTMLMesh.js)把事件连同 UV 坐标转发给底层真实 DOM → 触发真实 DOM 控件的交互(按钮按下、滑杆拖动等)。

同款用法还出现在 webxr_vr_layers.htmlwebgpu_xr_native_layers.html(WebGPU 渲染器 + XR 图层版),它们同样先创建 InteractiveGroup、绑定指针与手柄事件,再把 HTMLMesh 产物 group.add 进组。由此可见该组件在 WebGL 与 WebGPU 两条渲染路径、桌面与 WebXR 两种交互模式中均可通用。

使用注意事项小结

  1. 必须先绑定再使用cameraelement 默认均为 null,只有先调用 listenToPointerEvents( renderer, camera ) 后指针分支才可用;
  2. 桌面与 XR 可同时绑定:示例中 listenToPointerEventslistenToXRControllerEvents 并存,一套子对象同时接受鼠标与手柄输入,方便开发期桌面调试;
  3. 销毁时务必 disconnect:在页面卸载或组件销毁时调用 group.disconnect() 解除 DOM 与 XR 监听,避免事件泄漏;仅解绑手柄可用 disconnectXrControllerEvents()
  4. 拾取目标层级:射线只检测组的直接子对象,嵌套更深的交互对象建议直接作为组子对象加入;
  5. 命中取最近交点:同一射线命中多个对象时仅分发最近者,需自定义"点击穿透"逻辑的场景不适合直接用本组件;
  6. 方法名拼写:官方断开指针监听的 API 名为 disconnectionPointerEvents(拼写与 disconnect 不一致),调用时请以本文与源码为准。

相关资源

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