角色优化

2022-09-08  本文已影响0人  离原春草

本文将罗列工作中遇到的一些角色渲染优化的技术,这里的优化主要集中在性能的优化上面。

GPU Skinning

早期的动画蒙皮方案都是在CPU中完成的,主要步骤为:

  1. 骨骼变换:根据骨骼的父子关系&动画状态,计算出各个骨骼的变换矩阵,如果存在动画的blend,也是发生在这个阶段。
  2. 角色蒙皮:顶点数据根据其绑定的骨骼,分别进行变换,并将变换后的位置加权输出,即完成了角色动作的变化

这种方式的好处是在一帧之中,甚至多帧之中(如果角色动作保持不变的话),只需要一次蒙皮就能进行多次渲染使用,比如可以用于shadow pass与normal pass等,不过Unity提供的GPU蒙皮通过回写(Vertex Shader完成蒙皮,通过Geometry Shader的Steam Out功能,将蒙皮后的顶点数据写回到内存)也可以实现类似功能。

顺便提一句,GPU数据回写到内存中,在OpenGL中叫做Transform Feedback,不过这个特性在使用上是存在一些问题的,具体可以参考Transform feedback is terrible, so why are we doing it?,总体结论就是,在有Compute Shader的时候,可以考虑放弃这项特性。

根据上面的分析我们知道,对蒙皮结果进行回写在需要进行多遍蒙皮(比如某个角色需要经历多个pass,或者多个角色具有相同的动作)的时候有比较好的作用,且如今最合适的回写方法是通过Compute Shader,而这也是UE5的Skin Cache的基本原理[7]。

借用Compute Shader,甚至可以在未来考虑通过Async Compute在进行PostProcess的时候计算下一帧的蒙皮,从而进一步降低蒙皮消耗;此外,通过Skin Cache,还可以更方便实现动画的URO,比如通过降低蒙皮触发频率,可以做到每隔几帧更新一次,且动作效果是连贯的(如果没有回写,就会出现跳变回原始位置的情况)[7]。

不过回写也不是完美无缺的,其问题就在于会增加一份额外的Mesh数据的内存消耗[7]。

可以改进的地方在于,角色蒙皮时,各个顶点之间的变换是完全独立的,且这部分数目巨大,放在CPU上会存在较大的瓶颈,针对这一点,有两个解决方案:

  1. 将计算在CPU上改成并行完成,比如Unity上,就通过多线程+SIMD来提高速度
  2. 将计算放到GPU上完成,通过Vertex Shader的并行计算完成蒙皮

这里我们着重介绍第二种,这里将蒙皮计算放在GPU的VS中计算的方式就是GPU蒙皮。

GPU蒙皮的好处就是将蒙皮的压力从CPU移动到GPU,而众所周知,GPU的算力增长速度是远超CPU的,因此这种方式是符合趋势的。不好的地方在于每个角色都会触发一个Drawcall(如果一个角色上面有多个材质,就是多个Drawcall),即使多个角色之间是共享骨骼、动作甚至蒙皮的,也无法放到一个Drawcall中绘制。

Skeletal Instancing(实例化蒙皮)

实例化蒙皮方案是基于GPU蒙皮的改进方案,目标是解决GPU蒙皮单个Drawcall只能绘制一个角色的问题。

其做法类似于动态合批,不过这里合批的不是Mesh而是骨骼,这里有一些技术迭代:

  1. 早期的技术方案是,在CPU完成骨骼变换之后,将多个角色的骨骼变换数据合并到一起写入到一个InstanceBuffer(或者贴图,如果担心Buffer空间有限,不过访问速度慢于buffer)中,这样就能通过实例化渲染的方式,将Mesh相同、材质相同的多个角色通过一个Drawcall绘制出来

这种方案的不足在于:

  1. Buffer的空间是有限的,因此一个Drawcall可以绘制的角色数目也是有限的

  2. InstanceBuffer过大,更新消耗较高(包括计算与数据上传),这些负面影响会抵消一部分合批导致的优势

  3. 更新的版本是,在制作的时候,将骨骼的父子关系展开(不再有层级关系,方便GPU并行计算),渲染的时候,将一套骨骼数据(加多套对应于不同角色的动作数据,动作数据包括多个动作以及融合参数等)上传到GPU,通过CS完成骨骼的变换计算(可以做到1D融合,2D的计算复杂度过高,不可控,暂时不考虑),之后从CS直接唤起VS完成渲染(如果有必要的话,还可以将CS的数据取出,通过SkinCache方式供其他Pass使用)。

这个方案相对于前一个方案来说,有如下优势:

  1. 骨骼的计算从CPU转到GPU,通过并行进一步降低了时间消耗
  2. 不再需要上传庞大的InstanceBuffer数据,性能更好
    他的问题在于,融合是在GPU中计算,相对于CPU而言,融合效果会有损失。

这两个方案都存在的限制是,绘制的时候对Mesh&材质有约束,即希望角色Mesh相同(否则无法合批),材质可以合批(比如TextureArray)渲染。

Mesh&材质的约束可以考虑通过动态合批来解决,不过近景角色面数较多,材质也不见得能够合并到一起(如果不是同一母材质,且只有贴图存在区别),因此动态合批也并不是一个确定可用的方案。

[6]中给出了GPU蒙皮+实例化蒙皮的代码Demo,感兴趣的同学可以前往一观。

AnimToTexture(ATT)

AnimToTexture是UE的一个插件,在黑客帝国City Sample场景中,海量角色渲染就是采用的这套方案,其本质上是将角色(SkinnedMesh)当成StaticMesh来渲染,因此可以使用ISM/HISM的合批优化策略。

这个方案的原理可以参考[3]中的介绍,大概对这个文章的内容做一下摘录。

文章要解决的问题是要在写实渲染效果的基础上,实现实时帧率下对数千角色的支持(外加正常的场景、动态光、密集的特效),UE4并无对骨骼模型的实例化渲染支持,意味着角色默认消耗一个drawcall。

这里给出了两种实现思路:
1. 基于烘焙贴图的顶点动画

// Preprocess
for each Character
  for each LOD
    for each Animation
      for each Frame
        bake vertex position into texture

// Runtime - Vertex Shader
vert.pos = VertexAnimTexture.Sample(vert.UV, time)

大概翻译一下,就是对于每个角色的每一级LOD而言,对每个动作的每一帧,我们提前将各个顶点的位置数据烘焙到贴图中,在运行时,只需要对贴图进行采样就能完成动作的变化,本质上是一个顶点动画。

这种方案的问题在于,贴图消耗过高,渲染时显存占用大,借用文章中的数据,4个角色,19个动作,3级LOD,对应于228(1943)张贴图。好处在于在顶点Shader中不需要进行矩阵变换,计算消耗相对较低,如果只有一个角色,且用同一级LOD,那么这个方法会是一个很好的选择(比如在距离非常远的时候,我们只使用最低的一级LOD,且此时所有角色都退化成完全相同的模型&骨骼了,可以考虑这种方法)。

2. 基于烘焙的蒙皮动画

// Preprocess
for each Skeleton Set
  for each Animation
    for each Skeletal
      bake transform into texture

// Runtime - Vertex Shader
for each Skeletal Index
  vert.pos = blendfactors[index] * SkeletalAnimTex.Sample(index, time) * vert.localpos

大概翻译一下就是,贴图中不再存顶点的变换结果,而是存骨骼的变换矩阵(可以用更精简的方式表达),这样只要是同一套骨骼,那就只需要用同一套贴图即可,不需要考虑LOD、角色等的区别,显存占用更小。

最终结果是:用UE4实现了20w角色同场景,在NVidia 1080上全分辨率约0.5ms的角色渲染消耗。

Level of Detail(LOD)

LOD是一个广义的概念,不只是对应于面片数随着距离的减少,还可以用在其他方面,这里列举了可以用于角色性能优化的一些LOD:

参考

[1]. UE5 CitySample的MassAI海量人群绘制
[2]. Large Numbers Of Entities With Mass In Unreal Engine 5
[3]. How To Populate Real-Time Worlds With Thousands Of Animated Characters
[4]. GPU Skinning 加速骨骼动画
[5]. chengkehan - GPUSkinning
[6]. GPU-Skinning-Demo - Github
[7]. What is the purpose of the GPU Skin Cache?

上一篇 下一篇

猜你喜欢

热点阅读