用 MediaPipe 驱动 3D 角色

从关键点到骨骼旋转

使用 MediaPipe 等基于关键点的姿态估计系统,可以从图像中获取一组人体关键点。然而,这些关键点本身无法直接驱动 3D 角色,还需要将其转换为骨骼的旋转。本文将在 Unity 中对这一过程进行逐步推导。在开始之前,让我们先梳理姿态估计方案的两种主要类型,并分析不同输出形式对后续处理的影响。

两类姿态估计方案

基于关键点

这类方案输出人体各关节的二维或三维坐标,通常包含 17~33 个点。代表性的方案有,

  • OpenPose CMU 开源的经典方案,支持多人姿态估计,输出 25 个身体关键点
  • MediaPipe Pose Google 开源方案,支持 33 个全身关键点,可在移动端实时运行
  • VideoPose3D 从 2D 关键点序列中恢复 3D 坐标,利用时序信息提升深度估计精度

关键点方案的输出是一组坐标,要驱动骨骼就需要自行将这些坐标转换为旋转。这正是本文要解决的问题。

基于参数化人体模型(SMPL)

这类方案直接输出人体模型的参数,包括每个关节的旋转和体型参数。代表性的方案有,

  • 4DHumans 单帧输入即可回归 SMPL 参数,速度快,适合实时场景,但缺少帧间连续性,动作可能逐帧抖动
  • WHAM 利用视频时序信息,输出时间上连贯的 SMPL 序列,动作比单帧方法更平滑,同时估计人体在世界坐标中的全局位移
  • GVHMR 在 WHAM 的基础上进一步提升全局轨迹的精度,尤其擅长处理快速移动和大范围位移的场景

SMPL 方案的输出已经包含了关节旋转,可以直接映射到骨骼系统,不需要额外的转换计算。值得注意的是,SMPL 背后的 Meshcapade 近期已被 Epic Games 收购,未来有望在虚幻引擎中得到更深入的整合。

两者的比较

基于关键点基于 SMPL
输出关节坐标关节旋转 + 体型参数
驱动骨骼需要自行计算旋转可直接映射
实时性轻量,适合端侧实时较重,多数需要 GPU
身体接触仅有关节位置,难以判断身体部位间的接触关系包含体型参数,对接地、坐姿等身体部位接触的场景还原更好
灵活性不依赖特定人体模型绑定 SMPL 拓扑
商业授权部分方案开源且支持免费商用SMPL 模型本身用于商业用途需向 Meshcapade 获取授权

从关键点到骨骼旋转

MediaPipe 等系统给出了三维空间中的关键点坐标,而 Unity 骨骼系统需要的是每根骨骼的旋转。我们的任务就是从前者推出后者。

这之所以可行,是因为骨骼动画中动作由旋转定义。不同身高的人在做同样动作时,各关节的空间位置会不同,但骨骼之间的相对旋转是一致的。只要每根骨骼的旋转正确,无论角色身材比例如何,最终表现出的动作都会一致。

在这种情况下,一个自然的想法是直接使用 IK 来拟合这些关键点。但这种方法在实际应用中存在限制。原因在于,MediaPipe 的 3D 关键点经过归一化处理,其数值与真实物理长度无关。这意味着关键点之间的距离并不可靠,难以作为 IK 的有效约束。

另一方面,MediaPipe 给出的 3D 关键点坐标已经很好地描述了人体动作,所以更直接的做法是从这些关键点中提取方向信息,推算各骨骼的旋转,从而复原动作。

本文假设读者已经了解 Unity 的 Humanoid 骨骼系统以及向量叉乘的基本概念。示例中将使用 MediaPipeUnityPlugin,

https://github.com/homuler/MediaPipeUnityPlugin

完整示例代码可在本文末尾获取。

躯干

为了将关键点与模型骨骼放入同一坐标系中直接比较,我们先将模型放置在 Unity 世界原点,并保持其旋转为 identity(即为 0),同时约定 Unity 的 X 轴正方向为角色右侧。

模型与关键点共用坐标系
将模型放置在 Unity 世界原点,与关键点共用同一坐标系

将 pose landmark 显示在屏幕上,并将左肩与左髋标记为红色,便于识别。

标记后的 pose landmark
将左肩和左髋标记为红色,便于区分左右

同时给出关键点索引,方便后续引用。

MediaPipe Pose 33 个关键点
MediaPipe Pose 的 33 个关键点及其索引

https://github.com/google-ai-edge/mediapipe/blob/master/docs/solutions/pose.md

通过观察可以发现,Hips 的朝向可以由四个关键点确定,X 轴对应左髋指向右髋的方向,Y 轴对应髋部中心指向肩部中心的方向。

Hips 朝向由四个关键点确定
双肩与双髋构成的平面确定 Hips 的朝向

据此可以从关键点中计算出两个方向,

Vector3 hipXDir = (landmark[24] - landmark[23]).normalized;
Vector3 hipCenter = (landmark[23] + landmark[24]) * 0.5f;
Vector3 shoulderCenter = (landmark[11] + landmark[12]) * 0.5f;
Vector3 hipYDir = (shoulderCenter - hipCenter).normalized;C#

接下来需要将这两个方向转换为骨骼旋转。Unity 提供的 Quaternion.LookRotation(forward, up) 可用于构造旋转,其中 Z 轴对齐 forward,Y 轴尽量靠近 up。

由于当前已知的是 X 与 Y 方向,可以通过叉乘补出 Z 轴,并传入 LookRotation,

Vector3 hipZDir = Vector3.Cross(hipXDir, hipYDir).normalized;
_hips.rotation = Quaternion.LookRotation(hipZDir, hipYDir);C#

这里需要注意,LookRotation 会精确对齐 Z 轴(forward)。虽然我们最初是从 X 和 Y 出发,但通过叉乘构造出的 hipZDir 已经与 hipXDir、hipYDir 共同组成一组正交基。

这样,Hips 的 Z 轴垂直于躯干平面,Y 轴对齐髋部中心指向肩部中心的方向,X 轴由正交关系自动确定,从而唯一确定骨骼的空间朝向。

Hips 骨骼朝向结果
Hips 骨骼朝向随关键点变化
Hips 骨骼朝向结果
不同姿态下的 Hips 朝向始终与关键点保持一致

总结一下,这里我们做了四件事,

  • 将关键点表示在 Unity 的空间内,并且确保上下、左右、前后方向与 Unity 世界坐标一致
  • 对于 Hips 骨骼,选取双肩和双髋构成的平面来描述其朝向
  • 从这些关键点中提取 Z 轴朝向(forward)和 Y 轴朝向(up)
  • 将 forward 和 up 传入 LookRotation 得到 Hips 骨骼的旋转

其中后三步是后面所有骨骼的通用模式。不同骨骼之间的差异,只体现在关键点的选择以及方向的构造方式上。

接下来用同样的方法控制 Chest。与 Hips 类似,都是通过“肩部与髋部”之间的空间关系来确定朝向,只是参考点的组合略有不同。Hips 由双髋和肩部中心确定,而 Chest 则由双肩和髋部中心确定。两者的 Y 轴一致,均为髋部中心指向肩部中心,而 X 轴则改为由左肩指向右肩。

Vector3 chestXDir = (landmark[12] - landmark[11]).normalized;
Vector3 chestYDir = (shoulderCenter - hipCenter).normalized;  // 与 Hips 共享同一个 Y
Vector3 chestZDir = Vector3.Cross(chestXDir, chestYDir).normalized;

_chest.rotation = Quaternion.LookRotation(chestZDir, chestYDir);C#

四肢

MediaPipe 的四肢关键点在每一段上只提供两个点,只能确定骨骼的延伸方向。而 LookRotation 需要两个方向来唯一确定旋转。仅有一条方向时,骨骼仍可以绕该轴自由旋转,其姿态并不唯一。

为了解决这一问题,需要额外引入一个参考方向,并与延伸方向做叉乘,构造出第二个轴,从而确定完整的旋转。

右手臂

观察模型可以发现,右侧 UpperArm 骨骼沿本地 X 轴正方向延伸。

右上臂沿本地 X 轴延伸
右上臂骨骼沿本地 X 轴正方向延伸

在关键点中,上臂的延伸方向由 12(肩)指向 14(肘)确定,将其作为 rUpperArmXDir。再引入世界坐标的 up 作为参考方向,通过两次叉乘构造出完整的旋转值,

使用世界 up 作为参考方向
关键点决定 X 延伸,并引入世界 up 作为参考方向
Vector3 rUpperArmXDir = (landmark[14] - landmark[12]).normalized;  // 肩→肘
Vector3 rUpperArmZDir = Vector3.Cross(rUpperArmXDir, Vector3.up).normalized;
Vector3 rUpperArmYDir = Vector3.Cross(rUpperArmZDir, rUpperArmXDir).normalized;
_rightUpperArm.rotation = Quaternion.LookRotation(rUpperArmZDir, rUpperArmYDir);C#

不过,当 rUpperArmXDir 与 Vector3.up 接近平行时,叉乘会退化为零向量。这种情况通常出现在手臂上举或下垂时。

为避免退化,需要在接近竖直时替换参考方向。通过观察模型可以发现,

手臂上举(rUpperArmXDir ≈ Vector3.up)时,骨骼的 Y 轴指向世界 -X(身体左侧),

手臂上举时的 Y 轴朝向
手臂上举时,骨骼 Y 轴指向世界 -X(身体左侧)

手臂下垂(rUpperArmXDir ≈ -Vector3.up)时,骨骼的 Y 轴指向世界 +X(身体右侧),

手臂下垂时的 Y 轴朝向
手臂下垂时,骨骼 Y 轴指向世界 +X(身体右侧)

因此,可以通过点积判断当前方向是否接近与 Vector3.up 平行;若是,则根据手臂的朝向,将参考方向替换为 -Vector3.right 或 +Vector3.right,以避免叉乘退化。

Vector3 aux = Mathf.Abs(Vector3.Dot(rUpperArmXDir, Vector3.up)) < 0.99f
    ? Vector3.up : (rUpperArmXDir.y > 0 ? -Vector3.right : Vector3.right);
Vector3 rUpperArmZDir = Vector3.Cross(rUpperArmXDir, aux).normalized;
Vector3 rUpperArmYDir = Vector3.Cross(rUpperArmZDir, rUpperArmXDir).normalized;C#

右下臂由肘(14)→腕(16)确定方向,逻辑和上臂完全一样,只换关键点。

Vector3 rLowerArmXDir = (landmark[16] - landmark[14]).normalized;  // 肘→腕
Vector3 aux = Mathf.Abs(Vector3.Dot(rLowerArmXDir, Vector3.up)) < 0.99f
    ? Vector3.up : (rLowerArmXDir.y > 0 ? -Vector3.right : Vector3.right);
Vector3 rLowerArmZDir = Vector3.Cross(rLowerArmXDir, aux).normalized;
Vector3 rLowerArmYDir = Vector3.Cross(rLowerArmZDir, rLowerArmXDir).normalized;
_rightLowerArm.rotation = Quaternion.LookRotation(rLowerArmZDir, rLowerArmYDir);C#

左手臂

观察左上臂的 Transform Gizmo 可以发现,其 X 轴正方向指向左侧,与骨骼的延伸方向(肩→肘)相反。因此在构造 xDir 时,需要将方向反转,即从肘指向肩,

左上臂 X 轴方向与骨骼延伸方向相反
左上臂本地 X 轴与骨骼延伸方向相反,构造 xDir 时需要取反
// 左上臂: 肘(13)→肩(11)
Vector3 lUpperArmXDir = (landmark[11] - landmark[13]).normalized;

// 左下臂: 腕(15)→肘(13)
Vector3 lLowerArmXDir = (landmark[13] - landmark[15]).normalized;C#

参考方向同样使用世界空间的 up。在接近竖直时,退化处理与右手臂保持一致。观察可知,

  • 左臂上举时,lUpperArmXDir 接近 -Vector3.up,骨骼的 Y 轴指向世界 +X(身体右侧)
  • 左臂下垂时,lUpperArmXDir 接近 +Vector3.up,骨骼的 Y 轴指向世界 -X(身体左侧)

lUpperArmXDir.y > 0 ? -Vector3.right : Vector3.right 自动覆盖了这两种情况。后续的 aux、zDir、yDir 以及 LookRotation 计算流程与右手臂完全一致,无需做额外修改。

右腿

腿部和手臂的处理方式不同。观察右大腿骨骼可以发现,骨骼沿本地 Y 轴负方向延伸(从髋向膝盖,向下),同时本地 X 轴指向身体右侧。

右大腿沿本地 Y 轴负方向延伸
右大腿沿本地 Y 轴负方向延伸,本地 X 轴指向身体右侧

据此可以从关键点中提取两个方向,

  • landmark 23→24(左髋到右髋),作为 X 方向
  • landmark 26→24(膝盖到髋),作为 Y 方向
Vector3 legXDir = (landmark[24] - landmark[23]).normalized;  // 左髋→右髋(左右腿共用)
Vector3 rLegYDir = (landmark[24] - landmark[26]).normalized;  // 膝→髋
Vector3 rLegZDir = Vector3.Cross(legXDir, rLegYDir).normalized;
_rightUpperLeg.rotation = Quaternion.LookRotation(rLegZDir, rLegYDir);C#

这里没有像手臂那样处理退化情况,是因为 legXDir(水平左右)与 rLegYDir(竖直向上)天然接近正交。只有当腿完全水平指向正左或正右时才可能接近平行,而在实际捕捉中几乎不会出现。

右小腿的处理方式相同,由膝(26)指向踝(28)确定延伸方向,X 轴继续沿用 legXDir,

Vector3 rShinYDir = (landmark[26] - landmark[28]).normalized;  // 踝→膝
Vector3 rShinZDir = Vector3.Cross(legXDir, rShinYDir).normalized;
_rightLowerLeg.rotation = Quaternion.LookRotation(rShinZDir, rShinYDir);C#

右脚的处理方式不同。观察右脚可以看到,骨骼沿本地 Z 轴正方向延伸(踝指向脚尖,向前),而不是像大腿小腿那样沿本地 Y 轴。

右脚沿本地 Z 轴正方向延伸
脚骨骼沿本地 Z 轴正方向延伸,与大腿小腿不同

所以 zDir 直接来自关键点,配合 legXDir,就能叉乘得到 yDir。

Vector3 rFootZDir = (landmark[32] - landmark[28]).normalized;  // 踝→脚尖
Vector3 rFootYDir = Vector3.Cross(rFootZDir, legXDir).normalized;
_rightFoot.rotation = Quaternion.LookRotation(rFootZDir, rFootYDir);C#

左腿

左腿和右腿完全对称,X 方向继续复用右腿中定义的 legXDir。唯一的区别是关键点索引,从 24/26/28/32 换成了 23/25/27/31。

  • 左大腿,髋(23)→膝(25),Y 正向 = 膝→髋 = landmark[23] - landmark[25]
  • 左小腿,膝(25)→踝(27),Y 正向 = 踝→膝 = landmark[25] - landmark[27]
  • 左脚,踝(27)→脚尖(31),Z 正向 = landmark[31] - landmark[27]
// 左大腿
Vector3 lLegYDir = (landmark[23] - landmark[25]).normalized;
Vector3 lLegZDir = Vector3.Cross(legXDir, lLegYDir).normalized;
_leftUpperLeg.rotation = Quaternion.LookRotation(lLegZDir, lLegYDir);

// 左小腿
Vector3 lShinYDir = (landmark[25] - landmark[27]).normalized;
Vector3 lShinZDir = Vector3.Cross(legXDir, lShinYDir).normalized;
_leftLowerLeg.rotation = Quaternion.LookRotation(lShinZDir, lShinYDir);

// 左脚
Vector3 lFootZDir = (landmark[31] - landmark[27]).normalized;
Vector3 lFootYDir = Vector3.Cross(lFootZDir, legXDir).normalized;
_leftFoot.rotation = Quaternion.LookRotation(lFootZDir, lFootYDir);C#

头部

我们将 face landmark 显示在 Unity 空间中。和 pose landmark 部分一致,将部分点标记为红色,用于辅助区分上下与左右关系。

通过实际转动头部可以观察到,相比 pose landmark,face landmark 在姿态变化时响应更充分且更贴近真实运动。因此,这里使用 face landmark 来驱动头部旋转。

face landmark 在头部转动时的响应比 pose landmark 更充分

观察模型头部骨骼可以发现,其本地 X 轴指向身体右侧,本地 Y 轴指向上方。与 Hips 的处理方式类似,可以从面部关键点中选取四个点来构造局部坐标系,

  • 前额(10,图中脸部上方的黄色点)与下巴(152,图中脸部下方的红色点)用于确定纵向
  • 右眼外角(33,图中脸部右侧的黄色点)与左眼外角(263,图中脸部左侧的红色点)用于确定横向
四点构造头部坐标系
前额、下巴与两眼外角四个点确定头部的纵向与横向

据此提取两个方向,

  • headYDir,下巴指向前额
  • headXDir,左眼外角指向右眼外角

再通过叉乘得到第三个轴,并传入 LookRotation。

Vector3 headYDir = (faceLandmarks[10] - faceLandmarks[152]).normalized;
Vector3 headXDir = (faceLandmarks[33] - faceLandmarks[263]).normalized;
Vector3 headZDir = Vector3.Cross(headXDir, headYDir).normalized;

_head.rotation = Quaternion.LookRotation(headZDir, headYDir);C#

这样即可得到头部的完整朝向。

在实际应用中,也可以将部分旋转分配到 neck 骨骼上,以获得更自然的过渡效果,这里不再展开。

手部

我们将 hand landmark 显示在 Unity 空间中,并将左手标记为红色,方便区分左右。

hand landmark 在手掌朝向上的追踪更准确,并提供完整的手指关节信息

可以看到,与 pose landmark 相比,hand landmark 在手掌朝向上的追踪更加准确,同时还提供完整的手指关节信息。因此,我们使用 hand landmark 数据来驱动手部姿态。

为便于后续说明,下图给出了手部关键点的索引。

MediaPipe hand landmark
MediaPipe Hand 的 21 个关键点及其索引

https://github.com/google-ai-edge/mediapipe/blob/master/docs/solutions/hands.md

右手掌

观察模型可以发现,右手掌与手臂一致,沿本地 X 轴正方向延伸。

右手掌沿本地 X 轴正方向延伸
右手掌骨骼沿本地 X 轴正方向延伸

从手部关键点的分布可以看到,腕部(0)、食指根(5)和小指根(17)三个点构成一个三角形,基本覆盖了整个手掌平面。

基于这三个点,先构造两个方向,

  • toIndex,腕→食指根
  • toPinky,腕→小指根

由这两个方向可以进一步得到,

handYDir,为 toIndex 和 toPinky 的叉乘结果,垂直于手掌平面,指向手背方向。

另外,我们使用手腕指向中指根(0→9)的方向作为手掌的延伸方向

Vector3 rToIndex = (handLandmarks[5] - handLandmarks[0]).normalized;
Vector3 rToPinky = (handLandmarks[17] - handLandmarks[0]).normalized;
Vector3 rHandYDir = Vector3.Cross(rToIndex, rToPinky).normalized;
Vector3 rHandXDir = (handLandmarks[9] - handLandmarks[0]).normalized;  // 腕→中指根C#

再由 rHandXDir 与 rHandYDir 叉乘得到第三个轴,并构造旋转,

Vector3 rHandZDir = Vector3.Cross(rHandXDir, rHandYDir).normalized;
_rightHand.rotation = Quaternion.LookRotation(rHandZDir, rHandYDir);C#

这样我们便能控制手掌朝向。

右手四指

观察可以发现,除拇指外的四根手指,其骨骼与手掌一致,均沿本地 X 轴方向延伸。每一段骨骼都可以通过相邻两个关键点确定其延伸方向。

四指沿本地 X 轴延伸
除拇指外的四根手指骨骼均沿本地 X 轴延伸

在确定了手指的延伸方向 rFingerXDir 之后,还需要再确定另外两个正交方向,才能构造完整的旋转。

一种自然的思路是直接复用手掌的坐标系。

首先可以考虑使用手掌的 Y 轴,即掌面指向掌背的方向。但在手指弯曲幅度较大时,手指的局部朝向会逐渐偏离手掌平面,此时手指的 Y 方向可能与手掌 Y 轴不再一致,甚至出现反转,从而导致姿态不稳定。

手指弯曲幅度大时 Y 轴反转
手指弯曲幅度大时,手指 Y 方向可能偏离手掌 Y 轴甚至反转

另一种思路是使用手掌的 Z 轴,即将 rHandZDir 作为手指的 Z 方向。该方向在手部运动过程中相对稳定,但如果直接使用,由于 LookRotation 会严格对齐 forward,当所有手指共用同一个 rHandZDir 时,其 Z 轴会被锁定,无法表达手指之间的张开角度。例如下图中,关键点描绘的手指是张开的,但模型中的手指却保持并拢。

共用 Z 轴导致手指无法张开
所有手指共用 rHandZDir 时,无法表达手指间的张开角度

为了解决这个问题,需要为每根手指单独计算其 Z 轴方向。

这里利用叉乘的一个重要性质,结果向量同时垂直于两个输入向量。

当前我们已知两个方向,

  • 手掌提供的 rHandZDir
  • 手指延伸方向 rFingerXDir
已知 rHandZDir 和 rFingerXDir
已知手掌的 rHandZDir 与手指的 rFingerXDir 两个方向

第一步,rHandZDir 和 rFingerXDir 叉乘出 rFingerYDir,它同时垂直于两者。

叉乘得到 rFingerYDir
第一次叉乘得到 rFingerYDir,同时垂直于两个输入向量

第二步,rFingerXDir 和 rFingerYDir 叉乘出 rFingerZDir,这样构造出的三轴两两正交。

再次叉乘得到 rFingerZDir
第二次叉乘得到 rFingerZDir,三轴两两正交

把 rFingerZDir 和 rFingerYDir 传入 LookRotation,此时,骨骼的 X 轴会精确对齐 rFingerXDir。

最终骨骼 X 轴精确对齐 rFingerXDir
LookRotation 使骨骼 X 轴精确对齐 rFingerXDir

几何上,新算的 rFingerZDir 相当于把 rHandZDir 投影到以 rFingerXDir 为法线的平面上。rFingerZDir 会随手指张开角度同步变化。

下面的代码以食指根关节为例(5→6)。

Vector3 rFingerXDir = (handLandmarks[6] - handLandmarks[5]).normalized;
Vector3 rFingerYDir = Vector3.Cross(rHandZDir, rFingerXDir).normalized;
Vector3 rFingerZDir = Vector3.Cross(rFingerXDir, rFingerYDir).normalized;
fingerBone.rotation = Quaternion.LookRotation(rFingerZDir, rFingerYDir);C#

如下图所示,在为每根手指分别计算 Z 轴后,可以正确表现手指张开的角度。

单独计算 Z 轴后手指可以张开
为每根手指分别计算 Z 轴后,手指之间可以张开

右手拇指

拇指的情况较为特殊。

从模型结构可以看出,拇指骨骼的本地三轴与模型网格的实际延伸方向并不对齐。也就是说,本地坐标系中没有任何一根轴可以直接作为“骨骼延伸方向”。因此,如果直接沿用四指的构造方式,最终结果会出现明显偏移。

拇指本地轴与网格不对齐
拇指骨骼的本地三轴与网格实际延伸方向不对齐

进一步观察可以发现,将拇指骨骼绕世界 Y 轴旋转约 -40 度后,其本地 +X 轴能够与拇指网格的实际延伸方向对齐。

绕 Y 轴旋转 -40 度后对齐网格
绕世界 Y 轴旋转约 -40 度后,本地 +X 与拇指网格方向对齐

基于这一点,计算流程可以分为两步。首先按照四指的方法,通过关键点构造拇指的局部坐标系,得到理想状态下的旋转。然后在此基础上叠加一个绕 Y 轴 +40 度的补偿,将结果映射回模型实际使用的坐标系。

// 右手拇指三段共用(idx = 1, 2, 3 对应近端、中节、末节)
Vector3 rThumbXDir = (handLandmarks[idx + 1] - handLandmarks[idx]).normalized;
Vector3 rThumbYDir = Vector3.Cross(rHandZDir, rThumbXDir).normalized;
Vector3 rThumbZDir = Vector3.Cross(rThumbXDir, rThumbYDir).normalized;
thumbBone.rotation = Quaternion.LookRotation(rThumbZDir, rThumbYDir) * Quaternion.Euler(0f, 40f, 0f);C#

这里的“绕 Y 轴 +40 度”先作为一个固定补偿使用,后文会给出更通用的求解方法。

左手

和左右手臂一样,左手的 lHandXDir 和每段 lFingerXDir 需要取反。此外,左手的 lHandYDir 叉乘顺序也要反转,从 Cross(rToIndex, rToPinky) 变为 Cross(lToPinky, lToIndex),因为左右手是镜像关系,食指和小指的空间排列相反,同样的叉乘顺序会得到反方向的法线。

至此我们已经完成了从关键点到骨骼旋转的全部映射。

完成全部骨骼旋转映射
完成全部骨骼旋转映射后的效果

小结

可以看到,所有骨骼都遵循同一套思路。先观察骨骼在模型中的轴向,确定其沿哪一条本地轴延伸,例如右臂沿 X 正方向,腿部沿 Y 负方向,脚部沿 Z 正方向。随后围绕该延伸方向,通过叉乘构造其余两个正交轴,并最终得到对应的旋转。

这一方法能够成立,依赖两个前提条件。其一,骨骼坐标轴与 Unity 世界坐标轴对齐。其二,模型网格的延伸方向与某一条本地轴一致。拇指只满足前者而不满足后者,因此需要额外补偿。而在实际项目中,模型往往无法满足这两个条件,直接套用上述方法就可能得到错误结果。

任意模型套用方法时出现错误
骨骼坐标轴或网格方向不满足前提时,直接套用会得到错误结果

接下来将进一步讨论,在这些前提不成立的情况下,如何将这一方法扩展到任意模型。

适配任意模型

在实际模型中,应用上述方法时需要解决“轴不对齐”和“模型网格不沿本地轴”两个问题,对应地引入两个补偿量。

轴不对齐

可以在初始化时记录每根骨骼的世界旋转 initRot。它描述了骨骼从本地空间到世界空间的固定变换。

Quaternion initRot = bone.rotation;C#

运行时,通过 LookRotation(zDir, yDir) 得到的是世界空间中的目标旋转。将其与 initRot 相乘,就相当于把这个旋转叠加到 T-pose 上,得到当前骨骼的世界旋转。

bone.rotation = Quaternion.LookRotation(zDir, yDir) * initRot;C#

模型网格不沿本地轴

这种情况在拇指中已经出现过。本质是骨骼的本地轴与模型网格的实际延伸方向存在一个固定偏差。解决方法是在初始化时构造一个参考旋转 initAxisRot,使骨骼轴对齐到网格方向。计算出骨骼轴朝向后,再将这段变换抵消,即可得到正确的网格朝向。

其中,

  • 骨骼轴,根据骨骼的排列方向选取对齐轴。对于纵向排列的骨骼,如 Hips、Chest、腿部、头部,使用 Y 轴。对于横向排列的骨骼,如手臂、手指,使用 X 轴。对于深度方向排列的骨骼,如脚部,使用 Z 轴。
  • 网格方向,对于存在子骨骼的情况,取当前骨骼指向子骨骼的方向。若不存在子骨骼,则假设其与上一节保持一致。

以右手拇指根关节为例,我们使用 RightThumbProximal→RightThumbIntermediate 的方向作为 X 轴的目标方向。对应的 initAxisRot 可以这样构造,

// Start() 中捕获一次
Transform thumbProx = animator.GetBoneTransform(HumanBodyBones.RightThumbProximal);
Transform thumbInt  = animator.GetBoneTransform(HumanBodyBones.RightThumbIntermediate);

Vector3 rHandZDir_T = ...;  // 初始化时的手掌法线,和运行时 rHandZDir 同一个构造

Vector3 rThumbXDir_T = (thumbInt.position - thumbProx.position).normalized;  // mesh 方向
Vector3 rThumbYDir_T = Vector3.Cross(rHandZDir_T, rThumbXDir_T).normalized;
Vector3 rThumbZDir_T = Vector3.Cross(rThumbXDir_T, rThumbYDir_T).normalized;

Quaternion initAxisRot = Quaternion.LookRotation(rThumbZDir_T, rThumbYDir_T);C#

运行时仍按常规方式计算目标旋转,但需要将这段变换抵消,也就是右乘其逆,

// LateUpdate 中,每帧
Vector3 rThumbXDir = (thumbLandmarkInt - thumbLandmarkProx).normalized;
Vector3 rThumbYDir = Vector3.Cross(rHandZDir, rThumbXDir).normalized;
Vector3 rThumbZDir = Vector3.Cross(rThumbXDir, rThumbYDir).normalized;

thumbProx.rotation =
    Quaternion.LookRotation(rThumbZDir, rThumbYDir) *
    Quaternion.Inverse(initAxisRot);C#

这样,LookRotation 得到的是“理想骨骼轴”的朝向,而乘上 Inverse(initAxisRot) 后,就将其转换回与实际模型网格对齐的结果。

另外,按上述方法计算得到的右手拇指 initAxisRot 为(-1,-38,-11),与前文通过观察得到的结果基本一致。

合并

将“轴不对齐”和“模型网格不沿本地轴”这两个补偿统一起来,可以得到一条适用于任意模型、任意骨骼的通用公式,

bone.rotation =
    Quaternion.LookRotation(zDir, yDir) *
    Quaternion.Inverse(initAxisRot) * initRot;C#

其中,

  • initRot 用于补偿骨骼初始姿态与世界坐标轴不对齐的问题
  • initAxisRot 用于补偿网格实际延伸方向与骨骼本地轴不一致的问题

在实现时可以进一步优化。由 Quaternion.Inverse(initAxisRot) * initRot 在初始化完成后对每根骨骼来说都是常量,因此可以预先计算并缓存,

Quaternion compensation = Quaternion.Inverse(initAxisRot) * initRot;  // 预计算

bone.rotation = Quaternion.LookRotation(zDir, yDir) * compensation;  // 每帧C#

现在,我们的方法可以推广到任意模型与任意骨骼的旋转求解。

任意模型适配结果
加入两个补偿后,方法可推广到任意模型与任意骨骼

另一方面,由于 initAxisRot 和 initRot 都是初始化时从同一姿态下捕获的,两者随姿态同步变化,使得姿态相关的部分会自动抵消,只保留骨骼本身的几何偏差,因此模型在初始化时并不要求严格处于 T-pose。

处理初始旋转

在最初推导中,我们假设角色的初始旋转为 identity,即模型未发生旋转。而在实际项目中,角色通常会以特定朝向放置在场景中。如果直接使用上述公式,会使驱动结果仍处于世界坐标系下,偏离原设定朝向。

解决方法是在最终结果前引入角色的初始世界旋转。

在初始化时记录,

Quaternion rootInit = transform.rotation;  // Start 时一次性捕获C#

运行时将其作为前缀,

bone.rotation = rootInit * Quaternion.LookRotation(zDir, yDir) * compensation;C#

其中,Quaternion.LookRotation(zDir, yDir) * compensation 表示在世界坐标系下计算得到的目标姿态,而 rootInit 将该姿态整体映射到角色初始朝向对应的坐标系中,使驱动结果与角色当前朝向保持一致。

处理初始旋转后的效果
引入 rootInit 后,驱动结果与角色初始朝向保持一致

至此,我们完成了从关键点到骨骼旋转的推导,并使其能够适配任意模型与任意初始朝向。

局限和改进

本文介绍的是基于关键点驱动骨骼旋转的基础方案。在实际应用中,仍有多个方向可以进一步改进,下面列举几个典型问题。

腰部位移

MediaPipe 的 pose world landmark 以双髋中点为原点,因此 Hips 的坐标始终为 (0, 0, 0),无法直接获得角色的绝对位移,

若需要恢复位移信息,可以通过其他线索进行估计,例如,

  • 利用全身关键点的整体高度变化推断垂直位移
  • 结合 2D 归一化坐标中 Hips 的图像位置变化估计移动

数据抖动

即使激活 MediaPipe 的数据平滑功能,实际使用中仍然可以观察到明显抖动。

常见的改进方式包括,

  • 对关键点位置在计算旋转之前进行时间平滑,从输入侧降低噪声
  • 在旋转空间中进行插值,使输出更加连续

四肢沿长轴的转动

在前文中,手臂和腿部的旋转通过辅助方向(如世界 up 或髋宽方向)来构造。这种方式能够确定骨骼的指向(例如肩→肘的方向),但无法表达骨骼绕自身长轴的转动。

同时,手掌或脚部的翻转被集中施加在末端骨骼(Hand 或 Foot)上,而下臂或小腿未参与分担旋转,容易导致末端骨骼出现过度扭转,视觉上不自然。

主要改进方向有,

  • 使用更贴合局部运动的辅助方向,例如基于关节弯曲平面构造法线,从而更准确地反映骨骼的实际旋转
  • 将末端的扭动按比例分配到相邻骨骼(如下臂或小腿),以获得更均匀、自然的变形效果

使用示例代码

示例项目地址,

https://github.com/SunnyViewTech/MoCapLite

步骤

1. 下载 MediaPipeUnity.0.16.3.unitypackage 并导入项目。

https://github.com/homuler/MediaPipeUnityPlugin/releases/tag/v0.16.3

2. 使用 MoCapLite 中的 HolisticTrackingSolution.cs 替换以下路径中的同名文件,

\Assets\MediaPipeUnity\Samples\Scenes\Legacy\Holistic

3. 打开示例场景,

\Assets\MediaPipeUnity\Samples\Scenes\Legacy\Holistic\Holistic.unity

4. 将 Running Mode 设置为 Sync。

设置 Running Mode 为 Sync
将 Running Mode 设置为 Sync

5. 导入角色模型,并将其 Rig 类型设置为 Humanoid。

设置 Rig 类型为 Humanoid
将模型的 Rig 类型设置为 Humanoid

6. 为模型添加 AvatarController 组件(可选添加 LandmarkGizmos 以便调试)。

添加 AvatarController 组件
为模型添加 AvatarController 组件

7. 运行场景,即可看到动捕驱动效果。

动捕驱动效果
运行场景后的动捕驱动效果

说明

该示例代码用于演示从关键点到骨骼旋转的基本流程。其中,关于初始旋转的处理采用了更通用的实现方式,而针对模型不沿本地轴的部分,仅提供了一种可行实现,并非最优方案。

资源

以下是与本文主题相关的一些开源项目,可供参考,

https://github.com/yeemachine/kalidokit

https://github.com/digital-standard/ThreeDPoseUnityBarracuda

文中使用到的视频资源

https://pexels.com/video/woman-in-black-activewear-doing-leg-and-hip-exercise-5510143

https://pexels.com/video/young-man-practicing-break-dance-5363330