本文将罗列工作中遇到的一些角色渲染优化的技术,这里的优化主要集中在性能的优化上面。
GPU Skinning
早期的动画蒙皮方案都是在CPU中完成的,主要步骤为:
- 骨骼变换:根据骨骼的父子关系&动画状态,计算出各个骨骼的变换矩阵,如果存在动画的blend,也是发生在这个阶段。
- 角色蒙皮:顶点数据根据其绑定的骨骼,分别进行变换,并将变换后的位置加权输出,即完成了角色动作的变化
这种方式的好处是在一帧之中,甚至多帧之中(如果角色动作保持不变的话),只需要一次蒙皮就能进行多次渲染使用,比如可以用于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上会存在较大的瓶颈,针对这一点,有两个解决方案:
- 将计算在CPU上改成并行完成,比如Unity上,就通过多线程+SIMD来提高速度
- 将计算放到GPU上完成,通过Vertex Shader的并行计算完成蒙皮
这里我们着重介绍第二种,这里将蒙皮计算放在GPU的VS中计算的方式就是GPU蒙皮。
GPU蒙皮的好处就是将蒙皮的压力从CPU移动到GPU,而众所周知,GPU的算力增长速度是远超CPU的,因此这种方式是符合趋势的。不好的地方在于每个角色都会触发一个Drawcall(如果一个角色上面有多个材质,就是多个Drawcall),即使多个角色之间是共享骨骼、动作甚至蒙皮的,也无法放到一个Drawcall中绘制。
Skeletal Instancing(实例化蒙皮)
实例化蒙皮方案是基于GPU蒙皮的改进方案,目标是解决GPU蒙皮单个Drawcall只能绘制一个角色的问题。
其做法类似于动态合批,不过这里合批的不是Mesh而是骨骼,这里有一些技术迭代:
- 早期的技术方案是,在CPU完成骨骼变换之后,将多个角色的骨骼变换数据合并到一起写入到一个InstanceBuffer(或者贴图,如果担心Buffer空间有限,不过访问速度慢于buffer)中,这样就能通过实例化渲染的方式,将Mesh相同、材质相同的多个角色通过一个Drawcall绘制出来
这种方案的不足在于:
-
Buffer的空间是有限的,因此一个Drawcall可以绘制的角色数目也是有限的
-
InstanceBuffer过大,更新消耗较高(包括计算与数据上传),这些负面影响会抵消一部分合批导致的优势
-
更新的版本是,在制作的时候,将骨骼的父子关系展开(不再有层级关系,方便GPU并行计算),渲染的时候,将一套骨骼数据(加多套对应于不同角色的动作数据,动作数据包括多个动作以及融合参数等)上传到GPU,通过CS完成骨骼的变换计算(可以做到1D融合,2D的计算复杂度过高,不可控,暂时不考虑),之后从CS直接唤起VS完成渲染(如果有必要的话,还可以将CS的数据取出,通过SkinCache方式供其他Pass使用)。
这个方案相对于前一个方案来说,有如下优势:
- 骨骼的计算从CPU转到GPU,通过并行进一步降低了时间消耗
- 不再需要上传庞大的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:
- 模型LOD,随着距离的增加而减少面数
- 材质LOD,随着距离的增加,将多个材质合并成一个(类似于HLOD),同时降低shader的计算复杂度
- 骨骼LOD,随着距离的减少与面数的减少,同步减少角色的骨骼,虽然减少骨骼在ATT中会需要新增一套动画烘焙贴图,但是
- 更新频率LOD,降低动画更新频率,可以参考UE的Update Rate Optimization(URO)(【UE】性能优化策略集锦)或者UE的Anim Budgeter(可以理解为逻辑LOD与更新频率LOD的结合)
- 逻辑LOD,降低逻辑计算复杂度,如[2]中提到的对于近景处角色会走插值来实现平滑移动,远景可能就直接SetPosition完事。
参考
[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?
网友评论