书接上文,上回说道,烘焙顶点的方法存在着两个缺陷以及一个致命弱点:

  1. 信息密度低,顶点数往往要比骨骼节点数要大得多,导致烘焙出来的纹理尺寸大。
  2. 无法复用,导致每模型每动作均需要烘焙。
  3. 最重要的,做不到动画过渡(当然强行Lerp也可以,但肯定会有穿帮)。

这些都是由于烘焙顶点是一种针对“现象”而非“原因”的技术。驱动顶点运动的是骨骼的运动,因此信息密度最大的方法应该针对骨骼动画的信息而运作。

思路

要把骨骼动画烘焙到纹理上以驱动顶点动画。要实现这个目标首先要分拆里面的命题:

  • 烘焙到纹理上的到底是骨骼动画的什么信息?
  • 顶点如何得知它被什么骨骼所驱动,即如何获取顶点与骨骼的对应和权重?
  • 顶点如何被已知的骨骼驱动?

此时,实现的思路就出来了:

  • 将骨骼的矩阵烘焙到纹理上
  • 将权重和索引写入Mesh中
  • 在顶点着色器中读出矩阵做变换

烘焙矩阵

首先要解决的是烘焙的信息(即矩阵)如何计算的问题。这里涉及到蒙皮顶点和骨骼的关系问题。

这个问题换一个描述方法,蒙皮上的顶点要如何随骨骼移动而移动?这个问题就明了一些,能够关注本专栏的大手子们必然知道,就像相机渲染需要经过一系列的矩阵运算一样,骨骼的移动必然导致骨骼的变换矩阵发生了改变,而如果我们预先把一系列矩阵运算的结果计算出来,只需要在顶点着色器中读出矩阵然后mul一下,就能达到结果。

这一系列矩阵运算到底是啥呢?

首先肯定是模型空间下的顶点转换到该骨骼节点空间下的顶点;这一步Unity已经提供了,就是Mesh.bindPoses。我们来看看文档里咋写的:

The bind pose is the inverse of the transformation matrix of the bone, when the bone is in the bind pose
BindPose是骨骼的转换矩阵的逆矩阵。

BindPose这个说法,可能来源于美术绑定骨骼的时候摆成了T-Pose,我觉得暂时没有更好的中文称呼。文档下方是这样计算BindPose的:模型的局部转世界乘以骨骼的世界转局部矩阵。

所以使用这个矩阵变换顶点,相当于先把模型空间下的顶点转到世界空间,然后再从世界坐标空间转到骨骼空间。



bones



然而,骨骼在每帧都会发生平移、旋转、缩放,而骨骼空间的坐标描述不了这个变化。因此我们进行第二步,引入骨骼节点的在世界坐标下的变化信息。

【1】中的实现,是从骨骼节点上溯至根节点,不停的左乘变换矩阵。我觉得有更好的解决方法,就是直接用Transform.localToWorldMatrix。反正烘焙的时候基本上都是在空场景里做的,直接一步到位。这时候,顶点就从骨骼空间转换到了世界空间。

最后,我们实际要用的,还是模型空间内的坐标,因此再将世界空间内的节点变换回骨骼空间。

所以,蒙皮顶点的坐标空间变换是这样的:模型空间-骨骼空间-世界空间-模型空间。

开始的时候,我纳闷一件事情,特喵的,bindPose里用了worldToLocalMatrix,我后面再左乘一个localToWorldMatrix,不就白忙活了吗? 后来我意识到一个误区,就是bindPose,是Mesh静态的数据,是在T-pose下完成绑定时的数据,这里面计算所使用的那个worldToLocalMatrix,并不会随着每帧的运动而变化。因此为了引入时效性信息,自然要从每帧里额外左乘一个localToWorldMatrix进来。

所以这一部分的代码如下:



Texture2D



修改Mesh

虽然我们有了每帧每骨骼的变换信息,但是还有一点,就是一个顶点受哪些骨骼的影响及其程度如何,还没有实现。但是仔细一想就明白,这个索引跟权重是一个Mesh静态的数据。

既然是Mesh静态的,那就直接写到Mesh里,最常见的当然就是UV通道了。UV是一个Vector2的向量,因此一次只能存一对权重索引数据。如果你对精度提出了更高的要求,那可以用2个UV通道或者4个UV通道。

本文的写法是用2个UV通道,也就是支持一个顶点受两个骨骼的影响。

划掉,Tangent还是不能改的。TANGENT_SPACE_ROTATION宏要用到它。感谢 @头像是狐狸吗



private



Shader的工作

剩下的就是在Shader中完成了。先从预存数据的uv中拿出该顶点对应的骨骼和权重;随后在纹理中读出矩阵;最后拿蒙皮顶点做变换即可。



float



一些不得不说的弯路

这篇文章的主体,我已经在三天前就已经写好了,但是迟迟做不出来效果,为什么我会这么蠢呢?




unity skinnedmesh 绑定根骨骼 unity3d骨骼绑定_数据


总结了一下,有三个地方:

第一,主体部分沿用了上一篇文章中顶点纹理的代码。导致Texture2D的格式是RGB24,忽略了8位颜色分量不支持负数,需要手动压缩分量到01区间,这点忽略了,导致查了很久。


unity skinnedmesh 绑定根骨骼 unity3d骨骼绑定_移动端_02

RenderDoc里捕捉的数据


unity skinnedmesh 绑定根骨骼 unity3d骨骼绑定_数据_03

原本的矩阵第一行数据

unity skinnedmesh 绑定根骨骼 unity3d骨骼绑定_权重_04

结果就是这样

这一部分的问题,需要看Unity的文档,里头这么写的:


unity skinnedmesh 绑定根骨骼 unity3d骨骼绑定_移动端_05

没标着floating point的就只能到01区间

第二个,因为只支持两个骨骼,所以不能老老实实的按权重UV里得到的值来写,原本Unity支持的是一个顶点受4个骨骼影响,所以得出的uv值不能这么用:


mul


而是应该:


mul


第三个,搞这个东西,肯定要上移动端,移动端肯定还是用RGBA32这类的更加合适。UnityCG.cginc中提供了两个代码,可以把[0,1)内的浮点数映射到8位颜色分量上:


// Encoding/decoding [0..1) floats into 8 bit/channel RGBA. Note that 1.0 will not be encoded properly.


我选择在C#里Encode,Shader里Decode。因此烘焙相关的代码改成如下形式:


private


当然,这样不好,因为这意味着要构建一个矩阵,就要采样12次;支持两个骨骼,就要采样24次。如果要在移动端使用,可以选择用half映射而不是float映射,然后只支持一个骨骼。反正要用到这玩意儿的时候,基本也不太会在乎细节上的品质……

总结

本文实现了基于预计算矩阵和GPU的蒙皮动画,跟上文提到的基于预计算顶点纹理的蒙皮动画而言,各有优劣。有句话说,“政治是妥协的艺术”,游戏开发也是这样的,同等情况下没有十全十美的技术。

上文是用空间换效率,本文则是用效率换空间;当然,它们的诞生主要是为了解决大规模角色的批次问题——毕竟,在GC面前,这些消耗都显得可以接受了。


unity skinnedmesh 绑定根骨骼 unity3d骨骼绑定_数据_06


Github地址:

https://github.com/noobdawn/Unity_GPU-Skinning_Animationgithub.com


参考资料

【1】踏雪寻梅:[Unity3d手游开发笔记]骨骼蒙皮动画

【2】Unity - Scripting API: TextureFormat