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

前面讲解了些骨骼动画的基础知识,我们在基础知识上可以理解骨骼动画的原理。在骨骼原理之后,我们又对人物模型动画的换皮换装的原理了剖析一下,因为有了骨骼动画的基础知识支撑,我们在对换装换皮的方法和技巧上理解起来就更加的清晰了。

其实看一遍是不够的,要看很多遍,而且要边想边看边实践,这样才有效果。要不怎么说理论这东西用处不够大呢,如果只有理论,那么这就是纸上谈兵,解决不了实际问题,反过来也是一样,只有实践则无法彻底了解原理和机制,我们就无法精进,总是觉得有什么东西被蒙在鼓里,运用起来不能得心应手。

===

有原理和又有实践,其实依然不行,还需要多思考多举一反三。要彻底理解一个知识点,真的难。在这条学习的道路上我们不能放弃,即使放弃了,也要想着什么时候能重新捡回来。人无完人,大家都一样,所有人都要经历失败,放弃,再重新捡起来,再失败,再放弃,再重新捡起来的过程,谁能更快的重新捡起来,就成了取胜的关键了,失败是必然的,就看谁能先重新站起来,再失败也是必然的,就看谁能一次次失败后还能站得住。

本篇我们要讲在骨骼动画原理之上,做些更高级的技巧,这些技巧都是基于上篇和上上篇的基础知识之上的。

网格主要由顶点,三角形索引数组,uv这三个基础数据组成,也可以有顶点的法线和颜色数据。其中uv用于贴图,法线用于展示凹凸效果,顶点颜色则有其他多种用途。

蒙皮骨骼动画,也就是SkinnedMeshRenderer,除了有这些网格数据外,又多了骨骼点和骨骼权重数据。骨骼点,是以父子或兄弟的关系相连的节点,它在Unity3D里的表象就相当于许多GameObject相互挂载并放在根节点下,除了这些看得见的GameObject节点外,骨骼点还需要旋转矩阵bindPoses,它主要是为了当父节点旋转移位时能更快的计算得到自身位移和旋转的结果矩阵,每个矩阵都是其父节点矩阵相乘所得到的结果。最后每个顶点都有自己的顶点数据,现在在顶点数据之上又多了些数据来表达被哪些骨骼的影响权重值,这就是顶点的骨骼权重数据。

用最简洁的语言回顾了一下基础的知识。

6.捏脸

捏脸是一种行为方式,用以表达对对方的喜爱之情或对其某行为作出惩罚。网络游戏中泛指对虚拟角色样貌进行DIY的数据操作。

捏脸看起来像是很复杂的技巧,在我们剖析一下后就会觉得也没有想象中的困难。

首先,捏脸最重要的部分就是换部位。

角色身上可以替换的部位有,不同形状的头,不同形状的上身,不同形状的腿,不同形状的脚,不同形状的手,其实还可以细分到更多,比如嘴,耳朵,胸,头发等,这些部件都可以从整体模型中拆分出来,单独成立一个模型,然后再选出来后拼装到整体模型上去。

拆分出不同部位的模型,有了多个相同部位不同形状的模型后,我们就有了很多个模型部件可以替换,在捏脸时就可以选择不同的形状的部件。

替换的过程其实就是上篇我们讲到的换装的过程,我们再来简单回顾下。

我们必须所有模型都使用同一套骨骼,把骨骼以SkinnedMeshRenderer组件的方式实例化出来,我们暂时称它为‘根节点’,并挂上动画组件和动画文件,当播放动画时就可以看到骨骼会跟随每帧动画数据而变动。

但此时还没有任何模型展示,我们把选中的部件模型也以SkinnedMeshRenderer组件的形式实例化出来,并挂载在‘根节点’下。

现在挂载在‘根节点’下的部件模型只是静止的不会动的模型,虽然其自身有顶点的骨骼权重数据,但没有骨骼点的数据是无法计算出骨骼变化后的模型变化的。

因此我们再把‘根节点’里的骨骼点数据赋值给这些模型部件,让他们能在每帧渲染前根据骨骼点的变化结合自身的骨骼权重数据计算出自身的网格变化情况。

做完这些操作后,我们就算成功合成了一个由自己选择的人体部件并带骨骼动画的角色模型实例。

当需要更换人体部件时,所需要的操作与合成一个角色模型的步骤一样,只是在这之上有了些小的变化,因为只替换某个部件,所以‘根节点’与其他没有更换的部件不需要被销毁,是可以重复利用的,只需要删除替换的部件实例。

合成完模型看看这个角色,这么多部件都使用了SkinnedMeshRenderer,每个SkinnedMeshRenderer都有一定计算和drawcall的消耗,怎么办?合并。

一种简单的办法就是仍然使用多个材质球进行渲染,在合并Mesh时使用子网格(SubMesh)模式,相当于只减少了SkinnedMeshRenderer组件的数量,并没有减少其他的消耗。

另一种办法稍微复杂点,不使用子网格(SubMesh)模型,而是将所有模型合并成一个Mesh网格,使用同一个材质球。不过我们还是得保证有相同Shader的材质球进行合并,不相同的Shader的材质球不合并的原则,以保证角色渲染效果不变。

把这么多材质球合并成一个的困难之处在于,贴图怎么办,uv怎么办?贴图我们采用实时合并贴图的方式,为了降低Drawcall,我们的办法实质上就是内存换CPU的方式,每次合成角色、更换部件时都重新合成一遍贴图,同时把uv设置在合并贴图后的某个范围内,因为uv的相对位置是不变的,所以只要整体移动到某个范围内就可以正常显示。

这样模型的更换与合并,让角色捏脸系统有了基础的功能,而材质球、贴图的合并,优化了性能效果让这个系统更加完美。

其次是更换贴图

不同颜色的头发,不同颜色的手套,不同颜色相同形状的衣服,不同贴图相同形状的眼睛等,这些可以简单的使用贴图来达到目标的动作,就直接更换材质球里的贴图就可以了,不需要太复杂的操作,如果是采用贴图合并的方式来做的合并,那么就再重新合并一次贴图,uv则不需要任何变化。

再者是骨骼移动、旋转、缩放

除了更换部件、更换颜色的操作外,捏脸还有一个重要的功能,就是用户可以自由随意的DIY去塑造模型。例如把鼻子抬高点,把嘴巴拉宽点,把腰压细一点,把腿拉长一点等。

由于模型的网格(Mesh)是根据骨骼点来变化的,每个组成网格的顶点都有自己的骨骼权重数据,所以只要骨骼点移动了,它们也跟着移动,骨骼点旋转了,它们也跟着旋转,骨骼点缩放了,它们也跟着缩放。于是我们可以利用这个特性来做一些操作,来让‘捏泥人’更加容易。

不过问题也来了,骨骼点是随着动画一起动的,动画数据里的关键帧决定了骨骼点的变化,我们实时改变骨骼点位置是无法达到效果的,因为动画数据会强行恢复骨骼点,致使我们的操作变得无效。

我们既要整个模型网格仍然依照原来的动画数据去变动,又要用某个骨骼点去影响某些网格怎么办?

额外增加一些骨骼点,这些骨骼点是专门为用户可操作服务的骨骼点,并且这些骨骼点不加入到动画数据里。

也就是说动画数据不会影响到这些骨骼点,动画播放时这些骨骼点是不会动的。

然后为了能让网格随着,操作这些骨骼点儿发生变化,在顶点的骨骼权重数据里给这些骨骼点一些权重,这个权重能达到玩家操作效果就可以了,其他都由动画去决定变化,SkinnedMeshRenderer会在每帧根据骨骼点的变化计算出所有顶点的位置,也就是网格的变化形状。

这样操作下来,我们就达到了先前说的,既要整个模型网格仍然依照原来的动画数据去变动,又可以让用户自定义操作骨骼点去影响网格变化。

最后是改变原始Mesh凹凸形状

只操作骨骼点来改变模型的捏脸效果还是不够的,因为毕竟骨骼点数量不能太多,顶点的骨骼权重数据也是有限的,无法通过增加大量的骨骼点来达到模型复杂变化的效果。

于是,只能再另寻它方,这次我们回到了最基础的网格变化,由于蒙皮网格在每帧都从原始的网格加上骨骼点的变化数据来计算现在网格的形状的,那么改变原始网格的顶点数据,也同样可以改变网格在动画时的模型变化。

对原始网格数据里的顶点进行变化,例如凹陷,拉伸,偏移等都可以影响整个模型在动画时的变化,因为蒙皮网格每帧的变化是根据原始网格而来的。

其实我们一直在围绕着基础知识做技术研究,基础知识和原理是核心,当我们实践时巩固了对基础知识和原理的理解,理论与实践相结合,并且不停地交替学习,逐渐得我们就能得心应手,运用自如,甚至还能厉害到无剑胜有剑的境界。

7.动画优化

蒙皮动画太消耗CPU,为什么?

因为所有蒙皮网格的变化都是由CPU计算得到的,并且无法利用多线程,也无法交给GPU去做,也就是没有谁能分担这项任务。

更糟糕的是,游戏需要大量的蒙皮动画来达到丰富效果的目的。基本上所有项目都会极致得用尽动画功能,让游戏看起来很生动,很动感,很丰富,很饱满,很火热。

效果再好,性能不行,只有高端机才能承受渲染压力的游戏,就无法对普罗大众产生吸引力,也就无法开启吸引力效应。所以对每个项目来说,对动画的优化也是迫在眉睫。

本节就来说说3D模型动画的优化技巧。

用着色器代替动画

蒙皮动画说到实质处,就是网格顶点的变化,根据骨骼点与权重数据计算网格变化,它只是人们发明的一种每帧改变网格的方法而已,最终的目标都是,怎么让网格每帧发生变化,并且这种变化的形状是我们所期望的。

只要达到这个目标,无论什么方法都是可行的。

只要能想到这步,那么我们就有了另一种途径,即着色器(Shader)中的顶点着色器也可以改变网格顶点的位置。

于是利用顶点着色器,与相应的算法就等得到一个随着时间变化的模型动画。

利用着色器制造的动画,这种方式已经在许多项目中得到了利用,最常见就是随风摆动的草,会飘动的旗子,飘动的头发,左右摇摆的树,河流的波浪等。

这些算法不在这里一一讲解了,全部搬到Shader着色器章节中去深入剖析,但大部分这些算法都利用了,游戏时间,噪声算法(noise),数学公式(sin、cos等)来表达顶点的偏移量。

除了顶点动画,还可以利用uv来做动画,比如不断流淌的水流就属于uv位移动画,又比如火焰效果中不断更换uv范围达到序列帧动画效果的uv序列帧动画,再比如不停旋转的面片动画,就可以用uv旋转来代替面片旋转,把CPU的消耗转入到GPU去消耗。

uv动画的具体算法也不再这里一一讲解,都统一挪到Shader着色器章节中去了。

用着色器代替动画实质上,就是用GPU消耗来分担CPU的效果,让两个芯片能更好的发挥其作用,而不是让某一个闲着没事干(大部分时候都是GPU很空),另一个则忙的要死(大部分时候都是CPU很忙)。

但用顶点动画,uv动画的算法来代替动画方式毕竟是有限的复杂度,当需要代替的动画复杂到没有固定算法规律可寻时,则需要寻求其他途径。

离线Bake每帧的模型网格,然后用更换网格的方式绘制每一帧,用内存换CPU

上面介绍了用着色器算法来动画,从而将CPU的消耗转移到了GPU消耗使得动画性能得以优化,但这样做的动画的复杂度是有限的,因为很多复杂的动画无法用算法来表达。

这次我们不打算用算法了,即抛弃算法。我们来场无剑胜有剑的战斗,‘没有算法就是最大的算法’。

我们想,动画的实质是,每帧显示的内容不一样,而每帧显示的内容不一样,就需要每帧都计算出一个不一样的形状。

那么能不能,不计算?能。

我们可以每帧都准备一个模型,每帧都展示一个已经准备好的不一样的模型,于是就有了每帧都有不同形状的模型需要展示,这不就是动画么。

比如这是个5秒的蒙皮动画,每秒30帧,总共需要150个画面,我们需要准备150个模型来依次在每帧中播放。

是不是代价很大,本来一个模型只要一个网格就够了,现在要准备150个网格,这就是内存换CPU的想法,到底值不值得这么做呢?

假设这个场景只有2-3个模型在播放这个动画,那么为了这2-3个模型动画,我们就需要额外准备150个模型来播放动画,本来只要一个模型+骨骼就可以办到的事情,我们却要用150个模型来代替,加载这150个模型也是需要时间的,更何况内存额外加大了150倍。确实不值得。

我们又假设,这个模型同时播放的这个动画的数量很多,比如100个以上,计算机每帧都需要通过模型+骨骼的方式计算出一个模型的变化,而且要重复计算100次,这时我们再用150个模型来代替这每帧持续的CPU消耗,就非常值得了。

计算机不再需要大量计算相同模型网格的变化,而只是在读取这150个模型时内存消耗以及加载的消耗,换来的是持续的高效的动画效果,这就是值得的。
将每帧的网格偏移数据导出到图片,在Shader中让GPU通过图片里的数据来偏移顶点。

离线Bake每帧的模型网格,再通过直接改变网格数据来实时渲染动画,确实在大量渲染相同动画时起到了非常大的优化作用。

但毕竟是还是会有大量的Drawcall的,每个模型一个Drawcall,100个模型100个Drawcall,GPU的压力很大。

那么有没种方法合并这些drawcall?利用GPU Instancing。

什么是GPU Instancing?这是GPU渲染API提供的一种技术,如果绘制1000个物体,它将一个模型的vbo提交给一次给显卡,至于1000个物体不同的位置,状态,颜色等等将他们整合成一个per instance attribute的Buffer给GPU,在显卡上区别绘制,它大大减少提交次数,这种技术对于绘制大量的相同模型的物体由于有硬件实现,所以效率高,更灵活,也避免了合批而造成内存浪费,并且原则上可以做GPU Skinning来实现骨骼动画的instancing。

GPU Instancing最重要的一个特点是只要提交一次,就可以绘制1000个物体,把原本要提交1000次的流程,简化成了只需要提交1次,1000个Drawcall瞬间降为了1次。

当然没那么简单,它是有条件的,首先条件是模型的着色器(Shader)要支持GPU Instancing,其次是这1000个模型他们位置、角度可以不一样,但都使用同一个模型数据,最后他们每帧都使用的是同样的模型数据,这样才能提交一次渲染全部。

GPU Instancing 的条件有点苛刻,Shader要相同,材质球要相同,模型要相同,还不能有变形(就是不能有动画)。

对于我们来说,要用GPU Instancing,就不能变模型了,怎么办?

我们原来的方法是准备150个模型,每帧都渲染一个,现在换成,把这150个模型的顶点数据放入一张贴图里去,这张图总共有150行,每行都写入了所有顶点的坐标,每个像素数据 RGB 都分别代表 xyz 的顶点坐标,比如总共有3000个顶点,那么每行就有3000个像素,总共150行,这张图总共有 3000 * 150 个像素。

然后把这张图,也相当于是网格的顶点数据传入着色器(Shader),着色器根据传入的图中的每个像素去设置顶点,让GPU去改变网格而不是CPU,这样每次渲染前,所有模型都使用的是同一个模型,同一个材质球,同一个Shader着色器,符合了开启GPU Instancing的条件,只要提交一次模型数据,就能渲染1000个模型并且有动画,瞬间降低1000个Drawcall到个位数字。

此时,我们不再需要骨骼了,也不需要SkinnedMeshRenderer了,只需要MeshRenderer来渲染模型就可以了,动画里的顶点变化交给了着色器去做,也可以认为是变相的顶点动画。

这个着色器并不复杂,只是比普通的顶点着色器在传入参数时,多了个变量‘顶点索引’,根据这个顶点索引,来计算得到传入的贴图中的顶点坐标。

伪代码:

    v2f vert(appdata v, uint v_index : SV_VertexID)
    {
            UNITY_SETUP_INSTANCE_ID(v); // gpu instance

            float f = _Time.y / _AnimLength;
            fmod(f, 1.0);

            float animMap_x = (v_index + 0.5) * _AnimWidth;
            float animMap_y = f;

            float4 pos = tex2Dlod(_AnimTexture, float4(animMap_x, animMap_y, 0, 0));

            v2f o;
            o.uv = TRANSFORM_TEX(v.uv, _MainTex);
            o.vertex = UnityObjectToClipPos(pos);
            return o;
    }

    fixed4 frag (v2f i) : SV_Target
    {
            fixed4 col = tex2D(_MainTex, i.uv);
            return col;
    }

顶点着色器中,用时间和动画长度计算出y的位置,也就是行数,再用顶点索引计算出列数,从而得到动画数据贴图中属于自己位置的像素,也就是顶点的坐标信息。

取出这个像素后,就是这个顶点的坐标了,与世界坐标轴转换后即可使用。Shader中没有复杂的公式和原理,就是从图片中取出值来作为坐标去传递。

我们用这种操作方式,利用GPU Instancing 的功能,达到了合并Drawcall的目的,但同样也有很多缺陷,比如最重大的缺陷是,每个人物的动画都是一样的,不会错开来,也就是同一时间,很多模型,做相同的动作。
又比如精度问题,如果要求模型动画有好的表现,就必须提高图片的精度,因为每个像素RGB的颜色就代表了顶点坐标信息,如果要求GPU支持float类型的贴图,一般需要gles3.0以上级别的机器,虽然现在也已经算比较普遍,但毕竟也有部分底端机器无法实现,是否降低动画精度,或者放弃些低端机也需要根据项目不同而深思熟虑下。
LOD,动画LOD,网格LOD

借用GPU Instancing 的优势来合并Drawcall有好有坏,最大的坏处就是单个模型无法播放属于自己专有的动画而与其他相同的模型错开来。

无法错开来播放动画,这个是最糟糕的。想要错开来播放属于自己的动画,那么事情又回来了我们最初的原点,骨骼蒙皮动画。

对于1000个骨骼动画,并且每个人都必须有属于自己的动画序列,不能合并模型,不能合并材质球,只能用1000个Drawcall来支撑这巨大的消耗。

也不是没有办法,不过首先我们要‘认命’,这1000个Drawcall是躲不过了,但可以退而求其次,用方法去降低消耗。

骨骼蒙皮动画的最大消耗是,计算网格变化。那么这个计算网格的变化,究竟有哪些因素决定的?

我们再一次回到了基础知识的原点,网格的变化是由,骨骼点与顶点的权重数据计算得到的。

也就是说,有多少顶点,有多少骨骼,有多少权重数据,就有多少CPU要消耗。CPU的消耗与这三者任何一个都成正比。

也就是说,顶点越少,骨骼数越少,权重数据越少,CPU的消耗就越少。

但是顶点数少了,模型就不那么精细了,而骨骼数少了,动画就补那么精细了,而权重数据少了,网格变化就不那么精细了。

这是要用画面的质量来换取性能啊,伤筋动骨的事情还是少干干,毕竟游戏最重要画面质量不可动摇,这就相当于拿生命换钱一样,不可为。

我们还有LOD(Level of Detail)可以用,用远近的视觉差来优化性能开销,用内存来换取CPU。

LOD的视觉差是利用,离摄像机太远的东西精细不精细无法分辨,精细和粗糙,在距离远的情况下效果是同样的。

就这个视觉差效果,我们可以这么做LOD:

第一,为每个模型准备3-5套简化的模型,这些模型都一定以及肯定要带有骨骼权重数据,否则无法与骨骼关联播放动画,当摄像机与模型的距离拉远时启用较简单的网格,减少顶点数量。当摄像机拉近时则再次启用复杂的网格,加强表现。
第二,同样的手法,准备多套骨骼,当摄像机拉远时启用较为简单的骨骼,减少骨骼数量。当摄像机拉近时则再次启用复杂的骨骼,加强表现。
第三,SkinnedMeshRenderer的 SkinQuality 是决定顶点受多少根骨骼(最多4根)影响的变量,当摄像机拉远时设置为2根或者1根,当摄像机拉近时再恢复过来。

LOD也可以用到极致,LOD不只为了静态模型服务的,也同样可以为动画模型服务,虽然我们并没有用LOD降低任何Drawcall,但我们还是同样降低了很大的CPU开销,和降低Drawcall相比也有着异曲同工之妙。

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

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

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

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号