Three.js 如何用鼠标拖动关节

上一篇讲了怎么给关节一个角度。但那套方法是给程序用的 —— 滑条、动画数据、算法,都能算出一个确定的数。这篇文章讲的是把这个数交给鼠标:按住机器人的某一截拖一下,关节跟着转。

难点在坐标系。鼠标是二维的,关节是三维的,中间要换三次。

鼠标拖动关节时的样子:被拖动的连杆高亮成蓝色,左侧滑条同步走到 -0.9406

1. 拖动一次关节,经过这几步

用鼠标拖着关节转,听起来像”跟着鼠标走”,拆开其实是一条单向的流水线:

1
2
3
4
5
6
7
8
9
10
11
12
13
鼠标位置
↓ 屏幕坐标 → NDC
Raycaster 发射射线
↓
射线与「拖拽平面」求交
↓
得到世界坐标 dragStart / dragEnd
↓
计算 delta
↓
curJointValue += delta
↓
joint.setValue(curJointValue)

每一步都不复杂,麻烦的是每一步都在换坐标系。

整条流程由三个鼠标事件驱动:

事件 干什么
pointerdown 按下:定下旋转轴、旋转中心、拖拽平面,记录起点
pointermove 移动:没按住时选中模型并高亮;按住时算这一帧的关节增量
pointerup 抬起:结束拖动,收尾

下面按鼠标实际的操作顺序拆开。

2. 选中要拖的关节

鼠标还没按下的时候,pointermove 负责回答一个问题:现在指的是哪个零件。

射线选中模型

1
2
3
const raycaster = new Raycaster()
raycaster.setFromCamera(NDCPos, camera)
let intersections = this.intersectRobots(raycaster)

射线怎么来的放在第 4 节,这里先看选中的结果:遍历所有机器人的所有 link,用射线和里面的每个 Mesh 求交,留下距离相机最近的那个。

往里找 Mesh 的过程是递归的,但遇到 joint 或 helper 就停止下钻:

1
2
3
4
5
6
7
8
9
10
traverseMeshInLink(obj, callback) {
const { userData: { isURDFJoint, isURDFHelper } } = obj
if (isURDFJoint || isURDFHelper) {
return
}
if (obj instanceof Mesh) {
callback(obj)
}
obj.children.forEach((child) => this.traverseMeshInLink(child, callback))
}

因为关节节点的子树里挂着下面那些 link。从当前 link 继续往下走,会选到别的零件上去。helper 也同理 —— 那些是辅助线和辅助坐标系,不该被点中。

从 Mesh 找到关节

命中一个 Mesh 之后,顺着父节点往上找两层:

1
2
const link = findNearestLink(hoverData.object)   // 往上找到最近的 URDFLink
const joint = findNearestJoint(link) // 从 link 再往上,找到第一个可动的 joint

findNearestJoint 会跳过 fixed 类型的关节,一直往上找到第一个能动的为止。所以点在大腿上,拖动的可能是髋关节。

高亮

1
2
3
4
5
6
const material = mesh.material as Material
mesh.userData.material = material // 先把原材质存一份
const matClone = material.clone() as MeshStandardMaterial
matClone.emissive = new Color(0x00acec)
matClone.emissiveIntensity = 0.3
mesh.material = matClone

悬停时把这个 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
2
3
4
5
6
const jointWorldPos = new Vector3(elements[12], elements[13], elements[14])
const origin = point
.clone()
.sub(jointWorldPos)
.projectOnVector(pivot)
.add(jointWorldPos)

elements[12]、elements[13]、elements[14] 是 matrixWorld 里第 4 列的前三个数 —— 一个节点在世界坐标系里的位置。

关节的轴是一条直线:过 jointWorldPos,方向 pivot。而鼠标点中的 point 落在模型表面上,一般不在这条直线上。

旋转必须绕着轴转,所以圆心得落在轴上。把 point 垂直投影到轴上,就得到了这个圆心:

1
2
3
point.clone().sub(jointWorldPos)   // 从轴上一点指向 point
.projectOnVector(pivot) // 只保留沿轴方向的分量
.add(jointWorldPos) // 加回去,得到垂足

projectOnVector(v) 是 Vector3 上的方法,把向量投影到 v 方向上并原地修改。

后面算旋转角时,起止点都要减去这个 origin —— 它是旋转的中心,不是关节的位置。

拖拽平面

鼠标只有两个自由度,要换成一个三维的点,得先约定一个平面让射线去撞。两种关节的平面不一样。

旋转关节:法线就是 pivot,且过 point。

1
tfPlane.setFromNormalAndCoplanarPoint(pivot, point)

平面垂直于旋转轴,拖动点就落在这个平面里绕轴划弧。

推拉关节:平面必须包含轴,这样投影到轴上才有意义。

1
2
3
4
5
6
const v1 = new Vector3()
.subVectors(camera.position, point)
.normalize()
.cross(pivot)
const v2 = pivot.clone().cross(v1)
tfPlane.setFromNormalAndCoplanarPoint(v2, point)

两行叉乘看着绕,其实是一句话:找一个包含轴、又尽量正对相机的平面。

  • v1 = 视线方向 × pivot:同时垂直于视线和轴
  • v2 = pivot × v1:正好是视线方向里垂直于轴的那部分
  • 法线取 v2,而 v2 ⊥ pivot,所以这个平面一定含着轴的方向

至于为什么要”正对相机”:平面越接近侧对着相机,射线和它的交点位置就越不稳定,拖起来会抖。

记录起点

1
2
3
this.curJointValue = joint.userData.value   // 拖拽开始时关节的值
this.dragStart.copy(point) // 起点 = 按下时射线与平面的交点
this.dragging = true

curJointValue 是这次拖拽的起点值,往后每一帧都在它上面累加。

4. 鼠标位置 → 射线 → 平面上的一个点

屏幕像素 → NDC

1
2
3
const { left, top } = canvas.getBoundingClientRect()
const [cx, cy] = [pageX - left, pageY - top]
const NDCPos = screenToNDC({ x: cx, y: cy }, canvas)

pageX / pageY 是相对整个页面的坐标,先减去 canvas 的位置,换算成 canvas 内部的像素坐标。

然后转成 NDC(标准化设备坐标):

1
2
3
function screenToNDC({ x, y }, { clientWidth, clientHeight }) {
return new Vector2((x / clientWidth) * 2 - 1, -((y / clientHeight) * 2 - 1))
}

两件事:

  • 把 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
2
raycaster.ray.intersectPlane(tfPlane, dragEnd)
if (!dragEnd) return

Ray.intersectPlane(plane, target) 求射线和平面的交点,结果写进 target 并返回;如果射线和平面平行、或者交点在射线反方向,返回 null。

到这里,鼠标的二维位置变成了三维空间里的一个具体点。拖动过程中每刷新一次,dragEnd 就更新一次 —— 它和上一次的 dragStart 一比,就是鼠标这一帧在世界坐标里移动了多少。

5. 这一步移动,关节变了多少

旋转和推拉算的完全是两回事。

推拉关节:把位移投影到轴上

1
2
const startToEnd = dragEnd.clone().sub(dragStart)
delta = startToEnd.dot(pivot)

鼠标可能斜着拖,但推拉关节只能沿轴走。dot(pivot) 把这段位移投影到轴方向上 —— 拿到的就是沿轴移动了多少米。

pivot 是单位向量,所以点积的结果直接就是长度,不用再除模长。

旋转关节:转了多少,往哪个方向

1
2
3
4
5
6
const originToStart = new Vector3().subVectors(dragStart, origin)
const originToEnd = new Vector3().subVectors(dragEnd, origin)
const direction = Math.sign(
originToStart.clone().cross(originToEnd).dot(pivot),
)
delta = direction * originToEnd.angleTo(originToStart)

先把两个点都减去旋转中心,变成”相对圆心”的向量。问题就变成: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,分两步看:

  1. A.cross(B) 得到一个垂直于 A、B 所在平面的向量,方向由右手定则决定:四指从 A 弯向 B,拇指指向就是结果方向。
  2. 再和 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
2
3
Math.sign(5.3)   // 1
Math.sign(-0.01) // -1
Math.sign(0) // 0

三、合起来

1
delta = direction × angle

夹角 0.05 弧度、方向为正,delta = +0.05;方向为负就是 -0.05。

angleTo 是对称的(A.angleTo(B) 和 B.angleTo(A) 相等),所以角度那一项按哪个顺序写都一样。真正决定正负的只有 cross 里的先后 —— 写成 cross(originToEnd, originToStart),整个符号就反了,拖起来会朝相反方向转。

6. 累积,然后写回关节

1
2
3
this.curJointValue += delta
joint.setValue(this.curJointValue)
dragStart.copy(dragEnd)

三行,三件事。

一、把增量累加到 curJointValue。

pointermove 是高频触发的,每一帧只算”相对上一次”的一小段。curJointValue 在按下时初始化为关节当时的值,所以它始终是这次拖拽的绝对值 —— 传给 setValue 的仍然是绝对值,不是增量。

二、写回关节。

setValue 内部会按关节类型走旋转或推拉分支,顺带把值夹到 <limit> 范围内。所以拖到边界就会自动停住,这里不用自己判断。

三、把起点移到当前位置。

1
dragStart.copy(dragEnd)

下一帧的”上一帧位置”,就是这一帧的”当前位置”。少了这一行,每一帧都会拿最开始那个起点去算增量,转动会越拖越快 —— 每次的位移都被重复累加了一遍。

如果这一帧算出来 delta 正好是 0(鼠标没动),提前返回,这三行都跳过。

参考

Three.js 如何用鼠标拖动关节

https://wywzixin.github.io/threejs/urdf-joint-drag/

作者

wangyawei

发布于

2026-09-18

更新于

2026-09-18

许可协议