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

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

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

===

有原理又有实践,最好我们能多思考多举一反三。要彻底理解一个知识点不容易,在这条学习的道路上我们不能放弃,即使停下来也要想着什么时候能重新开始。人无完人大家都一样,所有人都要经历失败,放弃,再重新捡起来的过程,谁能更快的重新开始就成了关键,失败是常见的,屡次失败也是常有的,就看谁能在一次次失败后还能继续坚持着。

本篇我们要讲在骨骼动画原理之上讲些更高级的技巧,这些技巧都是基于上篇和上上篇的内容之上的。我们用最简洁的语言回顾了一下基础的知识。

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

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

捏脸

捏脸在网络游戏中泛指对虚拟角色样貌进行DIY的数据操作。捏脸看起来像是很复杂的技巧,在我们剖析一下后就你会觉得它并没有想象中的那么困难。

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

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

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

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

现在挂载在‘根节点’下的部件模型只是静止的不会动的模型,虽然其自身有顶点的骨骼权重数据,但没有骨骼点的数据是无法计算出骨骼变化后的模型变化的。因此我们再把‘根节点’里的骨骼点数据赋值给这些模型部件,让他们能在每帧渲染前根据骨骼点的变化结合自身的骨骼权重数据计算出自身的网格变化情况。做完这些操作后,我们就算成功合成了一个由自己选择的人体部件并带骨骼动画的角色模型实例。

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

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

一种简单的办法就是仍然使用多个材质球进行渲染,在合并Mesh时使用子网格(SubMesh)模式,相当于只减少了SkinnedMeshRenderer组件的数量,并没有减少其他的消耗。另一种办法稍微复杂点,不使用子网格(SubMesh)模型,而是将所有模型合并成一个Mesh网格,使用同一个材质球。不过我们还是得保证有相同Shader的材质球进行合并,不相同的Shader的材质球不合并的原则,以保证角色渲染效果不变。

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

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

其次是更换贴图

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

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

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

由于模型的网格(Mesh)是根据骨骼点来变化的,每个组成网格的顶点都有自己的骨骼权重数据,所以只要骨骼点移动了,它们也跟着移动,骨骼点旋转了,它们也跟着旋转,骨骼点缩放了,它们也跟着缩放。于是我们可以利用这个特性来做一些操作,来让‘捏泥人’更加容易,最后只要记录骨骼移动和旋转或缩放的数据就可以了,在重现时再次将数据重新导入到骨骼,就能呈现出原来玩家捏脸时的样子。

不过问题也来了,骨骼点是随着动画一起动的,动画数据里的关键帧决定了骨骼点的变化,我们实时改变骨骼点位置是无法达到效果的,因为动画数据会强行恢复骨骼点,致使我们的操作变得无效。我们既要整个模型网格仍然依照原来的动画数据去变动,又要用某个骨骼点去影响某些网格怎么办?额外增加一些骨骼点,这些骨骼点是专门为用户可操作服务的骨骼点,并且这些骨骼点不加入到动画数据中。也就是说动画animation中的数据不会有这些特别骨骼点的存在,这也使得在动画播放时这些骨骼点是不会动的。

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

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

改变原始Mesh凹凸形状

只操作骨骼点来改变模型的捏脸效果还是不够的,因为毕竟骨骼点数量不能太多,顶点的骨骼权重数据也是有限的,无法通过增加大量的骨骼点来达到模型复杂变化的效果。于是只能再另寻它方,这次我们回到了最基础的网格变化,由于蒙皮网格在每帧都从原始的网格加上骨骼点的变化数据来计算现在网格的形状的,那么改变原始网格的顶点数据,也同样可以改变网格在动画时的模型变化。

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

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

动画优化

我们前面讲了关于蒙皮动画太消耗CPU的问题,所有蒙皮网格的变化都是由CPU计算得到的。

在Unity3D中有一个 CPU Skinning 的选项,开启后将使用多线程 + SIMD 对蒙皮计算做加速处理,由于每个顶点的变化都是独立于骨骼点之上的,相邻的顶点并不互相影响,因此可以使用多线程将一个模型的网格顶点拆分成多个部分顶点进行计算,多线程的使用将提高蒙皮计算的速度。

而这里的SIMD即Single Instruction Multiple Data,是指单指令多数据流,它能够复制多个操作数,并把它们打包在大型寄存器的一组指令。我们以加法指令为例,通常我们使用的单指令单数据(SISD)在CPU对加法指令译码后,执行部件必须先访问内存取得第一个操作数,之后第二次访问内存取得第二个操作数,随后才能进行求和运算。而在SIMD型的CPU中,指令译码后几个执行部件同时访问内存,一次性获得所有操作数进行运算。SIMD的这个特点使它特别适合于数据密集型运算,在我们游戏开发中SIMD特别适合矩阵运算,我们的蒙皮计算就是大量使用矩阵运算的地方。

但 CPU SKinning 并没有减少运算CPU的运算量只是加速了运算速度提高了运算效率,我们在游戏使用了大量的蒙皮动画来达到丰富效果的目的,而通常所有项目都会极致得用尽动画功能,让游戏看起来很生动,丰富,饱满,火热。这使得设备在游戏项目中承受了巨大压力,如果效果再好性能消耗太大,只有高端机才能承受渲染压力的游戏,就无法对普罗大众产生吸引力,也就无法开启吸引力效应,因此对每个项目来说动画的优化是重中之重。

下面我们就来说说3D模型动画的优化方法和解决方案。

用着色器代替动画

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

其实无论用什么方法只要达到“变化是我们所期望看到的”这个目标都是可行的。想到这里,除了cpu中改变顶点坐标位置,我们还有另一种途径来改变顶点坐标位置,它就是着色器(Shader)中的顶点着色器,它也可以改变网格顶点的位置。于是我们可以用顶点着色器,加上一个适合的顶点变化算法就等于得到一个随着时间变化的模型动画。

用着色器制造的动画这种方式已经很久远了,它也在许多项目中用的比较频繁,最常见就是随风摆动的草,会飘动的旗子,飘动的头发,左右摇摆的树,河流的波浪等等。这些算法不在这里一一讲解,它们大部分这些算法都利用了,游戏时间,噪声算法(noise),数学公式(sin、cos等)来表达顶点的偏移量。

Shader着色器随风飘动的草的例子

    ???

除了顶点动画,我们还可以利用uv来做动画,比如不断流淌的水流就属于uv位移动画,又如火焰效果可以根据不断更换uv范围达到序列帧动画效果的uv序列帧动画,还如不停旋转的面片动画,就可以用uv旋转来代替面片旋转,把CPU的消耗转入到GPU消耗,使一部分计算更为高效。uv动画的具体算法也不再这里讲解。

Shader着色器序列帧动画的例子

    ???

用着色器代替动画实质上,就是用GPU消耗来分担CPU的计算量,因为部分计算在GPU中会更高效,这让两个芯片能更好的发挥其作用,而不是让某一个闲着没事干(GPU很闲或者CPU很闲),另一个则忙的要死。用着色器动画就能充分利用GPU的计算优势,为CPU分担不少计算量。但用前面说的顶点动画,uv动画的算法来代替动画方式毕竟是有限的复杂度,当动画复杂到没有固定算法规律可寻时,就需要某求其他途径了,因此着色器动画的对模型动画的可优化范围是有限的。

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

上面介绍了用着色器算法来动画,实质上它将CPU的消耗转移到了GPU消耗从而使得动画性能得以优化,不过这样做的动画的复杂度是有限的,很多复杂的动画无法用算法来表达。除了将计算量消耗从CPU转移到GPU,我们还有其他方法来优化,这次我们不打算用算法了,我们来场无剑胜有剑的战斗‘没有算法就是最大的算法’。

动画的实质是,每帧显示的内容不一样,而每帧显示的内容不一样,就需要每帧都计算出一个不一样的形状。那么我们能不能不计算呢?可以的。我们可以每帧都准备一个模型,每帧都展示一个已经准备好的不一样的模型,这样就有了每帧都有不同形状的模型渲染形成动画。

例如这一个5秒的蒙皮动画,每秒30帧,总共需要150个画面,我们需要最多准备150个模型来依次在每帧中播放。内存和硬盘的代价很大,原本一个模型只要一个模型网格就够了,现在要准备150个网格,内存的代价是巨大的。这就是内存换CPU的想法,到底值不值得这么做呢?

假设这个场景只有2-3个模型在播放这个动画,那么为了这2-3个模型动画,我们就需要额外准备150个模型来播放动画,本来只要一个模型+骨骼就可以办到的事情,我们却要用150个模型来代替,加载这150个模型也是需要时间的,更何况内存额外加大了150倍,确实不值得。那么我们又假设,这个模型同时播放的这个动画的数量非常多,例如20个以上,这20个模型都需要计算机每帧通过模型+骨骼的方式计算出一个模型的变化形状,而且要重复计算100次,这时我们再用150个模型来代替这每帧持续的CPU消耗就非常值得了。

具体步骤为:

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

前面说的离线制作每帧的模型网格,再在每帧中渲染不同的网格来实时渲染动画的效果,这种方式确实在当需要大量渲染相同动画的场景中起到了很大的优化作用,它用内存节省了骨骼计算蒙皮的CPU消耗。不过这种方式毕竟是还是会有大量的Drawcall存在,每个模型至少有一个Drawcall,100个模型就会有100个Drawcall,GPU的压力依然没有减少。那么有没种方法合并绘制相同模型以及动画的Drawcall呢?利用GPU Instancing 这个GPU特性。

什么是GPU Instancing?这是GPU显卡一个特性,大部分图形API都提供的一种技术,其表象是假如我们绘制1000个物体,它只将模型数据以及1000个坐标提交给显卡,这1000个物体不同的位置,状态,颜色等等他们将整合成一个per instance attribute的Buffer给GPU,在显卡上区别绘制。这样做因为只需要提交一次,这大大减少提交次数,这种技术对于绘制大量的相同模型的物体由于有硬件实现,效率高,更灵活,也避免了合批而造成内存浪费。这样一来我们可以根据 GPU Instancing 来实现骨骼动画的多实例渲染。

GPU Instancing 最重要的一个特点是只提交一次就可以绘制1000个物体,把原本要提交1000次的流程,简化成了只需要提交1次,1000个Drawcall瞬间降为了1个。当然没那么简单,它是有条件的,首先条件是模型的着色器(Shader)要支持GPU Instancing,其次是这1000个模型他们位置、角度可以不一样,但都使用同一个网格数据。其实 GPU Instancing 的条件有点苛刻,Shader要相同,材质球要相同,网格要相同(就是传入的网格不能有变形的,就是不能有动画)。

对于我们来说,如果用GPU Instancing 来优化渲染就不能再使用 SkinnedMeshRender 和 Animator或Animation来计算蒙皮了,那怎么办呢?

前面我们提高离线制作模型的方法来准备150个模型,每帧都渲染一个从而省去了计算骨骼蒙皮的CPU消耗。现在我们把150个模型的网格顶顶啊数据换成贴图形式的数据,把这150个模型的网格顶点数据都一股脑放入一张贴图里去,让这张图总共有150行,每行都写入了一个的完整的网格顶点数据,让每个像素数据的 RGB 都分别代表 xyz 的顶点坐标,这样的话如果一个网格顶点总共有3000个顶点,那么每行就有3000个像素,总共150行,这张贴图就是 3000 * 150 大小。

将动画的所有帧下的模型网格顶点数据制作成一张贴图后,我们要让这张图在着色器中起作用,这相当于是将所有动画帧下的网格的顶点数据传入着色器(Shader),着色器根据传入的图中的每个像素去偏移顶点,每帧都换一行从而形成了每帧都不一样的模型动画。我们用着色器去改变顶点坐标,就相当于让GPU去改变网格省去了CPU的提交渲染的消耗。就这样我们把贴图当做动画顶点数据交给着色器去渲染,每次渲染时所有模型都使用的是同一个模型,同一个材质球,同一个Shader着色器,符合了开启GPU Instancing的条件,即我们可以只要提交一次模型数据,就能渲染1000个模型并且有动画,瞬间降低1000个Drawcall到个位数字。具体 GPU Instancing 的工作原理我们将在渲染章节中讲解。

至此我们在实时渲染时不再需要计算骨骼了,同时也不需要SkinnedMeshRenderer了,只需要普通的MeshRenderer来渲染模型就可以了,动画里的顶点变化交给了着色器去做,将CPU的消耗从线上转移到了线下,同时利用 GPU Instancing 的特性更高效的渲染了图形。

这里用于 GPU Instancing 做动画的着色器并不复杂,只是比普通的顶点着色器在传入参数时多了个变量‘顶点索引’,根据这个’顶点索引‘变量来计算得到传入的贴图中的uv位置从而在RGB中得到顶点坐标。

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

        // 根据时间获得数据的y轴位置
        float f = _Time.y / _AnimLength;
        fmod(f, 1.0);

        // 计算 uv位置
        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;
        //计算模型贴图uv
        o.uv = TRANSFORM_TEX(v.uv, _MainTex);
        //计算顶点位置
        o.vertex = UnityObjectToClipPos(pos);
        return o;
}

fixed4 frag (v2f i) : SV_Target
{
        // 根据uv上色
        fixed4 col = tex2D(_MainTex, i.uv);
        return col;
}
    代码来源于github

上述Shader代码就是对模型动画 GPU Instancing 的实现,它先计算得到当前需要展示哪一行帧数据,然后计算数据行中当前顶点的数据位置,即数据贴图中的uv,根据计算获得的数据uv位置提取顶点坐标,之后就是顶点着色器在传统意义上的贴图uv位置计算与顶点投影计算,在片元着色器上很简单只是根据uv位置从贴图中提取颜色而已。

顶点着色器中,用时间和动画长度计算出y的位置也就是数据在贴图中的行位置,再用顶点索引计算出数据在贴图中的列位置,从而得到动画数据贴图中属于自己位置的像素,取出这个像素信息就等于得到了这个顶点的坐标了,与世界坐标轴转换后即可使用。Shader中没有复杂的公式,就是从图片中取出值来作为坐标去传递。

上面我们用GPU Instancing 的GPU 特性,将数据从

CPU端转移到了GPU端并达到了降低 Drawcall 的目的。但只是这样还不够,如果只是使用普通的材质球嵌入Meshrender的方式,就会使得每个人物的动画都是一样的,不会错开来,同一时间很多模型做相同的动作。如果这时使用不同的材质球实例来达到不同的动画时差后又会增加了drawcall,为了不增加drawcall,我们就需要用Unity3D的 GPU Instance 接口向着色器传入动画开始位置的数据。即用 Graphics.DrawMeshInstanced 将数据传入需要 Instancing 的 Mesh,在调用前,我们将准备好的所有模型的坐标数据、顶点贴图数据、动画状态数据等传入材质球中,从而实现不同的动画的时差。

举例传入不同的模型动画数据:

    ???
除了各模型在动画播放时的差异数据,精度问题也值得关注,如果要求模型动画有好的表现,就必须提高图片的精度,因为每个像素RGB的颜色就代表了顶点坐标信息,如果要求GPU支持float类型的贴图,就需要Open GL ES 3.0以上级别的设备,虽然现在的手机设备的Open GL ES 3.0也算比较普遍,但还是有小部分低端手机设备无法实现,是否考虑降低动画精度,或者根据高中低端机的切换动画模块值得考虑。另外如果我们使用 GPU Instancing 优化模型动画,在融合动画方面则仍然束手无策,这使得动画表现会比较生硬。
离线制作LOD动画与LOD网格

前面我们说了使用 GPU Instancing 来优化同一个物体在场景中渲染,这使得Drawcall降低了很多,但仍然无法避免网格面片数太多的问题,我们仍然要为100个模型在场景中的渲染付出很多渲染巨大数量面片的代价。这对于一个100个由1万个三角面组成模型来说,在场景中渲染时巨大的负担。

LOD就是一个很好的解决面片渲染负担过重的方案,我们可以根据不同的机型来加载’高、中、低‘不同等级的资源,从而实现降低面片渲染的负担。无论我们使用传统的骨骼动画实时计算网格顶点的方式,还是通过 GPU Instancing 来提高动画性能,LOD都可以做到让性能再次提升。

我们再一次回到基础知识的原点,传统骨骼动画计算的网格的变化是由,骨骼点与顶点的权重数据计算得到的。也就是说,顶点数量越多骨骼数量越多有效权重数据越多,CPU消耗的也就越多,CPU的消耗与这三者任何一个都成正比。反过来也是一样,顶点越少骨骼数越少有效权重数据越少,CPU的消耗的就越少。

不过我们仍然要注意的是,顶点数少了模型就不那么精细了,而骨骼数少了动画就补那么丰富了,而有效权重数据少了网格变化就不那么细腻了。因此我们需要对高、中、低设备进行判断,对于低阶设备则使用低档资源以保证性能优先画面流畅,对于高阶设备则使用高档资源以丰富画面。

当然高、中、低资源都需要我们离线制作,完成后在场景使用前选择并加载进来,如果使用传统的骨骼动画,则我们需要准备3套模型3套骨骼动画,如果使用 GPU Instancing 则使用需要3套模型3套动画数据贴图。

很多情况下场景内的静态物体都可以使用LOD来实时切换模型精细度等级,那么实时切换动画的高、中、低资源是否可取。传统的实时LOD(Level of Detail)用远近的视觉差来优化性能开销,用内存来换取CPU。LOD的视觉差是利用离摄像机太远的东西精细度无法分辨,这使得替换成更粗糙的模型,在距离远的情况下效果差别很小。

在实时切换动画LOD时,我们可以这么做:

第一,每套模型都分别有高、中、低三套模型与骨骼动画,在渲染时判断摄像机与物体的距离来决定使用哪一套模型。
第二,当切换模型时,把被切换的资源隐藏,将新的资源展示出来并初始化,初始化时要把当前的动画状态还原到新的LOD动画模型上,以保证动画无缝切换。
第三,当前动画状态一致还不够,还需要还原动画融合的表现,以使得切换前后表现一致,这可能是一个大的麻烦,我们需要先将动画切换到前置的动画再对后置动画进行融合。

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

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

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

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

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

    Copyright attention

    Please don't reprint without authorize.

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

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