做三维钓鱼页面时,人物需要坐着握竿,也要抛竿、抄鱼。鱼竿的姿态变了,手臂和手掌都得跟着调整。
我在「闲钓小栈」里采用了这样的流程:由同一份会话状态产生鱼竿姿态和手部握点,再根据握点计算肩、肘、腕的姿态。项目使用 Three.js0.186.1。
这里先讲腕点的位置求解。掌心朝向和手指弯曲是后续步骤,单靠双骨骼 IK 不会自动得到完整的握持动作。
1. 已知肩膀和握点,先求肘部在哪里
把一条手臂简化成两个固定长度的骨段:
| 符号 | 含义 |
|---|---|
A | 当前肩点 |
B | 当前肘点 |
C | 当前腕点 |
T | 希望手腕到达的目标 |
P | pole 点,用于选择肘部弯曲方向 |
上臂长a = |B - A|,前臂长b = |C - B|。移动到目标后,这两个长度应保持不变。
所有计算先统一到世界坐标。骨骼的位置可以用getWorldPosition()读取;握点如果定义在人物根节点的局部坐标中,就通过localToWorld()转换。转换时传入副本,避免把原始握点配置改成世界坐标。Three.js 坐标接口说明
actorRoot.updateWorldMatrix(true,true);consttargetWorld=actorRoot.localToWorld(localGrip.clone());constpoleWorld=actorRoot.localToWorld(localPole.clone());项目中的握点含有针对当前模型的偏移标定,不能换一个人物或鱼竿模型,就默认这些数值仍然适用。
2. 目标不一定在手臂够得到的范围内
两段骨骼可达的肩腕距离,位于|a - b|到a + b之间。目标太远,手伸直也够不到;两段长度不相等时,目标太近也可能无法到达。
为了避开完全伸直或完全折叠的奇异位置,独立示例留了一点余量:
constmargin=Math.min(a,b)*1e-4;constd=THREE.MathUtils.clamp(A.distanceTo(T),Math.abs(a-b)+margin,a+b-margin,);constreachableTarget=A.clone().addScaledVector(u,d);其中u是从肩指向目标的单位方向。若目标刚好与肩重合,这个方向没有定义,需要退回当前肩腕方向;仍然重合时,再使用当前上臂方向。
钳制距离之后,上臂和前臂都应使用同一个可达目标。如果上臂按钳制后的三角形求解,下段却继续追原始的不可达目标,最终姿态就不能按同一组几何关系解释。
下文是从项目思路提炼的独立教学实现,补充了这些边界处理,没有把它们当成现有产品代码的全部保证。
3. pole 解决“肘部往哪边弯”的问题
即使肩点和腕点都确定,肘部仍然可以绕肩腕连线转动。还需要一个方向来选择弯曲平面。
先取 pole 相对肩的位置w = P - A,再减掉它沿u的分量:
v = normalize(w - u * dot(w, u))得到的v与u垂直,指向选择的弯曲侧。
对应代码是:
constbend=pole.clone().sub(shoulder);bend.addScaledVector(direction,-bend.dot(direction));bend.normalize();pole 如果落在肩腕连线上,投影会接近零。独立示例先尝试当前肘部的投影;仍然退化时,再选择与u不平行的坐标轴构造垂直方向。
这个 fallback 能让数值继续求解,但不保证穿过奇异点时动画连续。实际项目还可以保留上一帧的弯曲方向,并加入肘部限位。
4. 用余弦定理算出肘点
设肘点沿肩腕方向的投影距离为x,垂直距离为h,肩腕距离为d。根据两个直角三角形:
a² = x² + h² b² = (d - x)² + h²相减后得到:
x = (a² + d² - b²) / (2d) h = sqrt(max(0, a² - x²)) B* = A + x·u + h·vB*就是期望肘点。根号内的max(0, ...)用来处理浮点舍入带来的微小负值;骨段长度、目标和方向仍要事先检查,不能靠它掩盖无效输入。
代码里真正的几何计算只有几行:
constalong=(upperLength**2+distance**2-lowerLength**2)/(2*distance);constrise=Math.sqrt(Math.max(0,upperLength**2-along**2));constelbow=shoulder.clone().addScaledVector(direction,along).addScaledVector(bend,rise);到这里求出的只是两个目标点:肘点B*和可达腕点C*。接下来还要把它们变成骨骼旋转。
5. 世界空间里的旋转,不能直接写进局部 quaternion
先把上臂当前的世界方向B - A转到目标方向B* - A。对于归一化方向,Three.js 的setFromUnitVectors()可以构造这个旋转修正。四元数接口说明
设修正为ΔQ,骨骼原世界旋转为Qworld,父节点世界旋转为Qparent,则:
QworldNew = ΔQ × Qworld QlocalNew = inverse(Qparent) × QworldNew因此,常规方向下的关键写法是:
constcorrection=newTHREE.Quaternion().setFromUnitVectors(from.clone().normalize(),to.clone().normalize(),);constdesiredWorld=bone.getWorldQuaternion(newTHREE.Quaternion()).premultiply(correction);constparentInverse=bone.parent?bone.parent.getWorldQuaternion(newTHREE.Quaternion()).invert():newTHREE.Quaternion();bone.quaternion.copy(parentInverse.multiply(desiredWorld)).normalize();premultiply()对应左乘修正。乘法顺序换了,修正所处的坐标空间也会改变。Three.js 四元数乘法接口
上臂转完之后,需要更新矩阵,再重新读取肘点和腕点,然后计算前臂旋转。否则前臂仍然使用上臂旋转前的世界坐标。
示例限定直接的三骨骼父子链,以及正均匀缩放。非均匀缩放、剪切和带额外扭转骨的模型,需要重新处理变换和骨骼映射。
6. 测试时碰到的一个细节:接近 180° 的方向修正
独立示例里,我把“目标与肩重合”也放进了测试。两段骨长都为 1,目标距离被钳制到1e-4后,前臂的方向修正接近 180°。
首轮测试中,实际腕点几乎回到肩点,相对钳制目标留下约1e-4个模型单位的误差。
核对锁定版本的Quaternion.setFromUnitVectors()源码,它在dot + 1 < 1e-8时进入完全反向分支。这是数值容差策略,但这个测试需要保留可测的近反向角度。
所以教学示例对“接近反向、且叉积仍能确定旋转轴”的情况,改用:
constcross=newTHREE.Vector3().crossVectors(start,finish);constsine=cross.length();constcosine=THREE.MathUtils.clamp(start.dot(finish),-1,1);// 仅在接近反向且 sine 足够大时使用。correction.setFromAxisAngle(cross.divideScalar(sine),Math.atan2(sine,cosine),);完全反向时旋转轴并不唯一,示例仍沿用库的合法 fallback。这里验证的是位置求解,肢体扭转是否自然,还需要结合人物模型检查。
7. 接回人物姿态时,还要处理掌心与骨骼副本
项目中的每次姿态更新先恢复记录的基姿,再应用当前状态和 IK。手掌随后用当前基向量与目标基向量计算朝向修正,手指再做参数化弯曲。
顺序可以整理成:
恢复动画基姿 → 更新人物位置和当前握点 → 统一坐标,计算可达腕点与肘点 → 旋转上臂,更新矩阵 → 旋转前臂,更新矩阵 → 调整掌心方向和手指 → 绘制腕点到了握点附近,掌心仍然可能朝错方向。双骨骼求解没有约束腕部朝向,也没有处理肩肘限位、身体补偿和手部碰撞。
另外,项目通过SkeletonUtils.clone()复制蒙皮人物。在锁定版本实现中,它会把副本 SkinnedMesh 的骨骼引用重新指向副本中的 Bone;复制的根对象要包含相关网格和骨骼。几何和材质仍可能共用,不能因此当成独占资源。
我用合成骨骼和网格做了 13 项自动测试,覆盖可达与不可达目标、pole 退化、父节点旋转、不同骨骼局部轴、重复求解和骨骼副本隔离。这些测试不创建 WebGL 上下文,人物外观、穿插和动作连续性仍需要实际画面验收。
这些姿态处理思路来自我开发的闲钓小栈,目前是电脑端单人三维垂钓与学习预览。