首页
/ three.js DragControls 拖拽交互控制详解:拖动、悬停与分组变换实战指南

three.js DragControls 拖拽交互控制详解:拖动、悬停与分组变换实战指南

2026-09-06 18:24:07作者:齐添朝

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,因此 addEventListenerremoveEventListenerdispatchEvent 均可用。

由于 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 模块本体,因为该控件内部依赖 ControlsMatrix4PlaneRaycasterVector2Vector3MOUSETOUCH 等 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 的 farInfinity,近裁面为 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 基类上公开的 mouseButtonstouches 两个配置对象:

this.mouseButtons = { LEFT: MOUSE.PAN, MIDDLE: MOUSE.PAN, RIGHT: MOUSE.ROTATE };
this.touches = { ONE: TOUCH.PAN };

也就是说:

  • 鼠标左键 / 中键:进入 PAN(平移)拖拽状态;
  • 鼠标右键:进入 ROTATE(旋转)状态;
  • 单指触摸:默认 PAN,且可通过改写 controls.touches.ONETOUCH.PANTOUCH.ROTATE 间切换。

内部状态常量定义于 L28-L32STATE = { 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、清空选中引用、恢复光标并重置状态。鼠标 pointeruppointerleave 都会触发该回调。

.hoveron

当指针移到一个 3D 对象上(或移动到其子对象之上且命中其祖先)时触发。触发后控件会把 domElement 光标设为 pointer。hover 逻辑仅在 pointerTypemousepen 时启用,触摸不会误触发 hover 事件(源码 L277-L321)。

.hoveroff

当指针从对象上移开、或从对象 A 直接移到对象 B 上时,对原对象触发一次(随后对 B 触发 hoveron)。触发后光标恢复为 auto

.dragstart(补充)

官方事件列表中未单列、但源码确实派发的事件:在 pointerdown 成功选中对象并完成平面交计算后触发(L368L376),官方 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 时(onPointerDownL329-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. 平移的平面投影。 指针移动时(onPointerMoveL239-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 事件(onContextMenupreventDefault()),避免拖拽右键旋转时弹出浏览器菜单。

完整实战:200 个物体的拖拽 + 分组选择

仓库示例 examples/misc_controls_drag.html 是一个完整可运行的拖拽场景:随机生成 200 个带阴影的彩色立方体,全部加入 objects 数组并交给 DragControls;通过 Shift+点击把命中的立方体挂到 groupgroup.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)会移除全部已注册的 pointermovepointerdownpointeruppointerleavecontextmenu 监听,并把 touchActioncursor 恢复原状——这与基类 Controlsconnect/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 已被拦截,若右键另有业务用途需自行权衡。

参考资料

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