PAYATHON 2026

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 的正常分支;如果需要同时支持两种情况,应分别定义并求解。

备注:内容仅供参考。