

Unity Open Day 上海站-游戏专场 | DOTS在角色换装及动画上的实践
Unity技术博客
官方技术干货、社区动向、学习信息、活动资讯
阅读 1018
2023年9月18日
在Unity技术开放日上海站游戏专场中, 巨人网络技术专家 Laugh 和 Ricky 基于自身项目带来演讲《DOTS在角色换装及动画上的实践》。
【点击下载PPT】 https://plastichub.unity.cn/unity-tech-cn/Unity-Openday/src/branch/main...Unity%e4%b8%8a%e6%b5%b7%e6%8a%80%e6%9c%af%e5%bc%80%e6%94%be%e6%97%a52023/%e6%b8%b8%e6%88%8f%e4%b8%93%e5%9c%ba/3-Laugh%20&%20Ricky-DOTS%e5%9c%a8%e8%a7%92%e8%89%b2%e6%8d%a2%e8%a3%85%e5%8a%a8%e7%94%bb%e4%b8%8a%e7%9a%84%e5%ae%9e%e8%b7%b5.pdf
Laugh:大家下午好,我是Laugh,来自巨人网络,接下来我和我的搭档Ricky一起和大家聊一聊DOTS方面的应用。我们这个项目主要基于DOTS做了角色及换装的一些功能。
在大概五年前,Unity推出了DOTS相关的技术栈,当时出的DEMO也比较震撼。我们当时做了一些DEMO尝试了一下,确实它的性能方面还是超出预期的。2022年Unity官方发布了消息,预计在今年,2023年发布正式版,所以去年有这个信心去尝试DOTS相关的技术,也想达到性能上更优的表现。
我们使用DOTS的内容主要是两块,一个是角色换装动画,还有场景相关的内容。当时国内相关的资料比较少,国外也不多,当时参考的项目是一个小的DEMO,所以经过一段时间的调研,在两个前提下,一个是不改变原有美术和策划制造流程和生产管线,另一个是保证生产效率,我们做了几个方面的处理。
下面是我们目前完成的一个DEMO,这是我们实际的一个战争场景。当时我们要达到一个百人PVP的效果,每个角色都是一个真实的玩家,每个玩家大概有5-6个部位,每个部位都有一些渲染效果。针对这样的需求,DOTS当时还没有相关的技术方案,特别是动画。有些动画是独立部位的动画,这个也比较容易做,但是对于多部位的处理,当时还没有成熟的方案。我们基于前面几个前提,整体分析了原有美术资源的数据格式。
因为DOTS本身是基于DOD,我们应用DOD的思路对原有的资源进行解构。首先对多部位动画进行解构,面向数据进行设计,主要是把整个原有动画数据化。接下来我们对Animator进行解构和复现,然后对Animation进行解构和复现,最后我们基于ECS来做动画的驱动,最后基于一些逻辑来实现换装。因为我们实现整个动画的细节很多,特别是动画的效果,所以又做了一些细节的优化。

多部位动画解构复现
首先聊一下多部位动画的解构和复现。我呈现了大家经常见到的动画结构,上面是整个骨骼点的动画设计,下面是骨骼挂点,由骨骼的动画进行驱动的。然后我们抽象结构数据。

具体一点,我们先抽象了一个动画的层级结构,刚才基于GameObject的资源结构是可以层级嵌套的,但是在早期的ECS是没有办法嵌套的,所以我们当时进行数据上的嵌套。有用的大概分了四层,最底层是基于Part层(部位,比如头发、武器、配件),Part层挂在Body上。Body是身体整体的骨骼驱动,Body挂在Horse坐骑上,Horse挂在交通工具上,最后加了一个Container,一方面预留,另一方面做顶级容器用。

每个部位的定义很简单,中间两个字段,第一个段是每个部位的ID,这个部位ID不是简单的身体、头发、武器,还包含身体的阴影,我们也定为一个部位。甚至还有X光效果,我们也定义一个部位。第二个字段是单独的entity,一方面是为了挂组件,另一方面是为了独立的渲染。

这个是我们整个部位列表,为什么这样的设计呢?在Struct结构中,用NativeArray或NativeList非常难操作,我们基于部位本身是有限的,所有部位加起来再加上各个部位效果的延伸,总的加起来不超过30个,一方面看起来比较直观,方便维护,也方便更多人能看懂。
第一个部位叫身体,我们用GPUSKIN来做,基于骨骼动画进行GPU驱动。Body的X光效果、包括阴影我们也设计成一个独立的部位,这样的好处是可以直接用简模来做,节省了渲染顶点数。我们还使用了基于GPU的顶点动画,一般在顶点数小于1200的部位上运用,顶点数过高的或者基于骨骼动画的,我们一般使用GPUSKIN,避免动画贴图内存占用过大。
武器有些带动画、有些不带动画,比如拉弓带动作,但一般的刀剑没有动画。每个部位的效果其实都可以独立去做部位,听起来是有点复杂,其实真正做起来它都是线性的复杂度,不会指数级的增加。

总结一下,每个部位其实都是一个独立的Entity,因为Entity不能嵌套。1.0的时候其实Entity已经有嵌套的一些功能,但是使用起来目前还不是特别直观。然后每一个部位独立进行动画处理和Transform,我们要做很多的驱动运算,包括渲染,也是每个部位独立进行渲染。部位的关联主要是通过数据驱动,整体是基于DOD的思路来做。
阴影也是独立的部位,方便用简模做阴影。还有多Mesh部位需要分开处理。为什么强调独立处理?主要是因为Entity嵌套不是太方便。另一方面,DOTS本身基于Job Systems多线程处理,每个部位甚至同一个部位的不同效果都可以分到不同的线程处理,方便细致优化。强调一下,我们在做URP的时候不太能接受的一点是SRP Batch和Instancing不能同时使用,但是基于Batch Renderer Group可以把两个合批技术同时使用,但还是不能减少带宽占用量。

Animator的解构与复现
第二步——对Animator的解构和复现。一般我们会利用Animator里面的状态机,还有动画剪辑、过度混合,整个数据的描述相对比较直观和简单。像前面整体的GameObject层次结构一样,我们把这些数据转换到一套数据结构里面,加载到内存,通过DOTS里面提供的Blob Asset进行保存,静态处理。

这是我们在System拿到数据之后进行的数据变换,通过矩阵把RTS进行标准处理,这些代码是摘抄的片断。

Animation的解构与复现
第三步,对Animation的解构。我们当时考虑了两种动画驱动方案,第一个是基于DOTS本身的效率,另外是更高效的数学库,还有Burst编译。我们的思路就是把动画变换的每帧的mesh在CPU进行计算,逐帧传到GPU。这个带宽占用很大,后来放弃掉了。

考虑基于GPU的动画,有两种。首先看一下GPUSKIN,主要是最上面的骨骼点,还有Bindpose和BoneWeight。我们把Bindpose数据烘焙到一张BoneMap上,然后把BoneWeight烘到模型UV上。右上方是我们改过的mesh,下方是我们烘的动画贴图,是每一帧骨骼变换的点,内存占用量比较小。

拿到这些数据之后,我们在shader中进行一些骨骼的变换,主要的思路是常见的GPUSKIN。有些插值算法是比较麻烦的,最后使用了Unity自带的一个插值算法,后面我同事会详细介绍这块。

另外一个是Vertex Animation,右边的动画贴图包含了两个,第一个是Vertex就是每帧顶点的变换数据,另外一个是Normal,这两个是连续的、合在一起的。我们的贴图可以形成一个更合理的尺寸,有的时候如果顶点数量不定的话,每个动画的贴图会有一些冗余在,我们把它合在一起冗余会减少很多。

这是顶点变换的一个shader,这个不详细说了,因为网上案例很多。

ECS动画驱动
Ricky:大家下午好!前面我的同事讲了一些都是设计跟原理上的东西,我下面说一下具体ECS和GPUSKIN是如何交互的。
Animator的解构,传统的Animator是通过state的切换来控制动画切换的,最终转成的数据结构只需要动作的名字、起始帧、总帧数、是否循环这几个参数就够了,拿到这些参数去刚才的动画贴图上采样点,就知道每个骨骼每一帧是如何有一个矩阵去控制它运动的,然后把这些数据序列到一些二进制的数据上,最终传到Component供ECS来操作。

这个就是我们实际上用到的控制动画的数据,主要有骨骼数、当前动画的起始帧、总帧和当前的动画帧。最右边的是之前提到过的System的时序问题,人是骑在马上的,武器是在人身上的,头发也是在人身上的,由于Entity没有parent的概念,所以在动画里取到的本地坐标、旋转和变换只是本地的,比如武器在背上,只有背上的local position和rotation,需要转到人身上,然后转到World上来做最终的效果。时序上需要把马的动画先计算完毕,然后把马身上人的挂点数据传给人,然后人身上的各个挂点传到各个部位,来实现最终的动画效果。

具体传输的方法,就是官网给的一些方法,用MaterialProperty标注Component Data,它会传到shader里对应的属性上。

下面是一张整体的比较核心部分的控制链。从左边开始讲,最开始要创建一个角色,会创建body Entity和各个部位的Entity。Body上最主要有三个部分,一个是Transform,和传统Transform基本没什么差别;另外是MaterialMeshInfo和RenderMeshArray。ECS里面所有单个场景内的所有mesh和render都是在同一个shared RenderMeshArray上列出来,每一个具体如何渲染是根据MaterialMeshInfo里的ID来指定采用Array中的某一个mesh和某一个render。换装、换色就是控制MaterialMeshInfo的ID就好了。
下面动画数据的控制,用前面讲到的float4从Mono端改变动画ID,取序列化的数据,拿到当前动画的帧数跟总帧数,然后传到Component上。然后System里拿到Component数据的变换,通过MaterailOverride到shader里,最终实现骨骼动画的变换。
前面讲到过马身上的人、人身上的武器是通过各个部位的Local变换× LocalToWorld矩阵,最终变成世界坐标的变换,这样来实现的。

换装实现
换装就是通过创建的时候给每个部位绑定Component,然后加载部位资产,就是那些shared Mesh/Material;替换部位就是通过改变那些ID就好了。

下面讲由美术资产如何动态转化成游戏里所用的东西。ECS设计之初,官方推荐把所有资产放到一个SubScene里,然后加载这个SubScene来load prefab。但是我们由于是之前的项目改成DOTS,有一部分需要动态加载的需求,其实也是可以实现的。用WeakObjectReference生成一个弱引用,可以通过动态runtime拿到bake出来的Entity,实例化出来一个,就可以实现动态加载prefab。
Load出来的prefab上面有Mesh和Material,拿到组装出一个Entity,放到RenderMeshArray上,换装也是通过这个来实现的。

这里是一个比较简单的例子,cube和人共用同一个RenderMeshArray,比如说想把左边的人变成右边的cube,只需要把Material Mesh Info改成跟cube一样的-2,-2就可以了。这样也有一些问题,想换装的话,可能需要把这个人物所有的服装都初始化load出来,可能内存上有一些损耗,但切换比传统方式好一些。

细节优化
最后说我们做的过程中遇到的一些难点跟细节。
首先要求美术的骨骼数量只能设定为2,最初有的是4、有的是2,4的话那张图就会大整整一倍,而且动画的观感上也不会好多少,设成2就可以了。
然后Mesh只能用一套UV,第二套用来存顶点依赖的骨路ID&权重,当然你用多套也可以,至少要留出一套。
各个部位挂点放到根节点下K帧。
最后一点是美术跟策划经常不是很开心的地方,一个角色有很多动作,因为所有动作都是合到一张图上的,但凡改了一个动作的一个小小的点,都要把整个角色动作全部都烘焙一遍。当然这个东西也是一键化的,等十几秒就bake完了,其实也不是很麻烦。
后面就是一些策划提的需求,比如动画融合、暂停、截断,包括animator固有的事件音效。
还有一些简模和全模的切换,跟换装的原理是一样的。然后高模与低模共用动画,由于不同设备支持的DOTS数量不是很一样,我们最终会根据机型的高低配去load高模或低模。高模和低模可以共用一张图片播动画,但是我们最初bake是高模和低模会生成两套Mesh、两套material两套图片,会比较费,后面合了一下。
材质半透明、材质特效、模型缩放、部位缩放,也都是跟动画的原理一样,通过MaterialOverride控制shader里的属性去动态改的,不同于传统的去改GameObject上面材质的透明度。

最后一个小点,我们做动画融合的时候最开始想当然地把上一个动画和下一个动画的融合帧线性融合到一起,发现如果这个动作幅度特别大的话,会出现很奇怪的扭曲。后来查了很多资料,最后发现是用了指数形式的函数,黄线是上一个动画的权重,蓝线是下一个动画的权重,它不是一个标准的线性X,而是一个这样的形状。最近我们发现,其实好多函数跟这个形状是类似的,而且效率比这个高,下面是power(时间t,1-t),指数运算总是比较耗好的。后来我们发现正弦值还有一些开根号的函数都跟这个曲线差不多。最终我们用的是正弦值。

Laugh:谢谢Ricky,其实我们在用DOTS过程中也遇到了很多问题,当时也和官方做了很多交流,官方给了很多意见,在此感谢一下。DOTS我们用下来没有像网上说的那么难,虽然从0.51到1.0变化很大,但是它的基本原理都是没有变化的,我们从0.51升到1.0,升的过程中没有遇到太多的门槛,也建议大家可以尝试一下。谢谢大家!
发布于技术交流
1条评论

问
AI
全新AI功能上线
1. 基于Unity微调:专为Unity优化,提供精准高效的支持。
2. 深度集成:内置于团结引擎,随时查阅与学习。
3. 多功能支持:全面解决技术问题与学习需求。

问
AI