用 MediaPipe 驱动 3D 角色

从关键点到骨骼旋转

MediaPipe 等姿态估计工具可以从图像或摄像头画面中提取人体关键点,但 3D 角色需要的是骨骼旋转,而不是原始关键点坐标。本文将介绍如何在 Unity 中完成这个转换,并说明它如何作为一个轻量级动捕流程的一部分。

两类姿态估计方案

基于关键点

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

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

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

基于参数化人体模型

这类方案直接输出参数化人体模型的参数,包括每个关节的旋转和体型参数。SMPL 是这类模型中常用的一个例子,但这一思路并不局限于 SMPL。代表性的方案有,

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

参数化人体模型方法的输出已经包含关节旋转,因此通常可以直接映射到骨骼系统,而不需要额外的关键点到旋转的转换。SMPL 是这个方向中常见的模型家族。值得注意的是,SMPL 背后的 Meshcapade 近期已被 Epic Games 收购,未来 SMPL 相关技术有望在虚幻引擎中得到更深入的整合。

两者的比较

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

从关键点到骨骼旋转

接下来我们聚焦核心转换,也就是如何从已有关键点中,为骨骼的每个部分推导出可用的骨骼旋转。

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

在这种情况下,一个自然的想法是直接使用 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 的朝向

在代码中,我们将这两个方向表示为 hipXDir 和 hipYDir。

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 朝向始终与关键点保持一致

Hips 的旋转可以拆解为四个步骤。

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

这个模式会在整个骨骼中反复出现。不同骨骼之间的差异,主要体现在关键点的选择以及参考方向的构造方式上。

Chest 是下一个例子。与 Hips 类似,它的朝向同样来自肩部与髋部之间的空间关系,只是参考点的组合略有不同。Hips 由双髋和肩部中心确定,而 Chest 则由双肩和髋部中心确定。两者的 Y 轴一致,均为髋部中心指向肩部中心,而 Chest 的 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,腕→小指根

接下来,对 toIndex 和 toPinky 做叉乘可以得到 handYDir,它指向手掌平面外侧,也就是手背方向。

另外,我们使用手腕指向中指根(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 确定了手指的延伸方向,但 LookRotation 仍然需要两个方向才能完整定义旋转。

一种自然的初步想法是复用手掌的坐标系。

例如,可以考虑使用手掌的 Y 轴,也就是掌面指向手背的方向。但这种方法只在手指接近手掌平面时比较合适。一旦手指弯曲幅度较大,手指的局部朝向会逐渐偏离该平面,此时手指的 Y 方向可能不再匹配手掌的 Y 轴,甚至出现反转,从而导致姿态不稳定。

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

另一种思路是使用手掌的 Z 轴,将 rHandZDir 作为每根手指的 Z 方向。这个方向在手部运动过程中相对稳定,但也会把所有手指锁定到同一个 forward 方向。结果是模型无法表现手指之间的张开角度。例如下图中,关键点描绘的手指是张开的,但模型中的手指却保持并拢。

共用 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

这样,每根手指的 Z 方向仍然基于手掌方向,但也可以随着手指张开角度变化。

下面的代码以食指根关节为例(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 轴能够与拇指网格的实际延伸方向对齐。完成这个对齐后,就可以像处理其他手指一样,将拇指的本地 +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#

这里的 +40 度是针对当前模型选取的固定补偿。后文会给出更通用的方法来计算这类补偿。

左手

左手与右手互为镜像,因此 lHandXDir 和每段 lFingerXDir 都需要取反。lHandYDir 的叉乘顺序也需要反转,从 Cross(rToIndex, rToPinky) 变为 Cross(lToPinky, lToIndex),因为左手上食指和小指的空间排列顺序相反。

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

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

小结

同样的思路可以应用到整个骨骼。对于每根骨骼,首先观察模型,确定哪条本地轴跟随骨骼的延伸方向。然后选择能够描述同一延伸方向的关键点。最后通过叉乘构造剩余的轴,并用它们构建旋转。

这个方法能在示例模型上成立,是因为它的骨骼轴大多与 Unity 世界轴对齐,并且大多数网格段都沿着某条骨骼本地轴延伸。拇指是一个例外,它的骨骼轴是对齐的,但网格并不沿本地轴延伸,所以需要额外补偿。

实际项目中的模型经常不满足这些前提。直接套用上述方法,可能得到错误结果。

任意模型套用方法时出现错误
骨骼和网格方向不同的模型上的错误结果

接下来,我们将扩展这个方法,使其能够处理任意骨骼轴和网格方向。

支持任意模型

为了支持不同模型,需要处理两类不匹配。骨骼轴可能与参考坐标系不一致,网格也可能不沿所选的骨骼本地轴延伸。我们用两个补偿项来处理这些问题。

轴不对齐

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

Quaternion initRot = bone.rotation;C#

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

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

网格方向偏移

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

对于这个补偿,需要决定两件事。首先,选择哪条骨骼本地轴代表骨骼的延伸方向。然后,选择这条轴需要对齐到的网格方向。

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

以右手拇指根关节为例,X 轴应该跟随 RightThumbProximal 指向 RightThumbIntermediate 的方向。根据这个参考方向,可以这样构造 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),与前文通过观察得到的结果基本一致。

需要说明的是,这里介绍的网格方向补偿是一种实用方案,但不一定是最优方案。

合并

现在可以将两个补偿项合并起来。LookRotation(zDir, yDir) 根据关键点构建旋转,initAxisRot 修正网格方向偏移,initRot 则恢复骨骼原本的轴向对齐关系。

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

在这个公式中,每一项都有固定作用。

  • initAxisRot 用于补偿网格延伸方向与所选骨骼本地轴不一致的问题
  • initRot 用于补偿骨骼初始轴向与参考坐标系不一致的问题

补偿部分在初始化后保持不变,因此可以为每根骨骼预先计算并缓存。

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

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

有了这个缓存后的补偿,同一套旋转求解逻辑就可以应用到不同模型和不同骨骼上。

任意模型适配结果
同一套旋转方法可以应用到不同模型上

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

处理初始旋转

在文章开头,我们将模型放在 Unity 世界原点,并保持其旋转为 identity。前面的推导都基于这个前提。而在实际项目中,角色通常会以特定朝向放置在场景中。如果直接使用上述公式,驱动结果仍会停留在世界坐标系下,从而偏离角色原本设定的朝向。

为了解决这个问题,可以在初始化时记录角色的初始世界旋转。

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

运行时,将 rootInit 作为前缀应用到计算得到的骨骼旋转上。

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 这样的末端骨骼上,而下臂或小腿等相邻骨骼没有分担足够的旋转。这会让变形看起来不自然。

可能的改进方向包括,

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

以上就是将 MediaPipe 关键点转换为 3D 角色骨骼旋转的主要思路。下面的示例项目展示了如何在 Unity 中把完整流程组合起来。

使用示例代码

示例项目

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