GPUSkinning 教程大纲(UNITY3D)
节一. 原理

  1. 动画:理解骨骼动画的运作方式
  2. 模型:理解美术的制作流程
  3. 骨骼:理解骨骼动画以及背后的数学意义

节二. 实践

  1. 技术背景
  2. 使用GPUSkinning
  3. 工作原理(重点)
  4. 实际效果
  5. 其他可以同时进行的优化方案

原理

动画:骨骼动画的运作方式

让们从轻松一点的内容开始本篇教程吧。

  在的游戏中,骨骼动画的基础是关键帧技术,们先重温一下关键帧技术。

比较有代表性的是帧动画:们将一段较长的动作分解成几个关键帧,然后基于此进行动画的制作。即使现在们使用的工具更新换代,制作动画的思路还是大致如此,拆分与过渡。所有的工具都是帮们自动完善其中的细节。

Skinned Mesh:中文一般称作骨骼蒙皮动画,正如其名,这种动画中包含骨骼( Bone )和蒙皮 (Skinned Mesh) 两个部分, Bone 的层次结构和关节动画类似, Mesh 则和关节动画不同:关节动画中是使用多个分散的 Mesh, 而 Skinned Mesh 中 Mesh 是一个整体,也就是说只有一个 Mesh, 实际上如果没有骨骼让 Mesh 运动变形, Mesh 就和静态模型一样了。 Skinned Mesh 技术的精华在于蒙皮,所谓的皮并不是模型的贴图(也许会有人这么想过吧),而是 Mesh 本身,蒙皮是指将 Mesh 中的顶点附着(绑定)在骨骼之上,而且每个顶点可以被多个骨骼所控制,这样在关节处的顶点由于同时受到父子骨骼的拉扯而改变位置就消除了裂缝。 Skinned Mesh 这个词从字面上理解似乎是有皮的模型,哦,如果贴图是皮,那么普通静态模型不也都有吗?所以觉得应该理解为具有蒙皮信息的 Mesh 或可当做皮肤用的 Mesh ,这个皮肤就是 Mesh 。而为了有皮肤功能, Mesh 还需要蒙皮信息,即 Skin 数据,没有 Skin 数据就是一个普通的静态 Mesh 了。Skin 数据决定顶点如何绑定到骨骼上。顶点的 Skin 数据包括顶点受哪些骨骼影响以及这些骨骼影响该顶点时的权重(weight) ,另外

Bone:对于每块骨骼还需要骨骼偏移矩阵 (BoneOffsetMatrix) 用来将顶点从 Mesh 空间变换到骨骼空间。骨骼控制蒙皮运动,而骨骼本身的运动呢?当然是动画数据了。每个关键帧中包含时间和骨骼运动信息,运动信息可以用一个矩阵直接表示骨骼新的变换,也可用四元数表示骨骼的旋转,也可以随便自己定义什么只要能让骨骼动就行。除了使用编辑设定好的动画帧数据,也可以使用物理计算对骨骼进行实时控制。

UNITY3D中的Skinned Mesh Renderer组件:UNITY3D在导入模型的时候自动会给模型附加蒙皮网格(Skinned Mesh Renderer),使用Unity骨骼人物的主要优势是可以使骨骼受到物理影响,譬如使用角色布娃娃。如下图所示Skinned Mesh Renderer会自动关联其对应的骨骼。

模型:理解美术的制作流程

 们在本文中主要讨论在UNITY3D内的实现方式。在UNITY中常用T-Pose居多。 T-Pose方便们对应骨骼与蒙皮方便校验表现与骨骼位置不一致的地方。对于每个骨骼,美术在建模的时候就进行了绑定,对于们程序只需要进行动画的播放,一般在骨骼上有一些额外的挂点。这些挂点是模拟出来的额外的点,通常提供给们挂一些武器或其他装备物体的节点。在完成建模后,在进行骨骼绑定的时候,通常美术会选择模型的盆骨做为模型的根骨骼。那么基于根骨骼,可以递推出各个骨骼相对于根骨的父子关系。通过骨骼所在的空间,其原点们会选择两个脚之间的中点作为原点,这时候就会发现根骨骼并没有和原点重合,这时美术会构建一个Scene_Root做为额外的骨骼,其位置就为世界原点,而真正的根骨骼Bip01会作为Scene_Root的唯一子骨骼。

骨骼:理解骨骼动画以及背后的数学意义

首先要明确一个观念:骨骼决定了模型整体在世界坐标系中的位置和朝向。
静态模型没有骨骼,们在世界坐标系中放置静态模型时,只要指定模型自身坐标系在世界坐标系中的位置和朝向。在骨骼动画中,不是把 Mesh 直接放到世界坐标系中, Mesh 只是作为 Skin 使用的,是依附于骨骼的,真正决定模型在世界坐标系中的位置和朝向的是骨骼。在渲染静态模型时,由于模型的顶点都是定义在模型坐标系中的,所以各顶点只要经过模型坐标系到世界坐标系的变换后就可进行渲染。而对于骨骼动画,们设置模型的位置和朝向,实际是在设置根骨骼的位置和朝向,然后根据骨骼层次结构中父子骨骼之间的变换关系计算出各个骨骼的位置和朝向,然后根据骨骼对 Mesh 中顶点的绑定计算出顶点在世界坐标系中的坐标,从而对顶点进行渲染。要记住,在骨骼动画中,骨骼才是模型主体, Mesh 不过是一层皮,一件衣服。


骨骼只是一个形象的说法,实际上骨骼可理解为一个坐标空间,关节可理解为骨骼坐标空间的原点。关节的位置由它在父骨骼坐标空间中的位置描述。上图中有三块骨骼,分别是上臂,前臂和两个手指。 Clavicle( 锁骨 ) 是一个关节,它是上臂的原点,同样肘关节 (elbow joint) 是前臂的原点,腕关节 (wrist) 是手指骨骼的原点。关节既决定了骨骼空间的位置,又是骨骼空间的旋转和缩放中心。为什么用一个 4X4 矩阵就可以表达一个骨骼,因为 4X4 矩阵中含有的平移分量决定了关节的位置,旋转和缩放分量决定了骨骼空间的旋转和缩放。们来看前臂这个骨骼,其原点位置是位于上臂上某处的,对于上臂来说,它知道自己的坐标空间某处(即肘关节所在的位置)有一个子空间,那就是前臂,至于前臂里面是啥就不考虑了。当前臂绕肘关节旋转时,实际是前臂坐标空间在旋转,从而其中包含的子空间也在绕肘关节旋转,在这个例子中是 finger 骨骼。和实际生物骨骼不同的是,们这里的骨骼并没有实质的骨头,所以前臂旋转时,他自己没啥可转的,改变的只是坐标空间的朝向。你可以说上图的蓝线在转,但实际蓝线并不存在,蓝线只是画上去表示骨骼之间关系的,真正转的是骨骼空间,们能看到在转的是 wrist joint ,也就是两个 finger 骨骼的坐标空间,因为他们是子空间,会跟随父空间运动,就好比人跟着地球转一样。

骨骼就是坐标空间,骨骼层次就是嵌套的坐标空间。关节只是描述骨骼的位置即骨骼自己的坐标空间原点在其父空间中的位置,绕关节旋转是指骨骼坐标空间(包括所有子空间)自身的旋转,如此理解足矣。但还有两个可能的疑问,一是骨骼的长度问题,由于骨骼是坐标空间,没有所谓的长度和宽度的限制,们看到的长度一方面是蒙皮后的结果,另一方面子骨骼的原点(也就是关节)的位置往往决定了视觉上父骨骼的长度,比如这里 upper arm 线段的长度实际是由elbow joint 的位置决定的。第二个问题,手指的那个端点是啥啊?实际上在们的例子中手指没有子骨骼,所以那个端点并不存在:)那是为了方便演示画上去的。实际问题中总有最下层的骨骼,他们不能决定其他骨骼了,他们的作用只剩下控制 Mesh 顶点。对了,那么手指的长度如何确定?们看到的长度应该是由手指部分的顶点和蒙皮决定的,也就是由 Mesh 中属于手指的那些点离腕关节的距离决定。

为什么要将骨骼组织成层次结构呢?答案是为了做动画方便,设想如果只有一块骨骼,那么让他动起来就太简单了,动画每一帧直接指定他的位置即可。如果是n块呢?通过组成一个层次结构,就可以通过父骨骼控制子骨骼的运动,牵一发而动全身,改变某骨骼时并不需要设置其下子骨骼的位置,子骨骼的位置会通过计算自动得到。上文已经说过,父子骨骼之间的关系可以理解为,子骨骼位于父骨骼的坐标系中。们知道物体在坐标系中可以做平移变换,以及自身的旋转和缩放变换。子骨骼在父骨骼的坐标系中也可以做这些变换来改变自己在其父骨骼坐标系中的位置和朝向等。那么如何表示呢?由于4X4矩阵可以同时表示上述三种变换,所以一般描述骨骼在其父骨骼坐标系中的变换时使用一个矩阵,也就是DirectX SkinnedMesh中的FrameTransformMatrix。实际上这不是唯一的方法,但应该是公认的方法,因为矩阵不光可以同时表示多种变换还可以方便的通过连乘进行变换的组合,这在层次结构中非常方便。

Vertex blending公式
现在让们用一个公式总结一下Vertex blending的整个过程(使用矩阵变换)

1
2
3
4
Vworld = Vmesh * BoneOffsetMatrix1 * CombindMatrix1 * Weight1
+ Vmesh* BoneOffsetMatrix2 * CombinedMatrix2 * Weight2
+ …
+ Vmesh * BoneOffsetMatrixN * CombindMatrixN * WeightN

(这个公式使用的是行向量左乘矩阵)

由于BoneOffsetMatrix和Combined Matrix都是矩阵,可以先相乘这样就减少很多计算了。

实践

技术背景

UNITY3D默认的骨骼动画组件[SKinnedMeshRender]使用的是CPU蒙皮,屏幕内模型较多的时候会造成CPU负担过大,导致卡顿,手机发热等。新版本UNITY3D可以开启GPU Skinning,但其使用的 Transfrom feedback 会将大量顶点从GPU传到CPU再计算,以此来完成动画融合或IK等功能。因此需要高效的GPU蒙皮方案。

使用GPUSkinning

  1. 使用Unity Animation/Animator和SkinnedMeshRenderer制作角色prefab, 保证Animation/Animator组件子构件有SkinnedMeshRenderer(可以参考Example目录中的例子)
  2. 添加GPUSkinningSampler脚本到Animation/Animator绑定的GameObject上

更详细的资料您可参考 GpuSkinning使用手册 – GSDN

工作原理

当场景中有很多人物动画模型的时候会产生大量开销,这些开销除了 DrawCall 外,很大一部分来自于骨骼动画。Unity 内置提供了 GPU Skinning 的功能,但测试下来并没有对整体性能有任何提升,反而整体的开销增加了不少。有很多种方法来减小骨骼动画的开销,每一种方法都有其利弊,都不是万金油,这里介绍的方法同样如此。其实本质还是 GPU Skinning,由们自己来实现,但是和 Unity 内置的 GPU Skinning 有所区别。

从上图中可以看到,Unity 调用到了 Opengl ES 的 Transform Feedback 接口,这个接口至少要到 OpenGL ES 3.0 才有。

在开启 GPUSkinning 的时候,Unity 确实已经在 CPU 中进行了骨骼变换,而后将矩阵数组传递给 Shader,通过 Transform Feedback 后,将结果存储到 Buffer Object 中,这时 Buffer Object 中存储的顶点数据已经是蒙皮完成了,最后渲染模型的时候直接拿来用即可。下面这段 glsl 既是输出 Transform Feedback 的,也证明了这点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#version 300 es

const int max_bone_count = 32;
const highp float max_bone_count_inv = 1.0 / float(max_bone_count);
const highp float half_texel = 0.5 * max_bone_count_inv;
in vec3 in_vertex;
in vec3 in_normal;
in vec4 in_tangent;
in ivec2 in_boneIndices;
in vec2 in_boneWeights;
out vec3 out_pos;
out vec3 out_normal;
out vec4 out_tangent;

uniform vec4 bones[max_bone_count*3];
#define GET_MATRIX(idx) mat4( bones[int(idx)*3 + 0], bones[int(idx)*3 + 1], bones[int(idx)*3 + 2], vec4(0.0, 0.0, 0.0, 1.0))

void main(void)
{
vec4 inpos = vec4(in_vertex.xyz, 1.0);
mat4 localToWorldMatrix = GET_MATRIX(in_boneIndices.x) * in_boneWeights[0];
if(in_boneWeights[1] > 0.0)
localToWorldMatrix += GET_MATRIX(in_boneIndices.y) * in_boneWeights[1] ;
out_pos = (inpos * localToWorldMatrix).xyz;
gl_Position = vec4(out_pos.xyz, 1.0);
out_normal = normalize( ( vec4(in_normal.xyz, 0.0) * localToWorldMatrix)).xyz;
out_tangent = vec4( normalize( ( vec4(in_tangent.xyz, 0.0) * localToWorldMatrix)).xyz, in_tangent.w);
}

这次们要动手实现的就是这个过程,但是不使用 Transform Feedback,因为要保证在 OpenGL ES 2.0 上也能良好运行,况且引擎也没有提供这么底层的接口。

大致的步骤是这样的:

将骨骼动画数据序列化到自定义的数据结构中。这么做是因为这样能完全摆脱 Animation 的束缚,并且可以做到 Optimize Game Objects(Unity 中一个功能,将骨骼的层级结构 GameObjects 完全去掉,减少开销),同时不丢失绑点。
在 CPU 中进行骨骼变换。
将骨骼变换的结果传递给 GPU,进行蒙皮。
很简单的三大步,对于传统的骨骼动画来说没有任何特殊的步骤,下面会对其中的每一步展开说明,并将其中的细节说清楚。

提取骨骼动画数据


目的就是将这些数据提取出来,存储到自定义的数据结构中。代码大致是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
EditorCurveBinding[] curvesBinding = AnimationUtility.GetCurveBindings(animClip);
foreach(var curveBinding in curvesBinding)
{
// 旋转
AnimationCurve curveRX = AnimationUtility.GetEditorCurve(animClip, curveBinding.path, curveBinding.type, "m_LocalRotation.x");
AnimationCurve curveRY = AnimationUtility.GetEditorCurve(animClip, curveBinding.path, curveBinding.type, "m_LocalRotation.y");
AnimationCurve curveRZ = AnimationUtility.GetEditorCurve(animClip, curveBinding.path, curveBinding.type, "m_LocalRotation.z");
AnimationCurve curveRW = AnimationUtility.GetEditorCurve(animClip, curveBinding.path, curveBinding.type, "m_LocalRotation.w");

// 位移
AnimationCurve curvePX = AnimationUtility.GetEditorCurve(animClip, curveBinding.path, curveBinding.type, "m_LocalPosition.x");
AnimationCurve curvePY = AnimationUtility.GetEditorCurve(animClip, curveBinding.path, curveBinding.type, "m_LocalPosition.y");
AnimationCurve curvePZ = AnimationUtility.GetEditorCurve(animClip, curveBinding.path, curveBinding.type, "m_LocalPosition.z");

// 不考虑缩放,假定所有骨骼的缩放都是 1

float curveRX_v = curveRX.Evaluate(second);
float curveRY_v = curveRY.Evaluate(second);
float curveRZ_v = curveRZ.Evaluate(second);
float curveRW_v = curveRW.Evaluate(second);

float curvePX_v = curvePX.Evaluate(second);
float curvePY_v = curvePY.Evaluate(second);
float curvePZ_v = curvePZ.Evaluate(second);

Vector3 translation = new Vector3(curvePX_v, curvePY_v, curvePZ_v);
Quaternion rotation = new Quaternion(curveRX_v, curveRY_v, curveRZ_v, curveRW_v);
NormalizeQuaternion(ref rotation);
matrices.Add(
Matrix4x4.TRS(translation, rotation, Vector3.one)
);
}

其中有两个注意点。第一,要清楚 AnimationCurve 中提取出来的旋转量是欧拉角还是四元数,这里一开始就弄错了,想当然认为是欧拉角,所以随后计算得到的结果也就错了。第二,用来旋转的四元数,必须是单位四元数(模是1),否则你会得到 Unity 的一个报错信息。

以上的代码中,将每一帧的数据以 30fps 的频率直接采样了出来,其实也可以不采样出来,而是等需要的时候再从 AnimationCurve 中采样,这样会更平滑但是运行时的计算量也更多了。

骨骼变换

骨骼变换是所有代码的核心部分了,看似挺复杂,其实想清楚后代码量是最少的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private void Update()
{
// 更新 Walk 动作的所有骨骼变换
UpdateBoneAnimationMatrix("Walk", second);
second += Time.deltaTime;
}

private void UpdateBoneAnimationMatrix(string animName, float time)
{
// boneAnimation 是我们自定义的数据结构
// 其中存储了刚才从 AnimationCurve 中采样到的动画数据
GPUSkinning_BoneAnimation boneAnimation = GetBoneAnimation(animName);
int frameIndex = (int)(time * boneAnimation.fps) % (int)(boneAnimation.length * boneAnimation.fps);
// 获取当前播放的是哪一帧的动画数据
GPUSkinning_BoneAnimationFrame frame = boneAnimation.frames[frameIndex];

// 刷新所有的骨架动画矩阵
UpdateBoneTransformMatrix(bones[rootBoneIndex], Matrix4x4.identity, frame);
}

private void UpdateBoneTransformMatrix(GPUSkinning_Bone bone, Matrix4x4 parentMatrix, GPUSkinning_BoneAnimationFrame frame)
{
int index = BoneAnimationFrameIndexOf(frame, bone);
Matrix4x4 mat = parentMatrix * frame.matrices[index];
// 当前骨骼
bone.animationMatrix = mat * bone.bindpose;

// 继续递归子骨骼
GPUSkinning_Bone[] children = bone.children;
int numChildren = children.Length;
for(int i = 0; i < numChildren; ++i)
{
UpdateBoneTransformMatrix(children[i], mat, frame);
}
}

简单来说骨骼变换就是一个矩阵乘法,比如 bone0(简写为b0) 是 bone1(简写为b1) 的父骨骼:

注意这里是矩阵左乘(从右往左读),trs 是 Matrix4x4.TRS,也就是从 AnmationCurve 采样到的数据。
Bindpose 的作用是将模型空间中的顶点坐标变换到骨骼空间中(是骨骼矩阵的逆矩阵),然后应用当前骨骼的变换,沿着层级关系一层层的变换下去。

蒙皮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private void Update()
{
UpdateBoneAnimationMatrix("Walk", second);
Play();
second += Time.deltaTime;
}

private Matrix4x4[] matricesUniformBlock = null;
private void Play()
{
int numBones = bones.Length;
for(int i = 0; i < numBones; ++i)
{
matricesUniformBlock[i] = bones[i].animationMatrix;
}
// 将骨骼变换的结果传递到 Shader 中
// SetMatrixArray这是 Unity5.4 之后提供的新的 API
// 以前是不能直接传一个数组的,只能一个个元素单独传,效率很低
// 新的 API 减小了开销(看下图)
newMtrl.SetMatrixArray(shaderPropID_Matrices/*_Matrices*/, matricesUniformBlock);
}


由于骨骼数量固定为 24,所以图中的 96 = 24 x 4

使用 SetMatrixArray 其实有点浪费了,因为对于一个 4x4 的矩阵(四个float4)来说,最后一维永远是 (0, 0, 0, 1),所以可以使用 3x4的矩阵(三个float4)代替,这样就减少了数据传递的压力。

现在所有的骨骼变换矩阵已经传递到 Shader 中了,就可以使用这些数据来蒙皮(变换顶点坐标)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 模型确定为 24 个骨骼
// 不同的设备对常量寄存器存储的最大数据量都是有差别的,这一点需要注意
uniform float4x4 _Matrices[24];

struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
// tangent 存储了骨骼索引和权重
// tangent.x 第一根骨骼索引
// tangent.y 第一根骨骼权重
// tangent.z 第二根骨骼索引
// tangent.w 第二根骨骼权重
float4 tangent : TANGENT;
};

v2f vert (appdata v)
{
v2f o;

// 蒙皮
float4 pos =
mul(_Matrices[v.tangent.x], v.vertex) * v.tangent.y +
mul(_Matrices[v.tangent.z], v.vertex) * v.tangent.w;

// 注意,如果用到了 normal,也要像顶点一样经过蒙皮处理哦

o.vertex = mul(UNITY_MATRIX_MVP, pos);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}

// Mesh.tangents 里预先存储了骨骼索引和权重
// tangent 里只容得下两个骨骼融合
Vector4[] tangents = new Vector4[mesh.vertexCount];
for(int i = 0; i < mesh.vertexCount; ++i)
{
BoneWeight boneWeight = mesh.boneWeights[i];
tangents[i].x = boneWeight.boneIndex0;
tangents[i].y = boneWeight.weight0;
tangents[i].z = boneWeight.boneIndex1;
tangents[i].w = boneWeight.weight1;
}
newMesh.tangents = tangents;

其他可以同时进行的优化方案

  1. 除了使用GPUSkinning之外们还可以选择使用层次细节(LOD),它是根据物体在游戏画面中所占视图的百分比来调用不同复杂度的模型的。简单而言,就是当一个物体距离摄像机比较远的时候使用低模,当物体距离摄像机比较近的时候使用高模。这是一种优化游戏渲染效率的常用方法,缺点是占用大量内存。使用这个技术,一般是在解决运行时流畅度的问题,采用的是空间换时间的方式。
    结合GPUSkinning与LOD将会大大提高同屏数量,同时相应的画面会有所降低。可根据实际情况进行处理。

  2. 启用多线程渲染(Multithreading render) ,启用多线程渲染之后渲染效率高出一半左右。原理是将Mesh渲染任务交给另外的渲染进程以此降低当前进程的渲染耗时。

  3. 在模型上启用Optmize GameObject降低CPU耗时
    启用Optmize GameObject之前

    启用Optmize GameObject之后

    Optmize GameObject会极大降低骨骼数目对多线程的影响,从而达到降低主线程的CPU耗时。

本文部分内容参考如下链接:
🔗 GPU Skinning -Jim’s GameDev Blog
🔗 Unity中动画系统的性能优化方案-uwa
🔗 YouTube 12 Principles of Animation
🔗 Skinned Mesh原理解析和一个最简单的实现示例
🔗 Unity官方文档 Skinned Mesh Renderer