Three.js 如何生成关节的运动轨迹

上一篇讲鼠标怎么拖着关节动。这一篇讲拖动时屏幕上那条辅助线 —— 它告诉用户”这个关节还能往两边走多远”。

这条线是拖拽开始时才出现的,一松手就消失。

拖动关节时出现的运动轨迹辅助线:橙色到黄色的圆弧,两端就是这个关节能转到的极限

1. 这条线是什么

一条拖着关节时显示的辅助线,两种样子:

推拉关节:一条直线,从当前位置沿轴往两边伸,两端就是能推到的极限。

1
2
lower ←───●───→ upper
当前位置

旋转关节:一段圆弧,绕轴铺开,两个端点是能转到的极限角度。

1
2
3
4
5
6
  ╭─────────╮
╱ ╲
│ ● │
╲ ╱
╰─────────╯
当前位置

它由 JointDragHelper 画出来,本身就是一个 three.js 的 Line:

1
2
3
4
5
6
class JointDragHelper extends Line {
material = new LineBasicMaterial({ vertexColors: true })
colorMap: [Color, Color] = [new Color(0xff0000), new Color(0xffff00)]
// 强制渲染,取消视锥体检查
frustumCulled = false
}

两个颜色 —— 起点红、终点黄 —— 配 vertexColors: true,线上就会有一条红到黄的渐变,能一眼看出往哪个方向是”还能走”。

frustumCulled = false 是必须的。这条线的顶点每帧都在变,Three.js 用来做视锥体裁剪的包围球还是旧的,不关掉的话线会莫名其妙消失。

画法本身很简单:算出顶点坐标,写进 geometry 的 position 属性就行。

1
this.geometry.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3))

推拉是两个顶点,旋转是一串顶点连成弧。剩下的问题只有两个:顶点在哪,颜色怎么配。

2. 画之前要凑齐的四个量

1
2
applyPrismaticPath(point, origin, pivot, lower, upper, value)
applyRotatePath(point, origin, pivot, lower, upper, value, camera, domElement)

前三个是空间量,后三个是数值。

量 是什么 怎么来的
point 抓取点 按下时射线与拖拽平面的交点(世界坐标)
origin 旋转圆心 point 垂直投影到关节轴上的垂足
pivot 轴的世界方向 axis.transformDirection(matrixWorld),单位向量
lower / upper URDF 的限位 <limit lower="..." upper="...">
value 关节当前值 拖拽开始时的值

前三个上一篇拆过,这里只补一句容易混的:

point 和 origin 都是空间里的”点”,但 pivot 是”方向”。 所以 pivot 用 transformDirection(matrixWorld) 转换 —— 它只取矩阵的旋转部分,不理会平移。轴是方向,不该被关节站在哪里影响。

另外 origin 不是关节自己的位置。关节的位置是 matrixWorld 第 4 列前三个数:

1
const jointWorldPos = new Vector3(elements[12], elements[13], elements[14])

而 origin 是 point 到”过 jointWorldPos、方向 pivot 的那条直线”的垂足。两者只有在抓取点恰好正对关节时才重合。

pivot 是单位向量这一点后面一直用到 —— 它让 pivot × 距离 可以直接当作位移。

3. 先把绝对限位换算成”还能走多远”

两种轨迹的第一行都是同一个东西:

1
2
3
const currentLimit = limit - value      // 推拉
const curLower = lower - value // 旋转
const curUpper = upper - value // 旋转

URDF 里的 lower / upper 是相对零位的绝对角度(或绝对距离):

1
2
<joint name="left_hip_pitch_joint" type="revolute">
<limit lower="-3.14" upper="2.5"/>

而轨迹要从当前位置往外画。所以要减掉当前值,把”从零位算起能到哪”变成”从当前位置出发还能走多远”。

拿 value = 0.2 举例:

绝对限位 减掉 value 之后
下限 -3.14 -3.34,还能往回走这么多
上限 2.5 2.3,还能往前走这么多

减出来是负数就表示那个方向已经走到头了。

这和上一篇 setValue 每次都从零位重算是同一个前提 —— 关节值本来就是相对零位的绝对量。

4. 推拉轨迹:两个点连成一条线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
applyPrismaticPath(point, origin, pivot, lower, upper, value) {
const { colorMap: [cs, ce] } = this
// 变换基点origin到point的向量
const originToPoint = point.clone().sub(origin)
// 从origin 向point 方向外扩一个比较小的值,避免辅助线与模型的重叠
const outPoint = originToPoint.clone().normalize().multiplyScalar(0.02).add(point)
// 顶点集合
const positions: number[] = []
for (let limit of [lower, upper]) {
// 基于当前变换量的拖拽上下限
const currentLimit = limit - value
// 基于outPoint取辅助线的两端
const { x, y, z } = pivot.clone().multiplyScalar(currentLimit).add(outPoint)
positions.push(x, y, z)
}
...
}

第一步:把线挪出模型表面。

1
const outPoint = originToPoint.clone().normalize().multiplyScalar(0.02).add(point)

originToPoint 是圆心指向抓取点的向量,它的方向不是推拉方向 —— 只是用来挑一个”往哪边挪”的方向。

拆开看:

  • .normalize() 把长度压成 1,只留方向
  • .multiplyScalar(0.02) 变成 2 厘米
  • .add(point) 从抓取点再往外挪这 2 厘米

得到的 outPoint 比模型表面再外偏一点点。因为 point 正好落在 mesh 表面上,线画在那儿会和模型重叠在一起看不清 —— 稍微偏出来就清楚了。

第二步:两个端点。

1
pivot.clone().multiplyScalar(currentLimit).add(outPoint)

就是从 outPoint 出发,沿轴方向走 currentLimit 米。因为 pivot 是单位向量,pivot × currentLimit 的长度正好是 |currentLimit|。

两个端点连起来就是这条线。注意它是从当前位置向两边画的,不是从零位画到 limit。

5. 旋转轨迹:把半径向量转一圈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
applyRotatePath(point, origin, pivot, lower, upper, value, camera, domElement) {
const { colorMap: [cs, ce] } = this
const originToPoint = point.clone().sub(origin)
const curLower = lower - value
const curUpper = upper - value
// 路径圆滑度,即每多少弧度做一次分段
const step = 1 / (Math.PI * 2)
const angles: number[] = []
for (let angle = curLower; angle < curUpper; angle += step) {
angles.push(angle)
}
angles[angles.length - 1] = curUpper
...
}

先按固定弧度步长把角度切成一段一段。step = 1 / (2π) 约等于 0.159 弧度、9 度左右一段。

循环结束后有一句 angles[angles.length - 1] = curUpper —— 因为按步长递增不一定正好落在 curUpper 上,最后一点直接改成端点值,保证圆弧两边顶到极限。

圆弧上的点是怎么算的

1
2
3
4
5
6
angles.forEach((angle, ind) => {
_quaternion.setFromAxisAngle(pivot, angle)
const { x, y, z } = originToPoint.clone().applyQuaternion(_quaternion).add(origin)
positions.push(x, y, z)
...
})

originToPoint 就是半径向量 —— 从圆心指向抓取点,长度是圆弧半径。

每一个点是:把半径向量绕 pivot 转 angle,再把尾巴接回圆心。

1
2
3
4
5
6
7
8
第一步:只转方向,不搬家            第二步:把尾巴接回圆心

originToPoint 绕 pivot 转 θ → + origin

↗ ↗
╱ ╱
● origin ● origin
半径向量 圆弧上的点

拆成两句话:

  • originToPoint.clone().applyQuaternion(_quaternion) —— 转完还是一条”从原点出发”的向量
  • .add(origin) —— 把它的尾巴挪到圆心上,箭头尖才落到空间里真正的位置

为什么不直接旋转 point

1
point.clone().applyQuaternion(_quaternion)   // 错

因为 applyQuaternion 是个线性变换,它把向量当作”从原点出发”的箭头来转。而 point 是空间里的一个位置,直接转它,等于假设旋转轴穿过世界原点 (0, 0, 0)。

所以要先减 origin 把它变成向量,转完再加回来:

1
把点变成向量  →  旋转  →  把向量变回点

和上一节 projectOnVector 的处理是同一个套路:对点做线性变换,先减参考点、算完再加回来。

颜色不是线性的

1
2
3
const inter = ind / len
const { r, g, b } = cs.clone().lerpHSL(ce, inter * inter)
colors.push(r, g, b)

ind / len 从 0 走到 1,但传进 lerpHSL 之前取了平方。所以前半段变化慢、后半段变化快 —— 靠近起点那一大截颜色几乎不变,最后一段才快速过渡到终点色。

lerpHSL 是在 HSL 空间插值(色相 / 饱和度 / 亮度),比直接插 RGB 更接近人眼的感觉 —— RGB 插值在有些颜色之间会经过一段发灰的中间色。

参考

Three.js 如何生成关节的运动轨迹

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

作者

wangyawei

发布于

2026-09-18

更新于

2026-09-18

许可协议