three.js InteractiveGroup 交互分组指南:统一鼠标、触控与 XR 控制器拾取事件
导读
在 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';
- 单个 addon 源文件:InteractiveGroup.js
- addons 汇总导出:Addons.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。默认阈值(Raycaster 的 far 等参数)可直接修改该属性以定制拾取范围。
方法
.listenToPointerEvents( renderer : WebGPURenderer | WebGLRenderer, camera : Camera )
让交互组监听 Pointer 与 Mouse 事件,事件目标为 renderer.domElement。camera 用于内部射线投射,以便基于事件检测 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)可见,它为手柄注册了 move、select、selectstart、selectend 四个事件,对应 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(),随后将 camera、element 置为 null、controllers 清空。适合在页面卸载、场景销毁、组件隐藏时调用,避免 DOM 与 XR 会话句柄残留导致的内存泄漏或重复触发。
底层原理:从指针事件到命中分发
理解其行为需拆解指针分支的核心逻辑(对应源码 onPointerEvent,见 InteractiveGroup.js),流程分四步:
- 停止冒泡:先调用
event.stopPropagation(),防止原始 DOM 事件继续冒泡干扰页面其他逻辑; - 坐标换算为 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; - 射线投射:
raycaster.setFromCamera( _pointer, camera )生成从相机经该屏幕点发出的射线,再intersectObjects( this.children, false )对组直属子对象求交,false表示不做递归(即默认不穿透到孙级对象); - 命中分发:若存在交点,仅取最近的一个(
intersects[0]),构造事件{ type: event.type, data: new Vector2(uv.x, 1 - uv.y) },通过object.dispatchEvent( event )派发给命中的子对象。这里把 UV 的 V 轴翻转,使纹理坐标与常见的自上而下坐标系对齐。
由此得到三个可推断的实现约束,使用时可据此规划对象层级:
- 交互拾取针对的是
InteractiveGroup的直接子对象(intersectObjects(children, false)),若要拾取更深层级的对象,需将它们作为直接子对象加入组内; - 同一时刻仅分发最近命中的那一个对象,遮挡关系按射线距离自然处理;
- 事件携带的是命中点 UV(
event.data为Vector2),适合与纹理/平面映射类交互(如 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 是官方提供的最完整实战范例,它把 InteractiveGroup 与 HTMLMesh 配合使用,实现在 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.html 与 webgpu_xr_native_layers.html(WebGPU 渲染器 + XR 图层版),它们同样先创建 InteractiveGroup、绑定指针与手柄事件,再把 HTMLMesh 产物 group.add 进组。由此可见该组件在 WebGL 与 WebGPU 两条渲染路径、桌面与 WebXR 两种交互模式中均可通用。
使用注意事项小结
- 必须先绑定再使用:
camera、element默认均为null,只有先调用listenToPointerEvents( renderer, camera )后指针分支才可用; - 桌面与 XR 可同时绑定:示例中
listenToPointerEvents与listenToXRControllerEvents并存,一套子对象同时接受鼠标与手柄输入,方便开发期桌面调试; - 销毁时务必 disconnect:在页面卸载或组件销毁时调用
group.disconnect()解除 DOM 与 XR 监听,避免事件泄漏;仅解绑手柄可用disconnectXrControllerEvents(); - 拾取目标层级:射线只检测组的直接子对象,嵌套更深的交互对象建议直接作为组子对象加入;
- 命中取最近交点:同一射线命中多个对象时仅分发最近者,需自定义"点击穿透"逻辑的场景不适合直接用本组件;
- 方法名拼写:官方断开指针监听的 API 名为
disconnectionPointerEvents(拼写与disconnect不一致),调用时请以本文与源码为准。
相关资源
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 StartedRust0627
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