three.js DragControls 拖拽交互控制详解:拖动、悬停与分组变换实战指南
DragControls 是 three.js 中用于在 3D 场景里提供"拖拽与放下"(drag'n'drop)交互的控制类,可让用户用鼠标或触摸把场景中的 3D 物体沿视线平面自由移动,并支持对单物体旋转、对组进行整体变换以及悬停高亮反馈。读完本文,你将掌握 DragControls 的完整 API、事件体系,以及其基于 Raycaster 的拾取与平面拖拽的底层实现原理,并能参照仓库内示例写出可直接运行的拖拽交互代码。
类的定位与继承关系
DragControls 的完整继承链为 EventDispatcher → Controls → DragControls,其中抽象基类 Controls 定义在 src/extras/Controls.js 中,DragControls 实现在 examples/jsm/controls/DragControls.js。
从基类可以看出 DragControls 继承了一套统一的基础设施,这类基础设施对本控件同样有效:
object:被控件管理的对象(对 DragControls 而言即 Camera);domElement:挂载事件监听的 HTML 元素;enabled:是否响应用户输入,默认为true;state:控件内部状态,默认为-1(在 DragControls 内部被定义为STATE.NONE);connect()/disconnect()/dispose():连接/断开 DOM 事件与释放资源的标准生命周期方法;- 事件派发能力:继承自
EventDispatcher,因此addEventListener、removeEventListener、dispatchEvent均可用。
由于 DragControls 是 addon(附加组件),它不会被包含在核心构建中,必须显式导入后才可使用。
安装与导入
DragControls 以 addon 形式随 three.js 仓库发布,使用 ES Module 语法导入:
import { DragControls } from 'three/addons/controls/DragControls.js';
在仓库内的示例中,通常通过 importmap 将 three/addons/ 映射到 ./jsm/(参见 examples/misc_controls_drag.html 顶部的 importmap 配置),实际文件位于 examples/jsm/controls/DragControls.js。你需要自行准备 three 模块本体,因为该控件内部依赖 Controls、Matrix4、Plane、Raycaster、Vector2、Vector3、MOUSE、TOUCH 等 three.js 核心导出。
快速上手:最小可运行示例
构造函数签名如下:
new DragControls( objects : Array<Object3D>, camera : Camera, domElement : HTMLElement )
三个参数的含义:
objects:可拖拽 3D 对象的数组(数组元素须为Object3D或其子类,如 Mesh、Group);camera:渲染场景所用的相机,用于发射射线与构建拖拽平面;domElement:添加事件监听的 HTML DOM 元素,通常是renderer.domElement,默认值为null(为null时不自动连接,需手动调用connect())。
最小示例(源自官方文档代码示例):
import * as THREE from 'three';
import { DragControls } from 'three/addons/controls/DragControls.js';
const objects = []; // 先构造若干 Mesh 并 push 进该数组
const controls = new DragControls( objects, camera, renderer.domElement );
// 拖拽开始/结束时高亮被拖拽物体
controls.addEventListener( 'dragstart', function ( event ) {
event.object.material.emissive.set( 0xaaaaaa );
} );
controls.addEventListener( 'dragend', function ( event ) {
event.object.material.emissive.set( 0x000000 );
} );
注意:在文档对应示例中,只有真正进入渲染循环前把整个 objects 数组拷贝传入(new DragControls( [ ...objects ], camera, renderer.domElement )),这样才能利用下面的"运行期改 .objects"技巧;直接引用同一数组则 .objects 与外层数组是同一个引用。
属性详解
.objects : Array<Object3D>
可拖拽 3D 对象数组。该属性是公开且可写的:在运行期直接修改它即可动态增删可拖拽对象,例如在 examples/misc_controls_drag.html 的点击分组逻辑中,通过 const draggableObjects = controls.objects; 拿到数组后执行 draggableObjects.length = 0; 清空,再 push 新对象,从而在"全体散装物体"与"单个 Group"两种拖拽目标之间切换。
.raycaster : Raycaster
用于检测 3D 对象的射线投射器,在构造函数中自动创建(this.raycaster = new Raycaster())。默认情况下该 Raycaster 的 far 是 Infinity,近裁面为 0。如果你的物体与相机距离很远,或希望限制拾取范围,可以直接修改该属性上的参数;也可以替换整个实例。
.recursive : boolean
是否允许"可拖拽对象的子节点被独立于父节点拖拽"。默认值为 true。该值会被传入 raycaster.intersectObjects( this.objects, this.recursive, _intersections ):为 true 时,即使点击的是某可拖拽 Mesh 内部更深层的子物体(如嵌套进组的零件),也会命中祖先中的可拖拽对象并将其选中。
.rotateSpeed : number
物体处于 rotate 模式(旋转拖拽)时的旋转速度,数值越大旋转越快,默认值为 1。在 examples/misc_controls_drag.html 中被设为 2 以获得更灵敏的旋转;在 examples/webgpu_volume_fire.html 中则设置为 dragControls.rotateSpeed = 0,仅保留"移动"而禁用旋转。
.transformGroup : boolean
仅当 objects 数组中只含一个可拖拽 Group 对象时才有效。若为 true,控件不移动单个物体,而是对整个 Group 做变换;为 false 时命中哪个物体就拖哪个物体。默认值为 false。
从源码(examples/jsm/controls/DragControls.js)看,开启该开关后,指针按下命中物体时不会直接选中交点物体,而是调用内部工具函数 findGroup() 沿其上层父级链向上查找最外层的 Group 并整体选中:
if ( this.transformGroup === true ) {
// look for the outermost group in the object's upper hierarchy
_selected = findGroup( _intersections[ 0 ].object );
} else {
_selected = _intersections[ 0 ].object;
}
findGroup 的实现(同文件 L414-L421)是一个递归遍历:只要 obj.isGroup 成立就暂存该节点,然后继续向上直到 parent === null,从而返回层级最外层(即最高层)的 Group。
鼠标按键与触摸映射
DragControls 在构造函数中(L112-L113)配置了默认的输入映射,它继承了 Controls 基类上公开的 mouseButtons 与 touches 两个配置对象:
this.mouseButtons = { LEFT: MOUSE.PAN, MIDDLE: MOUSE.PAN, RIGHT: MOUSE.ROTATE };
this.touches = { ONE: TOUCH.PAN };
也就是说:
- 鼠标左键 / 中键:进入
PAN(平移)拖拽状态; - 鼠标右键:进入
ROTATE(旋转)状态; - 单指触摸:默认
PAN,且可通过改写controls.touches.ONE在TOUCH.PAN与TOUCH.ROTATE间切换。
内部状态常量定义于 L28-L32:STATE = { NONE: -1, PAN: 0, ROTATE: 1 }。每一次 pointerdown 都会根据 event.pointerType(触摸或鼠标)与 event.button(0/1/2 分别对应左/中/右键)经 _updateState() 映射成对应状态。misc_controls_drag 示例就利用这一点实现了按 M 键切换触摸模式:
controls.touches.ONE = ( controls.touches.ONE === THREE.TOUCH.PAN ) ? THREE.TOUCH.ROTATE : THREE.TOUCH.PAN;
事件体系
所有事件对象的负载均为 { type, object },其中 object 是被操作(或被悬停)的 3D 对象。四个事件与 dragstart 分别如下。
.drag
当用户拖动一个 3D 对象时持续触发(指针移动且处于选中状态时每次移动都会触发)。典型用途是在拖拽过程中实时刷新渲染或更新自定义 UI。
.dragend
当用户结束拖拽时触发一次。触发时机在源码的 onPointerCancel()(L388-L404)中:只要存在 _selected,就派发 dragend、清空选中引用、恢复光标并重置状态。鼠标 pointerup 与 pointerleave 都会触发该回调。
.hoveron
当指针移到一个 3D 对象上(或移动到其子对象之上且命中其祖先)时触发。触发后控件会把 domElement 光标设为 pointer。hover 逻辑仅在 pointerType 为 mouse 或 pen 时启用,触摸不会误触发 hover 事件(源码 L277-L321)。
.hoveroff
当指针从对象上移开、或从对象 A 直接移到对象 B 上时,对原对象触发一次(随后对 B 触发 hoveron)。触发后光标恢复为 auto。
.dragstart(补充)
官方事件列表中未单列、但源码确实派发的事件:在 pointerdown 成功选中对象并完成平面交计算后触发(L368、L376),官方 Code Example 正是用它与 dragend 搭配做高亮。在 webgpu_volume_fire 示例中,它还用于在拖拽开始时禁用轨道相机控件。
事件派发细节(源码级补充)
以下是文档事件列表(drag/dragend/hoveron/hoveroff)之外,从 examples/jsm/controls/DragControls.js 底部 JSDoc(L424-L450)可以确认的完整事件定义,供实现自定义回调时参考:
| 事件名 | 触发时机 | 负载字段 |
|---|---|---|
dragstart |
按下指针并命中可拖拽对象、完成平面计算后 | { type, object } |
drag |
拖拽过程中指针移动(平移或旋转均会触发) | { type, object } |
dragend |
指针抬起或离开元素、拖拽结束时 | { type, object } |
hoveron |
指针(鼠标/笔)移动到对象上 | { type, object } |
hoveroff |
指针(鼠标/笔)离开对象 | { type, object } |
工作原理:从 pointerdown 到位移的完整链路
DragControls 的核心机制是"射线拾取 + 相机视线垂直平面上的交点投影"。理解以下链路有助于排查自定义场景中的拖拽异常。
1. 指针归一化。 每次指针事件都会执行 _updatePointer()(L165-L172),用 getBoundingClientRect() 把浏览器像素坐标换算为标准 NDC 坐标:
_pointer.x = ( event.clientX - rect.left ) / rect.width * 2 - 1;
_pointer.y = - ( event.clientY - rect.top ) / rect.height * 2 + 1;
2. 选中与拖拽平面。 pointerdown 时(onPointerDown,L329-L386)用 raycaster.setFromCamera( _pointer, camera ) 发射射线并 intersectObjects( this.objects, this.recursive, _intersections ) 检测。命中后:
- 以相机视线方向为法线(
camera.getWorldDirection())、以命中物体世界坐标为平面上一点,构造拖拽平面_plane; - 若是
PAN状态:记录世界坐标偏移_offset与父对象世界矩阵的逆_inverseMatrix,派发dragstart; - 若是
ROTATE状态:用相机四元数把世界+Y与+X轴旋转为相机坐标系下的_up与_right,派发dragstart。
3. 平移的平面投影。 指针移动时(onPointerMove,L239-L327),对拖拽射线与 _plane 求交,得到三维交点后再减去记录好的 _offset,最后应用父对象世界矩阵的逆,从而正确支持父级存在缩放/旋转/位移的场景:
_selected.position.copy( _intersection.sub( _offset ).applyMatrix4( _inverseMatrix ) );
this.dispatchEvent( { type: 'drag', object: _selected } );
4. 旋转的增量差分。 ROTATE 状态则计算指针位移差分 _diff,乘以 rotateSpeed 后绕相机方向的 _up 轴水平旋转、绕 _right 轴垂直旋转(见 L262-L269):
_diff.subVectors( _pointer, _previousPointer ).multiplyScalar( this.rotateSpeed );
_selected.rotateOnWorldAxis( _up, _diff.x );
_selected.rotateOnWorldAxis( _right.normalize(), - _diff.y );
源码注释也明确指出:旋转模式下控件仅支持 Y+ 朝上的世界设定("the controls only support Y+ up"),因此若要兼容任意上方向需自行扩展。
5. 悬停检测。 未选中任何对象且指针类型为鼠标/笔时,每次移动同样发射射线做拾取,命中则派发 hoveron 并置 pointer 光标,离开则派发 hoveroff 并恢复 auto 光标(L277-L321)。
6. 右键菜单与触摸滚动手势。 connect() 会把 touchAction 设为 none 以禁止触摸滚动被浏览器抢占(disconnect 时恢复);同时拦截 contextmenu 事件(onContextMenu 中 preventDefault()),避免拖拽右键旋转时弹出浏览器菜单。
完整实战:200 个物体的拖拽 + 分组选择
仓库示例 examples/misc_controls_drag.html 是一个完整可运行的拖拽场景:随机生成 200 个带阴影的彩色立方体,全部加入 objects 数组并交给 DragControls;通过 Shift+点击把命中的立方体挂到 group(group.attach( object ))下,此时 controls.transformGroup = true 并清空/重填 controls.objects 为 [ group ],从而把分组当作一个整体拖拽;当组为空时再切回散装模式。其关键交互逻辑如下:
controls = new DragControls( [ ...objects ], camera, renderer.domElement );
controls.rotateSpeed = 2;
controls.addEventListener( 'drag', render ); // 拖拽过程中实时重绘
在点击处理里动态管理拖拽目标:
const draggableObjects = controls.objects;
draggableObjects.length = 0; // 清空当前可拖拽集合
if ( group.children.includes( object ) ) {
scene.attach( object ); // 从组中取出,恢复单独拖拽
} else {
group.attach( object ); // 加入组
controls.transformGroup = true;
draggableObjects.push( group ); // 只拖整个 group
}
if ( group.children.length === 0 ) {
controls.transformGroup = false;
draggableObjects.push( ...objects );
}
进阶实战:与 OrbitControls 协同 + 触摸旋转
examples/webgpu_volume_fire.html(webgpu_volume_fire)展示了 DragControls 与相机轨道控制(OrbitControls)共存的推荐做法:用同一个 renderer.domElement 分别创建 controls(轨道相机)与 dragControls(拖拽茶壶),在拖拽期间临时禁用轨道控制避免两者抢占输入:
const dragControls = new DragControls( [ teapot ], camera, renderer.domElement );
dragControls.rotateSpeed = 0; // 只平移不旋转
dragControls.addEventListener( 'dragstart', function () {
controls.enabled = false; // 拖拽时禁用 OrbitControls
} );
dragControls.addEventListener( 'drag', function () { /* ... */ } );
dragControls.addEventListener( 'dragend', function () {
controls.enabled = true; // 恢复轨道控制
} );
由于 DragControls 与 OrbitControls 都基于 Controls 基类,二者共用同一个 DOM 元素时,务必通过"拖拽开始时禁用另一方"的方式避免相机同时被拖动,这是将拖拽交互与场景漫游组合时的通用模式。
生命周期与清理
当不再需要 DragControls 时,可调用 dispose() 释放资源,其实现只是调用 disconnect()(L159-L163)。disconnect()(L146-L157)会移除全部已注册的 pointermove、pointerdown、pointerup、pointerleave、contextmenu 监听,并把 touchAction 与 cursor 恢复原状——这与基类 Controls 的 connect/disconnect/dispose 钩子设计(src/extras/Controls.js)保持一致,保证反复连接/断开的"副作用"可逆。在单页应用中卸载组件时记得调用它,避免内存泄漏。
常见注意事项小结
- 不要把
.raycaster之外的拾取细节写死:recursive会影响嵌套模型(如 GLTF 导入的层级结构)能否被命中,拖拽非叶子层级对象时按需设置; transformGroup只对"单 Group"生效,传入多个 Group 时行为可能不符合预期,需在业务层保证objects只含一个 Group;- 旋转模式依赖 Y+ 朝上世界,且旋转与平移两种状态通过左右键或触摸配置区分;若只想平移,参考 webgpu_volume_fire 将
rotateSpeed置 0; - DOM 元素必须具有正确的布局尺寸,因为指针归一化依赖
getBoundingClientRect(); - 使用 Pointer Events 意味着现代浏览器均可用,但
contextmenu已被拦截,若右键另有业务用途需自行权衡。
参考资料
- 核心实现:examples/jsm/controls/DragControls.js
- 抽象基类:src/extras/Controls.js
- 官方文档页:docs/pages/DragControls.html.md
- 完整示例(分组拖拽):examples/misc_controls_drag.html
- 组合示例(与 OrbitControls 协同):examples/webgpu_volume_fire.html
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 StartedRust0624
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