1502 字
8 分钟
骨骼动画与蒙皮——角色如何动起来

一、骨骼、网格体、蒙皮——三个独立的概念#

一个会动的角色由三层组成:

骨骼(Skeleton) → 层次化的"骨头"结构,定义运动学关系
网格体(Mesh) → 顶点、三角面——角色的"皮肤"
蒙皮(Skinning) → 连接骨骼和网格体的"胶水"——每个顶点被哪些骨头影响

关键认知:骨骼和网格体是分开的。骨骼定义了动作(旋转、平移),网格体只是跟着变形。同一套骨骼可以配不同的身体(换个皮肤、换套衣服),同一套骨骼也可以配不同的动画(走、跑、跳)。


二、骨骼的层次结构#

骨骼不是散装的——是父子层次树:

Root (Hips)
├── Spine
│ ├── Chest
│ │ ├── Neck → Head
│ │ ├── LeftShoulder → LeftArm → LeftHand → ...
│ │ └── RightShoulder → RightArm → RightHand → ...
│ └── ...
└── LeftLeg → LeftFoot → ...

每根骨头存储的是相对于父骨的变换(Local Transform)。世界空间中的位置通过从根向叶子逐级叠加计算:

WorldMatrix(Bone) = WorldMatrix(ParentBone) × LocalMatrix(Bone)

这和场景图(第 6 篇)里父子 Transform 的计算一模一样——骨骼本质上就是一个层次化的场景图。

Bind Pose(绑定姿势):蒙皮时角色的参考姿势——通常是 T-Pose 或 A-Pose。顶点权重和骨骼位置都以这个姿势为参考。


三、蒙皮:顶点怎么跟着骨骼动#

1 线性混合蒙皮(LBS / LBSM)#

标准做法。每个顶点可被最多 N 根骨头影响(UE 默认最多 4 根,即 4-bone influence):

顶点在动画中的位置 = Σ (Weight_i × BoneMatrix_i × BindPoseInverse_i × VertexPos_BindPose)
└──────────┬──────────┘
从绑定空间变换到骨骼空间,再跟随骨骼运动

简化理解:

float3 SkinnedPosition = float3(0, 0, 0);
for (int i = 0; i < 4; i++) {
float weight = Input.Weights[i]; // 第 i 根骨头对这个顶点的影响权重
float4x4 boneMatrix = BoneMatrices[Input.BoneIndices[i]];
SkinnedPosition += weight * mul(boneMatrix, float4(Input.Position, 1.0)).xyz;
}

每个顶点存:

  • BoneIndices[4]——哪 4 根骨头影响它
  • BoneWeights[4]——每根骨头的权重(总和为 1.0)

Weight 和为 1.0 很重要——如果总和小于 1,蒙皮结果会在 BindPose 原点附近收缩;如果大于 1,顶点会过度拉伸。

2 蒙皮的经典问题#

Candy Wrapper(糖纸效应): 关节弯曲时(如肘部),LBS 会让网格体在关节处”收缩”——像拧糖纸一样。因为 LBS 是对骨骼矩阵做线性插值,但旋转矩阵的线性插值不等于旋转的线性插值。

解决方案:Dual Quaternion Skinning(双四元数蒙皮)——用对偶四元数替代矩阵做插值。UE 支持在特定骨骼上启用(SkinCache 相关设置)。计算更贵,但关节弯曲处的体积保持更好。


四、动画数据:骨骼怎么动的#

1 关键帧动画#

动画 = 每根骨头每个时刻的 Transform。不存每帧——只存关键帧,中间帧由引擎插值:

Walk Animation(1 秒):
Bone_LeftLeg: t=0.0 (前) → t=0.5 (后) → t=1.0 (前)
Bone_RightLeg: t=0.0 (后) → t=0.5 (前) → t=1.0 (后)

2 动画压缩#

关键帧数据原始量很大——60 根骨头 × 每秒 30 帧 × 每帧位置+旋转+缩放。UE 的做法:

  • 移除冗余关键帧(线性段中间帧去掉)
  • 量化旋转(用 16-bit 整数替代 float 存四元数分量)
  • 不同的骨骼用不同的压缩率(手指骨骼可以比脊柱压缩更多——它们移动幅度大但视觉上不明显)

3 Animation Sequence(UE 中的动画资产)#

UAnimSequence 存储了动画数据。它不存完整的骨骼矩阵——只存相对于参考姿势的变化。运行时,AnimBlueprint 把多个 AnimSequence 混合(Blend)、叠加(Layered Blend)、IK 修正——产生最终的骨骼姿势。


五、动画状态机与 BlendSpace#

1 动画状态机(State Machine)#

在 UE 的 AnimBlueprint 里,状态机决定”当前在播哪个动画”:

[Idle] ──(速度>0)──→ [Walk] ──(速度>阈值)──→ [Run]
↑ │ │
└──(速度=0)──────────┴──(速度降低)───────────┘

状态之间的过渡(Transition)有 Blend Duration——0.2 秒内从 Walk 淡入 Run,避免动作突变。

2 BlendSpace#

不是离散的”Walk 是 300 速度,Run 是 600 速度”,而是连续的空间:

BlendSpace_1D(横轴 = Speed):
速度=0 → Idle
速度=200 → Walk
速度=400 → Jog
速度=600 → Sprint
速度=300 → Walk(50%) + Jog(50%) ← 引擎自动混合两个动画

BlendSpace 让动作之间的过渡变得自然——玩家从走到跑到冲刺,动画混合是连续的,看不出来”切换点”。


六、UE 的动画管线总览#

AnimSequence (动画数据资产)
AnimBlueprint (动画逻辑)
├── EventGraph → 从 Gameplay 获取速度、方向、是否在空中的状态
├── AnimGraph (状态机 + BlendSpace + IK)
│ ├── BlendSpace 根据速度混合 Walk/Run
│ ├── Layered Blend 叠加上半身的攻击动画
│ └── IK 修正脚部位置(贴合地面)
└── 输出:每帧的骨骼姿势(Pose)
SkeletalMeshComponent::TickComponent
GPU Skinning (Compute Shader 或 Vertex Shader 做蒙皮)
渲染

关键:AnimBlueprint 在 CPU 上算出每根骨骼的 Transform,然后把最终的骨骼矩阵数组(BoneMatrices)上传到 GPU。GPU 的 Vertex Shader 拿这些矩阵对每个顶点做蒙皮。


七、总结#

概念一句话
骨骼层次化的”骨头”,每根存相对于父骨的 Transform
蒙皮每个顶点被最多 4 根骨头加权影响——Weight 和为 1.0
LBS线性混合蒙皮——标准做法,关节处有糖纸效应
关键帧动画只存关键帧,引擎做插值
状态机决定”什么时候播哪个动画”
BlendSpace根据参数(速度、方向)连续混合多个动画
AnimBlueprintUE 的动画逻辑层,输出每帧的骨骼姿势 → GPU 蒙皮

骨骼动画是图形学和 Gameplay 的交叉地带——渲染侧关心蒙皮矩阵怎么高效上传和计算,Gameplay 侧关心状态机和 BlendSpace 怎么组织。理解两端,才能搞定”角色为什么不动”或”角色为什么动错了”的 bug。

骨骼动画与蒙皮——角色如何动起来
https://www.m4doka.xyz/posts/cg/cg-10-skeletal-animation/
作者
m4doka
发布于
2026-03-22
许可协议
CC BY-NC-SA 4.0