Three.js 如何生成关节的运动轨迹
上一篇讲鼠标怎么拖着关节动。这一篇讲拖动时屏幕上那条辅助线 —— 它告诉用户”这个关节还能往两边走多远”。
这条线是拖拽开始时才出现的,一松手就消失。

1. 这条线是什么
一条拖着关节时显示的辅助线,两种样子:
推拉关节:一条直线,从当前位置沿轴往两边伸,两端就是能推到的极限。
1 | lower ←───●───→ upper |
旋转关节:一段圆弧,绕轴铺开,两个端点是能转到的极限角度。
1 | ╭─────────╮ |
它由 JointDragHelper 画出来,本身就是一个 three.js 的 Line:
1 | class JointDragHelper extends Line { |
两个颜色 —— 起点红、终点黄 —— 配 vertexColors: true,线上就会有一条红到黄的渐变,能一眼看出往哪个方向是”还能走”。
frustumCulled = false 是必须的。这条线的顶点每帧都在变,Three.js 用来做视锥体裁剪的包围球还是旧的,不关掉的话线会莫名其妙消失。
画法本身很简单:算出顶点坐标,写进 geometry 的 position 属性就行。
1 | this.geometry.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3)) |
推拉是两个顶点,旋转是一串顶点连成弧。剩下的问题只有两个:顶点在哪,颜色怎么配。
2. 画之前要凑齐的四个量
1 | applyPrismaticPath(point, origin, pivot, lower, upper, value) |
前三个是空间量,后三个是数值。
| 量 | 是什么 | 怎么来的 |
|---|---|---|
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 | const currentLimit = limit - value // 推拉 |
URDF 里的 lower / upper 是相对零位的绝对角度(或绝对距离):
1 | <joint name="left_hip_pitch_joint" type="revolute"> |
而轨迹要从当前位置往外画。所以要减掉当前值,把”从零位算起能到哪”变成”从当前位置出发还能走多远”。
拿 value = 0.2 举例:
| 绝对限位 | 减掉 value 之后 |
|
|---|---|---|
| 下限 | -3.14 |
-3.34,还能往回走这么多 |
| 上限 | 2.5 |
2.3,还能往前走这么多 |
减出来是负数就表示那个方向已经走到头了。
这和上一篇 setValue 每次都从零位重算是同一个前提 —— 关节值本来就是相对零位的绝对量。
4. 推拉轨迹:两个点连成一条线
1 | applyPrismaticPath(point, origin, pivot, lower, upper, value) { |
第一步:把线挪出模型表面。
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 | applyRotatePath(point, origin, pivot, lower, upper, value, camera, domElement) { |
先按固定弧度步长把角度切成一段一段。step = 1 / (2π) 约等于 0.159 弧度、9 度左右一段。
循环结束后有一句 angles[angles.length - 1] = curUpper —— 因为按步长递增不一定正好落在 curUpper 上,最后一点直接改成端点值,保证圆弧两边顶到极限。
圆弧上的点是怎么算的
1 | angles.forEach((angle, ind) => { |
originToPoint 就是半径向量 —— 从圆心指向抓取点,长度是圆弧半径。
每一个点是:把半径向量绕 pivot 转 angle,再把尾巴接回圆心。
1 | 第一步:只转方向,不搬家 第二步:把尾巴接回圆心 |
拆成两句话:
originToPoint.clone().applyQuaternion(_quaternion)—— 转完还是一条”从原点出发”的向量.add(origin)—— 把它的尾巴挪到圆心上,箭头尖才落到空间里真正的位置
为什么不直接旋转 point
1 | point.clone().applyQuaternion(_quaternion) // 错 |
因为 applyQuaternion 是个线性变换,它把向量当作”从原点出发”的箭头来转。而 point 是空间里的一个位置,直接转它,等于假设旋转轴穿过世界原点 (0, 0, 0)。
所以要先减 origin 把它变成向量,转完再加回来:
1 | 把点变成向量 → 旋转 → 把向量变回点 |
和上一节 projectOnVector 的处理是同一个套路:对点做线性变换,先减参考点、算完再加回来。
颜色不是线性的
1 | const inter = ind / len |
ind / len 从 0 走到 1,但传进 lerpHSL 之前取了平方。所以前半段变化慢、后半段变化快 —— 靠近起点那一大截颜色几乎不变,最后一段才快速过渡到终点色。
lerpHSL 是在 HSL 空间插值(色相 / 饱和度 / 亮度),比直接插 RGB 更接近人眼的感觉 —— RGB 插值在有些颜色之间会经过一段发灰的中间色。
参考
- Three.js
Line与BufferAttribute:https://threejs.org/docs/#api/en/objects/Line - Three.js
Color.lerpHSL:https://threejs.org/docs/#api/en/math/Color - Three.js
Quaternion.setFromAxisAngle与Vector3.applyQuaternion:https://threejs.org/docs/#api/en/math/Quaternion - Three.js
Vector3.projectOnVector:https://threejs.org/docs/#api/en/math/Vector3 - 上一篇:Three.js 如何用鼠标拖动关节 —— https://wywzixin.github.io/threejs/urdf-joint-drag/
Three.js 如何生成关节的运动轨迹