我们曾在四年前对于 Unity 的主流模块的性能优化知识点逐一做过讲解,俗称 “小白版”。随着这几年引擎本身、硬件设备、制作标准等等的升级,UWA 也不断更新优化规则和方法并持续输出给广大开发者。作为"升级版"的性能优化手册,【Unity 性能优化系列】将力图以浅显易懂的表达,让更多开发者可以受用。本期就将分享渲染模块相关的知识点。
移动端的优化,渲染是一个逃不掉的话题。作为性能开销的大头,几乎所有的游戏都离不开场景、物体和特效的渲染。如何在优秀的场景视觉效果和流畅的运行中达到最佳的平衡,一直是策划、美术与程序大佬们都头疼的问题。
1、DrawCall
在 GOT Online 的 Overview 模式中,我们可以在渲染模块中看到 DrawCall 曲线,在这个曲线中可以看到具体的的 DrawCall 数量以及 Batch 数量。如下图所示:
目前,我们建议在中低端机型上 Batch 的主体范围(5%~95%)控制在 [0,250] 以内。
在 Unity 中,我们需要区分 DrawCall 和 Batch。在一个 Batch 中会存在有多个 DrawCall,如下图中 FrameDebugger 中可以看到两个默认的 ParticleSystem 合批成了一个 Batch,这样的一个 Dynamic Batch 中就有 2 个 DrawCall。
降低 Batch 的方式通常有动态合批、静态合批、GPU Instancing 和 SRP Batcher 这四种,在 UWA Day 2020 中我们分享了 DrawCall 与 Batch 的关系以及这 4 种 Batching 的使用详解,供大家参考:《Unity 移动游戏项目优化案例分析(上)》。
2、Triangle
通常情况下,Triangle 面片数越高会导致渲染的耗时越高,因此在我们的报告中提供了 Triangle 的使用情况,并有半透明和不透明的区分。一般建议通过 LOD 工具减少场景中的面片数,进而降低渲染的开销。
需要说明的是,此处的面片数量并不是当前帧场景模型的面片数,而是当前帧所渲染的面片数,其数值不仅与模型面片数有关,也和渲染次数相关。例如:场景中的网格模型面片数为 1 万,而其使用的 Shader 拥有 2 个渲染 Pass,或者有 2 个相机对其同时渲染,那么此处所显示的 Triangle 数值将为 2 万。
在渲染模块优化中,很有效的方法是通过 Camera.Render 函数的具体堆栈来定位具体的性能瓶颈。这些函数可以在无论是真人真机还是 GOT Online 报告,都可以在【代码效率】中查看。下面是我们优化时常见的几个函数:
1、RenderForward.RenderLoopJob
在 Camera.Render 展开堆栈中,可以看到 RenderForward.RenderLoopJob 的自身消耗是比较高的,通常是由于 Batch 数量较高导致的。
2、Culling 耗时较高
一般来说,Culling 的耗时在 10%~20% 的范围是比较合理的。一般 Culling 耗时较高的话,可以通过以下几个方面排查:
1)Culling 耗时与场景中的 GameObject 小物件数量的相关性比较大。这种情况建议研发团队优化场景制作方式 ,关注场景中是否存在过多小物件,导致 Culling 耗时增高。可以考虑采用动态加载、分块显示,或者 Culling Group、Culling Distance 等方法优化 Culling 的耗时。
2)如果项目使用了多线程渲染且开启了 Occlusion Culling,通常会导致子线程的压力过大导致整体 Culling 过高。
由于 Occlusion Culling 需要根据场景中的物体计算遮挡关系,因此开启 Occlusion Culling 虽然降低了渲染消耗,其本身的性能开销却也是值得注意的,并不一定适用于所有场景。这种情况建议研发团队选择性地关闭一部分 Occlusion Culling 去测试一下渲染数据的整体消耗进行对比,再决定是否需要开启这个功能。
3、Render.Mesh
Render.Mesh 对应的是无法合批的渲染耗时,它的调用次数对应的是相应的 Batch 数量。下图中,我们可以看到 Render.Mesh 的调用次数为 269,说明场景中有 269 个不透明对象没有进行合批,数量较高。
Render.Mesh 开销过高,通常是由于不能合批的对象较多导致的,可以从如下几点进行优化:
1)对于不透明的渲染队列,建议对 Material 的冗余进行排查,如原本一样的材质球因为实例不同而导致不能合批,可以通过 UWA 的在线 AssetBundle 检测,对 AssetBundle 中的 Material 冗余进行排查。
2)对于半透明的渲染队列,需要区分非 NGUI 与 NGUI 的情况,对于使用 NGUI 的情况,Render.Mesh 的调用有很大概率是由 UI 的 DrawCall 导致的,Render.Mesh 调用次数高说明 UI 的 DrawCall 很可能是偏高的,需要排查是否是图集没有合理的打包导致的。
对于非 NGUI 的情况,那需要考虑半透明的对象是否存在穿插的现象,可以通过调整 RenderQueue 来增大相同 Material 的对象进行合批。
4、ParticleSystem.ScheduleGeometryJobs 与 ParticleSystem.Draw
1)ParticleSystem.ScheduleGeometryJobs,是指在 Culling 之前主线程要等待子线程计算 Particle 的位置,然后才能 Culling。往往在战斗界面开销较高。
对于该函数的优化,建议研发团队考虑在中低端设备上尽可能降低粒子系统的复杂程度,同时尝试通过视域体对其进行预先裁剪,将视域体外部的粒子系统进行 Deactive,从而降低不必要的粒子系统 Schedule 开销。
2)ParticleSystem.Draw 的调用次数对应的是粒子系统的 DrawCall 数量。
如果该函数调用次数过高,建议研发团队考虑减少粒子系统的数量,可参考 UWA 真人真机测试报告【内存管理 - 具体资源信息 - 粒子系统】中的列表进一步分析和优化。
另外,可以通过使用 TextureSheetAnimation 的方式,或者通过修改 Order in Layer 减少粒子渲染的穿插从而增大合批的概率,以此来降低 DrawCall。
5、Shader.CreateGPUProgram
该 API 的 CPU 占用是 Shader 第一次渲染时产生的耗时,其耗时与渲染 Shader 的复杂程度相关。
从下图中我们可以看到,在某一帧中 Shader.CreateGPUProgram 的耗时达到了 203.87ms,这个耗时导致游戏的卡顿。
对此,我们可以将 Shader 通过 ShaderVariantCollection 进行预加载,在加载后通过 ShaderVariantCollection.WarmUp 来触发 Shader.CreateGPUProgram,并将此 SVC 进行缓存,从而避免在游戏运行时触发此 API 的调用,从而避免局部的 CPU 高耗时。
以下资料可供参考:
《一种 Shader 变体收集和打包编译优化的思路》
https://answer.uwa4d.com/question/5da86670e84db43d6efbda72
开启多线程渲染后,主线程的渲染耗时就会有很明显下降,建议研发团队开启。
但需要注意的是,由于我们的线上报告的 CPU 时间占用只统计了主线程的耗时,如果版本开启了多线程渲染,在报告中只能看到主线程的耗时,不利于分析渲染瓶颈。因此我们平时建议大家内部测试的时候,提交两个版本,一个开启多线程渲染,作为 Release 版本的渲染耗时参考,一个关闭多线程渲染,用于详细分析渲染瓶颈。
使用 GPU Instancing 可以一次渲染相同网格的多个副本,但是每个实例可以有不同的参数(例如:Color 或 Scale),以增加变化。在渲染诸如建筑、树木、草等在场景中重复出现的事物时,GPU Instancing 可以有效减少每个场景 DrawCall 数量,显著提升渲染性能。
但是使用 GPU Instancing 有如下注意点:
在一些特殊情况下,大量半透明物体的 GPU Instancing 渲染耗时可能会带来很高的耗时,这点我们在 UWA DAY 2019 的课程《Unity 引擎渲染、UI、逻辑代码模块的量化分析和优化方法》中做了详细解释。
越来越多的团队开始使用 URP 作为渲染管线,从而通过 SRP Batcher 大幅提升 Batch 的合批范围,提升渲染效率。使用 URP 时,渲染函数堆栈会变为:
而在使用 SRP Batcher 时,仍需要注意:
以上就是渲染模块在优化时需要关注的一些问题,如何操作还需要大家结合项目实际情况,同时结合 UWA 服务可以快速地帮助大家定位到性能瓶颈。