Three.js 如何用鼠标拖动关节
上一篇讲了怎么给关节一个角度。但那套方法是给程序用的 —— 滑条、动画数据、算法,都能算出一个确定的数。这篇文章讲的是把这个数交给鼠标:按住机器人的某一截拖一下,关节跟着转。
难点在坐标系。鼠标是二维的,关节是三维的,中间要换三次。

1. 拖动一次关节,经过这几步
用鼠标拖着关节转,听起来像”跟着鼠标走”,拆开其实是一条单向的流水线:
1 | 鼠标位置 |
每一步都不复杂,麻烦的是每一步都在换坐标系。
整条流程由三个鼠标事件驱动:
| 事件 | 干什么 |
|---|---|
pointerdown |
按下:定下旋转轴、旋转中心、拖拽平面,记录起点 |
pointermove |
移动:没按住时选中模型并高亮;按住时算这一帧的关节增量 |
pointerup |
抬起:结束拖动,收尾 |
下面按鼠标实际的操作顺序拆开。
2. 选中要拖的关节
鼠标还没按下的时候,pointermove 负责回答一个问题:现在指的是哪个零件。
射线选中模型
1 | const raycaster = new Raycaster() |
射线怎么来的放在第 4 节,这里先看选中的结果:遍历所有机器人的所有 link,用射线和里面的每个 Mesh 求交,留下距离相机最近的那个。
往里找 Mesh 的过程是递归的,但遇到 joint 或 helper 就停止下钻:
1 | traverseMeshInLink(obj, callback) { |
因为关节节点的子树里挂着下面那些 link。从当前 link 继续往下走,会选到别的零件上去。helper 也同理 —— 那些是辅助线和辅助坐标系,不该被点中。
从 Mesh 找到关节
命中一个 Mesh 之后,顺着父节点往上找两层:
1 | const link = findNearestLink(hoverData.object) // 往上找到最近的 URDFLink |
findNearestJoint 会跳过 fixed 类型的关节,一直往上找到第一个能动的为止。所以点在大腿上,拖动的可能是髋关节。
高亮
1 | const material = mesh.material as Material |
悬停时把这个 link 下所有 Mesh 的材质换成一个加了自发光的克隆。
用克隆而不是直接改原材质,是因为原材质可能被多个 link 共用 —— 直接改,别的零件也会一起亮起来。鼠标移出时再把暂存的原材质换回去,并把克隆出来的那份 dispose() 掉,不然每悬停一次就漏一份材质。
3. 按下时:定下轴、旋转中心和拖拽平面
pointerdown 里做的事情,本质上是把 URDF 里的静态数据换算成世界坐标系里能用来计算的量。
关节轴 → 世界方向
1 | const pivot = axis.clone().transformDirection(matrixWorld) |
axis 是 <axis xyz="..."> 写的那个方向,表达在关节自己的局部坐标系里。而后面所有的计算 —— 射线求交、点积、叉乘、夹角 —— 都在世界坐标系里做,所以要先把它转过去。
transformDirection(m) 和 applyMatrix4(m) 的区别是:它只用矩阵的旋转部分,不理会平移。 轴是一个方向,不该被平移影响。
举个例子:关节的局部轴是 (0, 1, 0),但这个关节已经绕世界 Z 轴转了 90°,那转出来的 pivot 是 (1, 0, 0) —— 世界坐标系的 X 方向。直接用局部的 axis 去算,方向就全错了,而且只在关节已经转过时才错。
点击点 → 轴上的旋转中心
1 | const jointWorldPos = new Vector3(elements[12], elements[13], elements[14]) |
elements[12]、elements[13]、elements[14] 是 matrixWorld 里第 4 列的前三个数 —— 一个节点在世界坐标系里的位置。
关节的轴是一条直线:过 jointWorldPos,方向 pivot。而鼠标点中的 point 落在模型表面上,一般不在这条直线上。
旋转必须绕着轴转,所以圆心得落在轴上。把 point 垂直投影到轴上,就得到了这个圆心:
1 | point.clone().sub(jointWorldPos) // 从轴上一点指向 point |
projectOnVector(v) 是 Vector3 上的方法,把向量投影到 v 方向上并原地修改。
后面算旋转角时,起止点都要减去这个 origin —— 它是旋转的中心,不是关节的位置。
拖拽平面
鼠标只有两个自由度,要换成一个三维的点,得先约定一个平面让射线去撞。两种关节的平面不一样。
旋转关节:法线就是 pivot,且过 point。
1 | tfPlane.setFromNormalAndCoplanarPoint(pivot, point) |
平面垂直于旋转轴,拖动点就落在这个平面里绕轴划弧。
推拉关节:平面必须包含轴,这样投影到轴上才有意义。
1 | const v1 = new Vector3() |
两行叉乘看着绕,其实是一句话:找一个包含轴、又尽量正对相机的平面。
v1 = 视线方向 × pivot:同时垂直于视线和轴v2 = pivot × v1:正好是视线方向里垂直于轴的那部分- 法线取
v2,而v2 ⊥ pivot,所以这个平面一定含着轴的方向
至于为什么要”正对相机”:平面越接近侧对着相机,射线和它的交点位置就越不稳定,拖起来会抖。
记录起点
1 | this.curJointValue = joint.userData.value // 拖拽开始时关节的值 |
curJointValue 是这次拖拽的起点值,往后每一帧都在它上面累加。
4. 鼠标位置 → 射线 → 平面上的一个点
屏幕像素 → NDC
1 | const { left, top } = canvas.getBoundingClientRect() |
pageX / pageY 是相对整个页面的坐标,先减去 canvas 的位置,换算成 canvas 内部的像素坐标。
然后转成 NDC(标准化设备坐标):
1 | function screenToNDC({ x, y }, { clientWidth, clientHeight }) { |
两件事:
- 把
0 ~ 宽映射到-1 ~ 1:x / clientWidth * 2 - 1 - y 轴翻转,所以外面套了个负号
y 要翻转是因为两个坐标系方向相反:屏幕坐标原点在左上角、y 向下;NDC 原点在正中心、y 向上。
NDC → 射线
1 | raycaster.setFromCamera(NDCPos, camera) |
setFromCamera 做的事情是:以相机的世界位置为起点,用 NDC 反推出方向。内部是把 NDC 乘上视图投影矩阵的逆矩阵,转回世界坐标。
射线建好之后,raycaster.ray 就是一条 Ray:origin 是相机位置,direction 是单位方向向量。
射线 → 平面上的交点
1 | raycaster.ray.intersectPlane(tfPlane, dragEnd) |
Ray.intersectPlane(plane, target) 求射线和平面的交点,结果写进 target 并返回;如果射线和平面平行、或者交点在射线反方向,返回 null。
到这里,鼠标的二维位置变成了三维空间里的一个具体点。拖动过程中每刷新一次,dragEnd 就更新一次 —— 它和上一次的 dragStart 一比,就是鼠标这一帧在世界坐标里移动了多少。
5. 这一步移动,关节变了多少
旋转和推拉算的完全是两回事。
推拉关节:把位移投影到轴上
1 | const startToEnd = dragEnd.clone().sub(dragStart) |
鼠标可能斜着拖,但推拉关节只能沿轴走。dot(pivot) 把这段位移投影到轴方向上 —— 拿到的就是沿轴移动了多少米。
pivot 是单位向量,所以点积的结果直接就是长度,不用再除模长。
旋转关节:转了多少,往哪个方向
1 | const originToStart = new Vector3().subVectors(dragStart, origin) |
先把两个点都减去旋转中心,变成”相对圆心”的向量。问题就变成:originToStart 转到 originToEnd,转了多少弧度、朝哪边。
一、夹角有多大:angleTo
1 | originToEnd.angleTo(originToStart) // 0 ~ π |
它算的是两个向量的夹角:
1 | θ = arccos( (A · B) / (|A| |B|) ) |
但它给不出正负。 夹角永远落在 0 ~ π,正向转 10° 和反向转 10°,结果都是 10°。所以方向要单独算。
二、往哪边转:叉乘再点乘
1 | originToStart.clone().cross(originToEnd).dot(pivot) |
这是一个标量三重积 (A × B) · Axis,分两步看:
A.cross(B)得到一个垂直于 A、B 所在平面的向量,方向由右手定则决定:四指从 A 弯向 B,拇指指向就是结果方向。- 再和
pivot点积,问的是:这个垂直方向和关节轴同向还是反向?
拿最干净的例子:A 取 X 轴 (1,0,0),B 取 Y 轴 (0,1,0),那么
1 | A × B = Z = (0, 0, 1) |
再看关节轴:
A × B |
pivot |
(A×B) · pivot |
结论 |
|---|---|---|---|
(0,0,1) |
(0,0,1) |
> 0 |
同向,正转 |
(0,0,1) |
(0,0,-1) |
< 0 |
反向,反转 |
与 pivot 垂直 |
任意 | 0 |
没转 |
Math.sign() 把这个正负压成三个值:
1 | Math.sign(5.3) // 1 |
三、合起来
1 | delta = direction × angle |
夹角 0.05 弧度、方向为正,delta = +0.05;方向为负就是 -0.05。
angleTo是对称的(A.angleTo(B)和B.angleTo(A)相等),所以角度那一项按哪个顺序写都一样。真正决定正负的只有cross里的先后 —— 写成cross(originToEnd, originToStart),整个符号就反了,拖起来会朝相反方向转。
6. 累积,然后写回关节
1 | this.curJointValue += delta |
三行,三件事。
一、把增量累加到 curJointValue。
pointermove 是高频触发的,每一帧只算”相对上一次”的一小段。curJointValue 在按下时初始化为关节当时的值,所以它始终是这次拖拽的绝对值 —— 传给 setValue 的仍然是绝对值,不是增量。
二、写回关节。
setValue 内部会按关节类型走旋转或推拉分支,顺带把值夹到 <limit> 范围内。所以拖到边界就会自动停住,这里不用自己判断。
三、把起点移到当前位置。
1 | dragStart.copy(dragEnd) |
下一帧的”上一帧位置”,就是这一帧的”当前位置”。少了这一行,每一帧都会拿最开始那个起点去算增量,转动会越拖越快 —— 每次的位移都被重复累加了一遍。
如果这一帧算出来 delta 正好是 0(鼠标没动),提前返回,这三行都跳过。
参考
- Three.js
Raycaster与Ray.intersectPlane:https://threejs.org/docs/#api/en/core/Raycaster - Three.js
Plane.setFromNormalAndCoplanarPoint:https://threejs.org/docs/#api/en/math/Plane - Three.js
Vector3(transformDirection/projectOnVector/angleTo/cross/dot):https://threejs.org/docs/#api/en/math/Vector3 - Three.js
Matrix4的elements布局(elements[12..14]是平移分量):https://threejs.org/docs/#api/en/math/Matrix4 - 上一篇:Three.js 如何旋转和推拉关节 —— https://wywzixin.github.io/threejs/urdf-joint-motion/
- 下一篇:Three.js 如何生成关节的运动轨迹 —— https://wywzixin.github.io/threejs/urdf-joint-drag-path/
Three.js 如何用鼠标拖动关节