Unity 中如何从 joint 的 quaternion 旋转反求球面角度?
可以反求。先在原函数返回值右侧乘上 Quaternion.Inverse(bindRelative),去掉绑定姿态,再依次求出外层的 azimuthDeg、移除外层旋转,最后求内层的 elevationDeg。
反求函数接收的旋转必须与 ComputeSphericalTarget() 的返回值采用相同的空间和姿态约定。刚体的世界旋转、相对父刚体的旋转和 ConfigurableJoint.targetRotation 不能混用。
先把原函数化简
以下推导针对 joint != null && joint.connectedBody != null 的分支。
记:
- (P):父刚体的世界旋转。
- (B):
bindRelative。 - (h):归一化后的
hangAxisParentLocal。 - (l):归一化后的
liftAxisParentLocal。 - (a):
azimuthDeg。 - (e):
elevationDeg。 - (R_u(\theta)):绕轴 (u) 旋转 (\theta)。
原函数计算的是:
[ Q=P^{-1}\left[R_{Ph}(a)R_{Pl}(e)\right]PB ]
绕旋转后的轴进行旋转,可以写成四元数的共轭变换:
[ R_{Ph}(a)=P R_h(a)P^{-1} ]
代入后,父级旋转全部消去:
[ Q=R_h(a)R_l(e)B ]
因此,原函数的正常分支等价于:
return Quaternion.AngleAxis(azimuthDeg, hangAxisParentLocal.normalized)
* Quaternion.AngleAxis(elevationDeg, liftAxisParentLocal.normalized)
* bindRelative;
去掉绑定姿态后,需要分解的旋转为:
[ D=QB^{-1}=R_h(a)R_l(e) ]
这里必须在右侧乘 (B^{-1})。四元数乘法通常不可交换,改成 (B^{-1}Q) 会改变结果。Unity 的四元数乘法说明
为什么先求 azimuth
让 (D) 作用于内层旋转轴 (l):
[ Dl=R_h(a)R_l(e)l ]
向量绕自身旋转不会改变方向,所以:
[ R_l(e)l=l ]
于是:
[ Dl=R_h(a)l ]
这样就消去了 elevationDeg,可以通过 (l) 与 (Dl) 绕 (h) 的夹角求出 azimuthDeg。
如果两根轴不垂直,需要先将这两个向量投影到垂直于 (h) 的平面:
[ u=l-h(h\cdot l) ]
[ v=Dl-h(h\cdot Dl) ]
方位角为:
[ a=\operatorname{atan2} \left(h\cdot(u\times v),,u\cdot v\right) ]
得到 (a) 后,从左侧移除外层旋转:
[ E=R_h(-a)D ]
对于原函数能精确生成的姿态,此时有:
[ E=R_l(e) ]
再从 (E) 中读取绕 (l) 的旋转角即可。
C# 实现
这个函数支持两根轴不垂直的情况,但要求它们非零且不平行。输入四元数应是有效旋转。
reconstructionErrorDeg 是用反求出的两个角度重建姿态后,与输入姿态相差的度数。返回 true 表示成功求得一组角度,是否精确匹配仍需看这个误差。
using UnityEngine;
public static class SphericalRotationUtility
{
public static bool TryGetSphericalAngles(
Quaternion currentRelative,
Quaternion bindRelative,
Vector3 liftAxisParentLocal,
Vector3 hangAxisParentLocal,
out float elevationDeg,
out float azimuthDeg,
out float reconstructionErrorDeg)
{
elevationDeg = 0f;
azimuthDeg = 0f;
reconstructionErrorDeg = float.PositiveInfinity;
const float epsilon = 1e-8f;
if (liftAxisParentLocal.sqrMagnitude < epsilon ||
hangAxisParentLocal.sqrMagnitude < epsilon)
{
return false;
}
Vector3 l = liftAxisParentLocal.normalized;
Vector3 h = hangAxisParentLocal.normalized;
// D = R_h(azimuth) * R_l(elevation)
Quaternion d = (
currentRelative.normalized *
Quaternion.Inverse(bindRelative.normalized)
).normalized;
// 内层旋转不改变 l,因此 d * l 只包含外层旋转的信息。
Vector3 u = Vector3.ProjectOnPlane(l, h);
Vector3 v = Vector3.ProjectOnPlane(d * l, h);
// 两轴平行,或当前姿态使投影退化时,无法可靠求角。
if (u.sqrMagnitude < epsilon || v.sqrMagnitude < epsilon)
{
return false;
}
u.Normalize();
v.Normalize();
azimuthDeg = Mathf.Atan2(
Vector3.Dot(h, Vector3.Cross(u, v)),
Vector3.Dot(u, v)
) * Mathf.Rad2Deg;
// 从左侧移除外层旋转。
Quaternion inner = (
Quaternion.AngleAxis(-azimuthDeg, h) * d
).normalized;
// 对纯绕 l 的旋转:
// inner.xyz = l * sin(elevation / 2)
// inner.w = cos(elevation / 2)
float sinHalf = Vector3.Dot(
new Vector3(inner.x, inner.y, inner.z),
l
);
float cosHalf = inner.w;
// 对任意实际姿态,这一步提取绕 l 的 twist。
// 两项都接近零时,该 twist 无法稳定确定。
if (sinHalf * sinHalf + cosHalf * cosHalf < epsilon)
{
return false;
}
elevationDeg = Mathf.DeltaAngle(
0f,
2f * Mathf.Atan2(sinHalf, cosHalf) * Mathf.Rad2Deg
);
Quaternion rebuilt =
Quaternion.AngleAxis(azimuthDeg, h) *
Quaternion.AngleAxis(elevationDeg, l) *
bindRelative.normalized;
reconstructionErrorDeg =
Quaternion.Angle(currentRelative.normalized, rebuilt);
return true;
}
}
求 elevationDeg 时,用到轴角四元数的形式:
[ q= \left( l_x\sin\frac e2,, l_y\sin\frac e2,, l_z\sin\frac e2,, \cos\frac e2 \right) ]
因此:
[ e=2\operatorname{atan2} \left(l\cdot q_{xyz},,q_w\right) ]
Mathf.DeltaAngle 将结果折回约 ([-180^\circ,180^\circ]) 的范围,同时消除四元数正负号不同造成的整圈差异。
如何读取 joint 的实际姿态
如果 bindRelative 定义为“零角度时,子刚体相对 connectedBody 的旋转”,初始化时就应按这个约定保存:
// 在控制器初始化时缓存。
Rigidbody childBody = joint.GetComponent<Rigidbody>();
Quaternion bindRelative =
Quaternion.Inverse(joint.connectedBody.rotation) *
childBody.rotation;
运行时读取当前相对姿态:
Quaternion currentRelative =
Quaternion.Inverse(joint.connectedBody.rotation) *
childBody.rotation;
bool success = SphericalRotationUtility.TryGetSphericalAngles(
currentRelative,
bindRelative,
liftAxisParentLocal,
hangAxisParentLocal,
out float elevationDeg,
out float azimuthDeg,
out float reconstructionErrorDeg
);
if (success)
{
Quaternion desiredRelative = ComputeSphericalTarget(
joint,
bindRelative,
elevationDeg,
azimuthDeg,
liftAxisParentLocal,
hangAxisParentLocal
);
// 用这两个角度更新控制器的球面目标。
// desiredRelative 再通过项目已有的姿态转换逻辑,
// 转成 ConfigurableJoint.targetRotation。
}
这里的父级是 connectedBody,不一定是 transform.parent。只有二者的参考坐标一致时,才能用 transform.localRotation 直接替代上述相对旋转。
姿态目标和驱动参数也需要区分。targetRotation 是关节驱动的目标值,其含义受关节配置影响。开启 configuredInWorldSpace 后,Target 值会按世界空间计算。targetRotation 文档、configuredInWorldSpace 文档
原函数只有旋转组合,没有包含从绑定姿态、关节坐标基到驱动参数的完整转换。仅凭这段函数,无法确定其返回值能否直接赋给 joint.targetRotation。如果项目中已有这层转换,重置时应继续使用。
碰撞后的姿态可能没有精确解
这套控制方式只有两个角度,一般刚体姿态则有三个旋转自由度。碰撞或外力可能使实际姿态超出下式能表示的范围:
[ Q=R_h(a)R_l(e)B ]
遇到这种情况,代码仍可尝试求出一组角度,但重建姿态会有误差。代码先根据 d * l 确定方位角,再提取剩余旋转绕 (l) 的 twist。这种方法不保证在所有情况下都能得到角度误差最小的全局拟合。
如果 reconstructionErrorDeg 明显超过控制器可接受的范围,可以:
- 接受这组角度,将目标拉回两个角度能够表示的姿态。
- 以这组角度为初值,在控制器允许的角度范围内做数值优化,最小化重建姿态与实际姿态的旋转差。
- 如果允许保留完整姿态,直接保存当前相对旋转作为目标,再通过现有转换逻辑写入驱动,无须先压缩成两个角度。
使用时还需注意
两根轴平行或反平行时,无法独立确定两个角度;接近平行时,计算对误差也更敏感。函数返回 false 时,应保留上一组有效角度。
角度具有周期性。例如,反求得到的 -10° 和原输入的 350° 可以表示相同旋转。连续控制时,可以选择最接近上一帧的等价角度:
azimuthDeg =
previousAzimuthDeg +
Mathf.DeltaAngle(previousAzimuthDeg, azimuthDeg);
elevationDeg =
previousElevationDeg +
Mathf.DeltaAngle(previousElevationDeg, elevationDeg);
验证结果时,应比较重建后的旋转,无须要求角度数值完全相同:
float errorDeg = Quaternion.Angle(originalRotation, rebuiltRotation);
原函数在没有 connectedBody 时使用 Quaternion.Euler(elevationDeg, 0f, azimuthDeg),这一分支忽略了传入的两根轴,采用了另一套映射。上述逆函数只针对有 connectedBody 的正常分支;如果需要同时支持两种情况,应分别定义并求解。
备注:内容仅供参考。
