《Unity3D高级编程之进阶主程》第五章,3D模型与动画(四) - 3D模型的变与换3

上篇我们了解了简化模型的算法,以及普通网格和蒙皮网格的区别,其中蒙皮网格是专门用来播放动画的,它除了普通网格所需要的顶点,三角形,uv数据外,还需要骨骼数据,以及每个顶点的骨骼权重数据。在骨骼动画中骨骼数据是主要由骨骼点组成的,每个骨骼点之间的关系,它们都是父子关系,或者是平行的兄弟节点的关系,这些骨骼点可以在 Unity3D 中对应到模型上的 GameObject 点。在这种骨骼数据结构下,当父节的骨骼点位移,旋转以及缩放时,子节点的骨骼也同时相对于父节点有位移和旋转操作。

===

那么骨骼点是怎么影响到顶点的呢,又是如何判断影响哪些顶点的呢?其实骨骼动画中,需要顶点的骨骼权重数据来决定顶点受哪些骨骼影响,每个顶点都可以受到骨骼点的影响,在Unity3D中每个顶点最多被4根骨骼影响,这些数据被存储在一个BoneWeight实例里,实例中描述了当前这个顶点对分别哪4个骨骼影响,它们分别有多少权重。因为是固定4个权重且每个顶点都会有,所以顶点数组有多长,顶点权重数据BoneWeight数组就有多长。当骨骼点移动时,引擎就会使用这些顶点权重值来计算顶点的旋转、偏移量和缩放程度。

简单来说就是,用顶点上的骨骼权重数据确定了该点受哪些骨骼点影响,影响的程度有多少。

我们在制作蒙皮动画时通常分三步:第一步是用工具3DMax,Maya等3D模型软件在几何模型上建立一系列的骨骼点(bones),并计算好几何模型的每个顶点受这些骨骼点的影响的权重值(BoneWeight)。第二步则是动画师通过3D模型软件制作一系列的动画,这些动画都是通过骨骼点的偏移、旋转、缩放来完成的,每一帧都有可能有变化,包括关键帧与关键帧之间会补间一些非关键帧的动画,制作完毕后导出引擎专有的动画文件格式,我们在Unity3D中以FBX格式文件专有格式文件。接着第三步则是在Unity3D中导入并播放动画,在动画播放时在动画数据中已经存储了动画师制作的骨骼点位每帧变化的情况,动画序列帧会根据每帧的动画数据持续改变一系列骨骼点,骨骼点的改变又导致了几何模型网格上的顶点产生相应的变化。

通常我们使用的都是关键帧动画,就是Unity3D里的Animation动画文件,在某个时间点上对需要改变的骨骼做关键帧,而并不是在每帧上都做关键帧的操作。使用关键帧作为骨骼的旋转位移点的好处是不需要每帧去设置骨骼点的位置变化,在关键帧与关键帧之间的骨骼位置,可以由Animation动画组件做平滑插值计算相应数据,这样可以大量减少数据大小,相当于关键帧之间做了‘补间动画’,‘补间动画’的目的就是对需要改变的骨骼做平滑的位移、旋转、缩放的插值计算,从而实时地得到相应的结果减少数据使用量。

’补间动画‘在每帧都对骨骼动画做了位置、缩放、旋转的改变,然后蒙皮网格组件(SkinnedMeshRender)在每帧必须重新计算骨骼与网格的关系。最终‘补间动画’每帧改变一些列骨骼点,骨骼点被(SkinnedMeshRender)重新计算得到模型网格变化,从而每帧就呈现出不同的网格变化,最后有了3D模型网格动画。

整个骨骼动画的计算流程清晰了,我们来用Unity3D的API来整理下,可以让我们理解的更加透彻。举例代码和解释如下,重点在注释的解释:

//新建个动画组件和蒙皮组件
gameObject.AddComponent<Animation>();
gameObject.AddComponent<SkinnedMeshRenderer>();
SkinnedMeshRenderer rend = GetComponent<SkinnedMeshRenderer>();
Animation anim = GetComponent<Animation>();

//新建个网格组件,并编入4个顶点形成一个矩形形状的网格
Mesh mesh = new Mesh();
mesh.vertices = new Vector3[] {new Vector3(-1, 0, 0), new Vector3(1, 0, 0), new Vector3(-1, 5, 0), new Vector3(1, 5, 0)};
mesh.uv = new Vector2[] {new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 1), new Vector2(1, 1)};
mesh.triangles = new int[] {0, 1, 2, 1, 3, 2};
mesh.RecalculateNormals();

//新建个漫反射的材质球
rend.material = new Material(Shader.Find("Diffuse"));

//为每个顶点定制相应的骨骼权重
BoneWeight[] weights = new BoneWeight[4];
weights[0].boneIndex0 = 0;
weights[0].weight0 = 1;
weights[1].boneIndex0 = 0;
weights[1].weight0 = 1;
weights[2].boneIndex0 = 1;
weights[2].weight0 = 1;
weights[3].boneIndex0 = 1;
weights[3].weight0 = 1;

//把骨骼权重赋值给网格组件
mesh.boneWeights = weights;

//创建新的骨骼点,设置骨骼点的位置,父节点,和位移旋转矩阵
Transform[] bones = new Transform[2];
Matrix4x4[] bindPoses = new Matrix4x4[2];

bones[0] = new GameObject("Lower").transform;
bones[0].parent = transform;
bones[0].localRotation = Quaternion.identity;
bones[0].localPosition = Vector3.zero;
bindPoses[0] = bones[0].worldToLocalMatrix * transform.localToWorldMatrix;

bones[1] = new GameObject("Upper").transform;
bones[1].parent = transform;
bones[1].localRotation = Quaternion.identity;
bones[1].localPosition = new Vector3(0, 5, 0);
bindPoses[1] = bones[1].worldToLocalMatrix * transform.localToWorldMatrix;

mesh.bindposes = bindPoses;

//把骨骼点和网格赋值给蒙皮组件
rend.bones = bones;
rend.sharedMesh = mesh;

//定制几个关键帧
AnimationCurve curve = new AnimationCurve();
curve.keys = new Keyframe[] {new Keyframe(0, 0, 0, 0), new Keyframe(1, 3, 0, 0), new Keyframe(2, 0.0F, 0, 0)};

//创建帧动画
AnimationClip clip = new AnimationClip();
clip.SetCurve("Lower", typeof(Transform), "m_LocalPosition.z", curve);

//把帧动画赋值给动画组件,并播放动画
anim.AddClip(clip, "test");
anim.Play("test");

以上的Unity3D代码就呈现了,几何模型数据,蒙皮动画数据,从无到有的过程。先添加动画组件和渲染组件,再自行创建一个Mesh实例,放入4个顶点成为一个矩形网格,添加uv和索引数组,计算法线数据,创建一个新的材质球并存入渲染组件。然后是对动画数据的创建,创建并设置骨骼权重数据,创建骨骼并设置空间矩阵。最后创建动画数据,包括关键帧数据,动画曲线,改变的节点名称,再加入到动画组件中去。

代码的大部分内容都简单易懂,除了bindPoses这个骨骼点的矩阵。我们前面提到骨骼点数据中,除了坐标还有矩阵,其实并没有什么节点之间的连线一说,都是我们为了便于理解而想象出来的,子节点在父节点的空间内,父节点的改变能带动子节点的改变,为了能更好的计算出在变动后子节点的位置,节点上的4x4矩阵就很好的发挥了重要作用。由于4x4矩阵能完整的表达点位的偏移、缩放、旋转的操作,也能以通过连续右乘法计算出从根节点到父节点再到子节点上的具体方位,因此4x4矩阵是骨骼点必要的数据,它表达了相对空间的偏移量。即

	骨骼节点变化矩阵 =  根节点矩阵 * 父父父节点矩阵1 * 父父节点 * 父节点矩阵 * 骨骼节点矩阵

我们再返回去看在蒙皮动画第一步中权重的计算决定了蒙皮算法的效果,如果想要几何模型发生自然、高质量的形变,必须得有一种高效准确的权重计算方法。这里简单讲一下蒙皮的计算方式,以了解下其中的计算原理。

线性混合蒙皮(Linear Blending Skinning,LBS)是最最常用的蒙皮计算方式,由于它的计算速度优势使得其成为商业应用中最主要的方法之一。什么是线性混合蒙皮计算方式呢?简单说就是

	骨骼点变化坐标 = (骨骼点变化矩阵*(顶点坐标 - 骨骼点原坐标) + 骨骼点变化后坐标)

	当前顶点位置 =  骨骼点1变化坐标 * 骨骼权重1 + 骨骼点2变化坐标 * 骨骼权重2 ....

如果直接使用这种线性混合计算蒙皮的方式效果有点粗糙,为了更好的效果。[Jacobson et al. 2011]提出了一种有界双调和权重(Bounded Biharmonic Weights,BBW)的计算方法,该权重能使得几何模型发生平滑变形,这个算法后来就成为了我们现在最常使用的骨骼蒙皮动画的计算方式。

总结他的意思就是说,既然网格数据的变化计算量大,线性混合计算的速度又是最快的,我们可以在线性计算的基础上加以改进。在线性蒙皮混合计算公式中,初始位位置无法改变,骨骼点的变化也无法改变,所以权重计算骨骼点的变化的量决定了最终效果是否好的关键。于是他就提出了,有界双调和权重的计算方法,其数学表达式如下:

有界双调和权重的计算方法

没看懂没关系其实我也是不懂,这方面也不是我的强项,故意点点头继续看下一个知识点,至少我们知道了它能更平滑的表达骨骼点影响顶点的变化。

前面说了这么多,我们来总结下,骨骼点结构是父子关系的层级结构,每个骨骼点都由坐标和空间矩阵数据组成,每次计算都可以通过矩阵连乘得到,顶点最终的坐标计算通过多个骨骼点的权重和偏移量来决定。这样看来,由于蒙皮动画是每帧都通过骨骼点来计算网格的变化的,如果骨骼点越多,网格越复杂(顶点或者面数很多)那么消耗的CPU就很多,因为网格里的顶点都需要通过蒙皮算法来算出顶点的变化,一般情况下这些都是靠CPU来计算的。因此在制作模型动画的时候,特别要注意,同屏里有多少蒙皮动画在播放,以及每个蒙皮动画中,骨骼的数量有多少,网格的面数有多复杂,如果太多太复杂就会巨量的消耗CPU。

人物3D模型动画换皮换装

有了上面的这些3D模型和骨骼动画的知识,我们在3D模型动画换装这种常见的游戏功能的编码设计上就显得简单的多了。

首先,为了达到模型动画的动态拼接,我们必须一个人物的所有动画和部件都只使用同一套骨骼。

骨骼点的移动影响网格顶点,更换了模型的部分网格可以,但骨骼点是不能更换的否则骨骼点对顶点的权重影响就不对了动画就乱了套了,因此如果要一个人物不断更换局部模型后还能有一样的动画效果,那么骨骼点必须是同一套。

其次,把骨骼和模型部件拆分开来,骨骼文件只有骨骼数据,每个部件的模型文件只包含了它自己的模型的顶点数据,同时它也必须包含了顶点上的骨骼权重数据。

用Unity3D的术语来说就是,把一个人物模型拆分成有很多个Fbx,其中一个Fbx只有骨骼数据,其他Fbx是每个部件的模型数据,它们都带有已经计算好的骨骼权重数据。这样更便于更换和拼凑模型,每套部件都可以玩家自己拼凑,而骨骼点不变,以及每个部件上模型的顶点数据也始终映射到这一套骨骼上。每次在更换部件时,只要把原来的部分删除,更换成新的部件即可,其他数据依然有效,这便是拆分骨骼与模型部件的好处。

然后,把骨骼数据和模型都实时的动态拼接起来。

将骨骼Fbx模型数据实例化后成为了一个SkinnedMeshRenderer,这样基础的骨骼数据就包含在这个实例里,接着再把需要展示的各个部件Fbx模型实例化出来,它们拥有自己的SkinnedMeshRenderer,最后将骨骼信息从前面骨骼SkinnedMeshRenderer里取出来赋值给他们,包括所有骨骼节点以及变换矩阵。

这样每个部件都进行了SkinnedMeshRenderer实例化,SkinnedMeshRenderer可以渲染出自己的模型效果,并且每个部件自己的SkinnedMeshRenderer都有骨骼数据,由于原本每个部件模型上也都一直存有骨骼的权重数据,这使得每个模型部件针对骨骼动画是有效的。

接着在骨骼的SkinnedMeshRenderer上挂上Animator来播放动画文件,动画文件里的数据改变的是骨骼点,当动画播放时骨骼点会针对动画关键帧进行位移,由于部件模型的骨骼数据都是从骨骼的SkinnedMeshRenderer上映射过来的,所以当骨骼点动起来时就能带动众多的模型网格上的顶点一起动起来。

当骨骼动画的SkinnedMeshRenderer上的动画文件开始播放时,每个部件模型上的顶点也会随着骨骼点的变动而不断的计算出网格模型的变动情况,进而在渲染上体现出部件模型的动画效果。这是由于动画播放时顶点偏移是由顶点上的骨骼权重数据决定的,如果骨骼权重数据没有问题,对应的骨骼点也没有被替换或删除,那么这就表明模型在动画表现上所对应数据的对应关系都是正确,因此他们所展现出来的动画效果也是正确的。

最后,当我们需要更换人物上的某个部件模型时,只需要把原有的部件模型实例删除,再实例化出那个我们需要的部件模型,并把骨骼数据赋值给它就完成了操作。更换的操作很简单,从表现上看就是更换了人物的某个部件,脸,或腿,或手,或腰,或脚。

这种方式虽然是最简单的更换部件的方式,但它由个缺点就是需要的Drawcall比较多,由于人物拆分成了5个部件,头,手,身体,腿,脚,这样我们就需要6个SkinnedMeshRenderer来支撑渲染,除了5个部件模型外还需要1个为骨骼动画的SkinnedMeshRenderer,其他5个为部件模型的SkinnedMeshRenderer,这从效率上看上去很不友好,也就是说一个人物要至少5个Drawcall来支撑。骨骼动画已经很消耗性能了,还需要5个材质球去消耗5个drawcall,加重了性能消耗,一旦人物在场景中过多就会拖慢帧率。

我们有更好的办法,我们希望一个人物动画只使用一个drawcall,那么我们就需要把这5个部件合并成一个模型,他们都使用同一个材质球,模型合并好办,使用Unity3D的Mesh.CombineMeshes就可以实现。

那么贴图怎么办?也同样合并。在每次初始化拼接一个人物模型时,或者更换人物的部件模型时,将5张贴图动态的合并成一张,并在合并贴图的同时需要改变每个模型部件的uv,将他们的uv偏移到这张合并整图的某个范围内。

这样一来,每个人物模型只需要消耗1个drawcall,减轻了gpu的负担。从CPU消耗来看,拼接的操作只存在于人物初始化,和更换部件模型时才会有消耗,因此合并贴图和模型的实际消耗cpu的量并不多。不过所有这些合并的前提是,模型部件都可以使用同一个材质球。

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第五章,3D模型与动画(四) - 3D模型的变与换3

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)