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 轴正方向为角色右侧。

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

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

https://github.com/google-ai-edge/mediapipe/blob/master/docs/solutions/pose.md
通过观察可以发现,Hips 的朝向可以由四个关键点确定,X 轴对应左髋指向右髋的方向,Y 轴对应髋部中心指向肩部中心的方向。

在代码中,我们将这两个方向表示为 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 的旋转可以拆解为四个步骤。
- 将关键点表示在 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 轴正方向延伸。

在关键点中,上臂的延伸方向由 12(肩)指向 14(肘)确定,将其作为 rUpperArmXDir。再引入世界坐标的 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(身体左侧),

手臂下垂(rUpperArmXDir ≈ -Vector3.up)时,骨骼的 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 时,需要将方向反转,即从肘指向肩,

// 左上臂: 肘(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 轴指向身体右侧。

据此可以从关键点中提取两个方向,
- 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 轴。

所以 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 来驱动头部旋转。
在模型中,头部骨骼的本地 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 空间中,并将左手标记为红色,方便区分左右。
如视频所示,与 pose landmark 相比,hand landmark 在手掌朝向上的追踪更加准确,同时还提供完整的手指关节信息。因此,我们使用 hand landmark 数据来驱动手部姿态。
为便于后续说明,下图给出了手部关键点的索引。

https://github.com/google-ai-edge/mediapipe/blob/master/docs/solutions/hands.md
右手掌
观察模型可以发现,右手掌与手臂一致,沿本地 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 轴方向延伸。每一段骨骼都可以通过相邻两个关键点确定其延伸方向。

rFingerXDir 确定了手指的延伸方向,但 LookRotation 仍然需要两个方向才能完整定义旋转。
一种自然的初步想法是复用手掌的坐标系。
例如,可以考虑使用手掌的 Y 轴,也就是掌面指向手背的方向。但这种方法只在手指接近手掌平面时比较合适。一旦手指弯曲幅度较大,手指的局部朝向会逐渐偏离该平面,此时手指的 Y 方向可能不再匹配手掌的 Y 轴,甚至出现反转,从而导致姿态不稳定。

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

因此,每根手指都需要自己的 Z 轴方向。
关键在于叉乘。给定两个输入方向,叉乘可以得到一个同时正交于二者的第三个方向。
现在我们已经有两个有用的方向。
- 手掌提供的 rHandZDir
- 手指延伸方向 rFingerXDir

第一步,对 rHandZDir 和 rFingerXDir 做叉乘,得到 rFingerYDir。

第二步,对 rFingerXDir 和 rFingerYDir 做叉乘,得到 rFingerZDir,从而完成手指局部坐标系的构造。

把 rFingerZDir 和 rFingerYDir 传入 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 轴后,可以正确表现手指张开的角度。

右手拇指
拇指的情况较为特殊。
从模型结构可以看出,拇指骨骼的本地三轴与模型网格的实际延伸方向并不对齐。也就是说,本地坐标系中没有任何一根轴可以直接作为“骨骼延伸方向”。因此,如果直接沿用其他手指的构造方式,最终结果会不正确。

在参考姿态下,将拇指骨骼绕世界 Y 轴旋转约 -40 度后,其本地 +X 轴能够与拇指网格的实际延伸方向对齐。完成这个对齐后,就可以像处理其他手指一样,将拇指的本地 +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 后,会将该姿态映射到角色初始朝向定义的坐标系中,使驱动结果与角色保持对齐。

至此,我们得到了一套完整的关键点到骨骼旋转流程,可以处理不同模型设置和不同初始朝向。
局限和改进
本文介绍的是一种从关键点推导骨骼旋转并驱动骨骼的基础方案。它可以作为一个简单动捕原型的核心,但仍有多个部分可以继续改进。
腰部位移
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。

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

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

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