
做三维钓鱼页面时人物需要坐着握竿也要抛竿、抄鱼。鱼竿的姿态变了手臂和手掌都得跟着调整。我在「闲钓小栈」里采用了这样的流程由同一份会话状态产生鱼竿姿态和手部握点再根据握点计算肩、肘、腕的姿态。项目使用 Three.js0.186.1。这里先讲腕点的位置求解。掌心朝向和手指弯曲是后续步骤单靠双骨骼 IK 不会自动得到完整的握持动作。1. 已知肩膀和握点先求肘部在哪里把一条手臂简化成两个固定长度的骨段符号含义A当前肩点B当前肘点C当前腕点T希望手腕到达的目标Ppole 点用于选择肘部弯曲方向上臂长a |B - A|前臂长b |C - B|。移动到目标后这两个长度应保持不变。所有计算先统一到世界坐标。骨骼的位置可以用getWorldPosition()读取握点如果定义在人物根节点的局部坐标中就通过localToWorld()转换。转换时传入副本避免把原始握点配置改成世界坐标。Three.js 坐标接口说明actorRoot.updateWorldMatrix(true,true);consttargetWorldactorRoot.localToWorld(localGrip.clone());constpoleWorldactorRoot.localToWorld(localPole.clone());项目中的握点含有针对当前模型的偏移标定不能换一个人物或鱼竿模型就默认这些数值仍然适用。2. 目标不一定在手臂够得到的范围内两段骨骼可达的肩腕距离位于|a - b|到a b之间。目标太远手伸直也够不到两段长度不相等时目标太近也可能无法到达。为了避开完全伸直或完全折叠的奇异位置独立示例留了一点余量constmarginMath.min(a,b)*1e-4;constdTHREE.MathUtils.clamp(A.distanceTo(T),Math.abs(a-b)margin,ab-margin,);constreachableTargetA.clone().addScaledVector(u,d);其中u是从肩指向目标的单位方向。若目标刚好与肩重合这个方向没有定义需要退回当前肩腕方向仍然重合时再使用当前上臂方向。钳制距离之后上臂和前臂都应使用同一个可达目标。如果上臂按钳制后的三角形求解下段却继续追原始的不可达目标最终姿态就不能按同一组几何关系解释。下文是从项目思路提炼的独立教学实现补充了这些边界处理没有把它们当成现有产品代码的全部保证。3. pole 解决“肘部往哪边弯”的问题即使肩点和腕点都确定肘部仍然可以绕肩腕连线转动。还需要一个方向来选择弯曲平面。先取 pole 相对肩的位置w P - A再减掉它沿u的分量v normalize(w - u * dot(w, u))得到的v与u垂直指向选择的弯曲侧。对应代码是constbendpole.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**2distance**2-lowerLength**2)/(2*distance);constriseMath.sqrt(Math.max(0,upperLength**2-along**2));constelbowshoulder.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因此常规方向下的关键写法是constcorrectionnewTHREE.Quaternion().setFromUnitVectors(from.clone().normalize(),to.clone().normalize(),);constdesiredWorldbone.getWorldQuaternion(newTHREE.Quaternion()).premultiply(correction);constparentInversebone.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时进入完全反向分支。这是数值容差策略但这个测试需要保留可测的近反向角度。所以教学示例对“接近反向、且叉积仍能确定旋转轴”的情况改用constcrossnewTHREE.Vector3().crossVectors(start,finish);constsinecross.length();constcosineTHREE.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 上下文人物外观、穿插和动作连续性仍需要实际画面验收。这些姿态处理思路来自我开发的闲钓小栈目前是电脑端单人三维垂钓与学习预览。