[译文]Unity性能优化—图形渲染最佳实践

原文地址: Fixing Performance Problems – 2019.3

引言

本文我们将学习Unity在渲染每一帧幕后所发生的事情,以及有哪些问题会导致渲染卡顿,以及怎么解决这些问题

阅读本文之前,首先要明白对于提升渲染性能,没有一招鲜的办法。影响渲染性能的因素太多:包括游戏类型、设备硬件、操作系统等。重要的是我们通过观察,实践得到数据然后分析数据从而有针对性的解决问题。本文包含一些常见的渲染性能问题和解决办法以及一些扩展链接供你更深层次的了解其原理;当然,很有可能你们游戏中出现的性能问题,这篇文章并没有指出;不过,这篇文章你还是值得看看的,理解事物的本质对解决问题很有帮助

渲染简介

开始之前我们快速的过一遍,看看Unity渲染每一帧时发生了什么,理解整个流程以及每个过程发生的时机会对我们解决性能问题有帮助。渲染流程如下:

  • CPU(central processing unit)计算哪些物体需要渲染以及为这些物体设置渲染状态
  • CPU 发送图形渲染指令给 GPU(graphics processing unit)
  • GPU 渲染物体

“渲染管线(rendering pipeline)”通常用来描述渲染的过程;这十分贴切,高效的渲染就像车间的流水线,无停顿,高效率。渲染每一帧的过程中,CPU都会做如下工作:

  • 检测场景中的每一个物体是否需要渲染;一个物体被渲染需要符合一定的条件。比如:物体必须处于摄像机的可视范围内(注意:哪怕只有一部分处于可视范围,仍然需要渲染整个物体),不在范围内的则剔除,如果想了解更多关于物体剔除方面的内容,请查阅:Understanding the View Frustum
  • CPU收集和计算将要被渲染的物体的相关信息并发送命令给GPU(包括将网格和纹理等传送到显存,以及设置渲染状态,调用图形API),这个过程称之为:Draw Calls
  • CPU 给每一个 Draw Call 创建的数据包,称之为:Batch(批次),批次有时候会包含一些 Draw Call 以外的数据,但这些数据对性能没有什么影响,因此本文将不会讨论这些数据

每一个批次至少包含一个 Draw Call,CPU 针对批次会做如下事情:

  • CPU会发出更改指令,让 GPU 更改渲染状态,这个指令称之为:SetPass Call,SetPass Call 告诉 GPU 使用哪些设置去渲染网格。SetPass Call指令只有在渲染下一个网格的设置和渲染上一个网格的设置不一样时,才会发出
  • CPU通过 Draw Call 命令告诉 GPU,就按照上一次 SetPass Call 的渲染设置去渲染指定的网格
  • 有些情况下,一个批次可能需要不止一个pass,pass是一段shader代码,并且新的 pass 需要更改渲染状态,对于批次中的每个 pass ,CPU必须发送新的 SetPass Call 指令,然后再次发送 Draw Call 通知 GPU 按照新设置的渲染状态去渲染网格

同时,GPU也会做如下事情:

  • GPU 按照 CPU 发送到 Command buffer 内的指令顺序处理
  • 如果当前指令是 SetPass Call,那么GPU更新渲染状态
  • 如果当前指令是 Draw Call,那么GPU根据上一次设置的渲染状态来渲染网格。渲染网格的过程有很多阶段,这里就不一一阐述,不过你可以了解一下:顶点着色器(Vertex Shader)是用来处理网格顶点的,而片元着色器(Fagment Shader)是用来处理每一个像素的
  • 这个过程会重复执行,直到Command buffer内的指令都被执行完毕

大概了解了Unity渲染的简要流程,让我们来考虑渲染的过程中可能会出现的问题

渲染问题

关于渲染,最重要的一点是:每一帧之内,CPU和GPU都要按时完成自己的任务。他们中任何一个任务超时的话,那就会造成渲染问题。渲染问题一般有两个基本原因:1. CPU 约束,渲染过程中,CPU 为每一帧渲染准备数据花费的时间太长,导致渲染瓶颈 2. GPU 约束,渲染数量过于膨大,导致 GPU 渲染一帧需要花费的时间过长

有果必有因,在性能出问题的时候,找出导致性能问题的原因才是首要任务。针对不同的问题,我们才能给出不同的解决方案。修复性能问题,其实也是一项平衡性的工作,比如:牺牲内存用于提高 CPU 性能,牺牲游戏画质解决渲染瓶颈。我们会使用Unity自带的两个工具来定位问题:ProfilerFrame Debugger

CPU瓶颈

基本上,渲染每一帧的过程中,CPU 就干三件事儿:1. 决定渲染哪些物体 2. 为渲染准备好数据以及设置渲染状态 3.发送图形渲染API 。 这三类工作包含很多独立任务,这些任务可能是通过多线程完成的;当这些任务被分配到不同的线程执行时,我们称之为:多线程渲染

Unity的渲染过程中,有三类线程参与:主线程(main thread)、渲染线程(render thread)、辅助线程(worker threads)。主线程用于我们游戏中主要的 CPU 任务(也包括一些渲染任务),渲染线程主要是用来给 GPU 发送指令的,而辅助线程则用来处理单独的任务,比如:物体剔除和网格蒙皮计算。哪些任务执行在哪个线程,取决于我们游戏运行设备的硬件以及游戏的设置。比如:设备的CPU核心数量越多,我们就会线程越多的辅助线程。正是由于这个原因,在目标设备上进行性能分析十分有必要,在不同的设备上,我们的游戏表现可能有天壤之别

由于多线程渲染非常复杂而且非常依赖硬件条件,所以在尝试提升性能的时候,我们要理解是那些任务导致的CPU瓶颈。如果游戏卡顿是因为剔除物体是否在摄像机是窗内的线程超时,那么我们减少 Draw Call 对提升游戏体验,并没有什么卵用

注意:不是所有平台都支持多线程渲染,WebGL就不支持。在不支持多线程的平台,所有的任务都是在同一个线程中执行。如果在这种平台碰到CPU瓶颈问题,那么就试着去优化所有可能对CPU性能有帮助的点

Graphics Jobs

Project Settings ->Player->Other Setting->Rendering-> Graphics jobs 选项可以让Unity将那些本该由主线程处理的渲染任务分配到辅助线程中。在可以使用该功能的平台上,它将带来显著的性能提升(可以开启和关闭这个功能来做性能对比,该功能目前仍是预览版)

发送指令到GPU

发送命令到GPU这个过程,一般是CPU瓶颈的最常见原因。大多数平台这个任务是由渲染线程完成的,个别平台是在辅助线程(比如:PS4)

而最耗时的操作是 SetPass Call 指令,如果C PU 性能问题是因为发送指令到 GPU 引起的,那么减少 SetPass Calls 的数量通常是最有效的改善性能的办法。我们可以通过 Profiler 查看到 SetPass Call 和 批次的数量。多少 SetPass Call 会造成性能问题,这个和游戏运行设备的硬件有很大关系,在高端PC上可以发送的 SetPass Call 数量远大于移动设备

SetPass Call 数量以及对应批次数量取决很多因素,稍后会详细介绍。然而一般来说:

  • 减少批次数量 或者 让更多物体共用相同的渲染状态,通常会减少 SetPass Call 数量
  • 减少 SetPass Call 数量,通常能提升 CPU 性能

减少批次能提升 CPU 性能,即便减少批次并没有减少 SetPass Call 的数量。因为CPU 能够更有效的处理单个批次。一般来说有以下方式,能减少批次和 SetPass Call 的数量:

  • 减少需要渲染的物体数量,能减少批次和SetPass Call 的数量
  • 减少每个物体被渲染的次数,能减少 SetPass Call 的数量
  • 合并需要渲染的物体,可以减少渲染批次

减少需要渲染的物体数量

这是减少批次和SetPass Call最简单的方法了,有以下方法可以降低渲染物体的数量:

  • 直接减少场景中的可见物体数量。比如:场景中有很多人物需要渲染,我们可以选择性的只渲染一部分人
  • 使用摄像机的 Far Clipping Planes 属性来降低摄像机的绘制范围。这个属性表示距离摄像机多远的物体不再被渲染
  • 通过 Occlusion culling 技术来关闭被其他物体完全遮挡的物体,这样就不用渲染被遮挡的物体了。请注意:这个功能不适用于所有场景,但是在某些场景它确实带来很可观的性能提升。另外,我们可以通过手动关闭物体来实现自己的遮挡剔除。比如:如果我们场景中包含一些过场切换才会出现的物体,那么在过场播放之前或者结束以后,就应该手动隐藏他们以减少需要渲染的物体。手动剔除往往比Unity 提供的动态剔除更高效

减少每个物体渲染的次数

实时光,阴影,反射等效果可以极大的提高游戏的真实感;但其实这些效果都非常消耗性能,因为这些效果会导致物体被渲染多次

我们对游戏的渲染路径的设置,对这些功能的性能消耗也有实质性的影响。渲染路径:表示绘制场景的时候,渲染计算的执行顺序;不同的渲染路径最主要的区别是他们怎么处理实时光,阴影和反射。通常来说,如果我们的游戏运行在比较高端的设备上,并且运用了实时光,阴影和反射,那么延迟渲染(Deferred Rendering)是比较好的选择。向前渲染(Forward Rendering)适用于不使用以上功能的低端设备。实时光,阴影和反射是个比较复杂的功能,最好能研究相关主题充分了解实现原理,可参考:灯光和渲染-2019.3

不管选择哪种渲染路径,实时光,阴影,反射都会极大的影响性能,所以优化他们十分有必要:

  • 动态光照是个非常复杂的主题,它超出了本文的讨论范畴,你可以参考这篇文章去深入了解:Shadow troubleshooting
  • 动态光照非常消耗性能,当我们场景中包含很多静态物体时,我们可以利用烘焙技术去预先计算场景的光照,生成光照贴图,详细可以点击:Lighting
  • 如果我们想使用实时阴影,你可以通过这篇文章来提交性能:Shadow Cascades。文章介绍了阴影的设置,以及这些设置怎么影响性能
  • 反射探头创建真实的反射,但是会影响合批。因此我们最好最小化的使用它,反射探头的优化请参考:Reflection Probe performance

合批物体

一个批次可以包含多个物体的数据,为了能合并批次,物体必须满足如下条件:

  • 共享相同的材质
  • 一样的渲染状态(比如:纹理,shader等)

合并物体确实可以提高性能;同时我们也要分析,合并物体带来的性能提高会不会反而造成其他方面更大的性能消耗。关于合批可以参考我上一篇文章:静态批处理、动态批处理、GPU Instancing

纹理图集(Texture Atlasing),是把大量的小尺寸的纹理合并成一张大的纹理。它通常在2D游戏以及UI系统中使用,Unity内置了图集工具:Sprite Packer

我们也可以手动合并共享材质和纹理的网格(可通过编辑器直接操作,也可以在运行时调用API操作),但是我们必须意识到,有可能我们手动合并以后,本来会被剔除而无需渲染的物体,因为共享相同的网格而必须渲染,而这些物体可能还有光照,阴影等更加消耗性能的操作,所以要权衡利弊

在脚本中,我们必须小心使用Renderer.material,这个接口会拷贝材质,并返回拷贝后的引用。这样做也会破坏合批,因为它和其他物体不再有相同的材质引用了。如果我们一定要访问合批物体的材质,应该使用Renderer.shareMaterial

蒙皮网格

SkinnedMeshRenderers通常用在网格动画中,渲染蒙皮的任务通常在主线程或者单独的辅助线程(取决于游戏的设置以及目标设备硬件)。渲染蒙皮是个很消耗性能的动作,如果在Profiler中看到是渲染蒙皮对CPU造成性能瓶颈,这里有几个方法我们可以改进性能

  • 考虑是否真的有必要使用蒙皮网格渲染组件,如果物体并不需要运动,尽可能使用普通的网格渲染组件(MeshRenderer)
  • 如果我们只在某些时刻运动物体,我们应该用细节较少的网格,(SkinnedMeshRenderers组件有一个函数BakeMesh,可以用匹配的动作创建一个网格)
  • 关于蒙皮网格的优化,可以参考:Skinned Mesh Renderer。(蒙皮网格消耗是在每个顶点上,因此,使用顶点较少的模型以及减少骨骼数量也可以提高性能)
  • 在某些平台,蒙皮可以被GPU快速处理。如果目标设备GPU比较强,则可以对当前平台开启 GPU Skinning

GPU瓶颈

如果游戏是GPU性能限制,那么首先就得找到GPU瓶颈原因。一般GPU性能最常见的问题是填充率限制,尤其是移动平台。当然显存带宽和顶点处理也可能影响

填充率

填充率是指 GPU 在屏幕上每秒可以渲染的像素数量;如果我们游戏是因为填充率导致的GPU性能问题,那么意味着我们游戏每帧尝试绘制的像素数量超过了 GPU 的处理能力,检查是否填充率引起的GPU性能问题其实很简单:

  • 打开Profiler,注意GPU时间
  • 重新设置渲染分辨率 Screen.SetResolution(width,height,true)
  • 重新打开Profiler,如果GPU性能提升,那么大概率就是填充率的原因了

如果是填充率问题,那么我们有如下几个方法解决这个问题

  • 片元着色器是告诉 GPU 怎么去绘制每一个像素的shader代码,如果这段代码效率低,那么就容易发生性能问题,复杂的片元着色器是很常见的引起填充率问题的原因
  • 如果我们游戏使用了Unity内置的Shader,那么应该使用针对移动平台的 the mobile shaders
  • 如果游戏使用的是Unity的Standard Shader,那么要理解 Unity 编译这些shader是基于当前材质设置,只有那些当前使用的功能才会被编译。这意味着,移除 detail maps可以减少片元着色器的复杂度
  • 如果使用的是定制的shader,那么应该尽量优化它。优化shader是个很大的话题,可以参考:Optimizations
  • Overdraw 是指相同位置的像素被绘制了多次。一般发生在某个物体在其他物体之上。为了理解 Overdraw,我们必须理解 Unity 在场景中绘制物体的顺序。物体的 shader 决定了物体的绘制顺序,通常由 render queue 属性决定。最常见引起 Overdraw 的因素是透明材质,未优化的粒子以及重叠的UI元素。关于Overdraw优化,可以参考:Optimizing Unity UI

显存带宽(Memory bandwidth)

显存带宽是指GPU读写专用内存的速度,如果我们的游戏受限于显存带宽,通常意味着我们使用的纹理太大了,我们可以用如下方法检测是否是显存带宽问题:

  • 打开Profiler,并关注GPU各项数据
  • Project Settings->Quality->Texture Quality,设置纹理质量,降低当前平台的纹理质量
  • 重新打开Profiler,重新查看GPU各项数据。如果性能改善,那么就是显存带宽问题

如果是显存带宽问题,那么我们需要降低纹理的内存占用,针对不同游戏通常有不同的解决方案,这里我们提供几个优化纹理的方法

  • 纹理压缩技术可以同时极大的降低纹理在内存中的占用。Texture Import Setting讲述了纹理压缩格式和各种设置的详细信息
  • Mipmaps,多级渐远纹理是 Unity 对远处物体使用低分辨率纹理。Unity场景视图中的 The Mipmaps Draw Mode 允许我们查看哪些物体适用多级渐远纹理。其实,Mipmap最主要的目的是为了提高质量,如果没有mipmap 纹理很大,采样频率却很小的情况下,模型看去来质量会很差(相邻的两个屏幕像素采样的纹素差的很远,此时会大大降低缓存命中率)

顶点处理

顶点处理是指 GPU 处理网格中的每一个顶点,顶点处理的消耗主要受两个因素的影响:顶点数量以及操作每个顶点的复杂度。有一些方法可以优化这个:

  • 降低网格复杂度
  • 使用法线贴图模拟更高几何复杂度的网格,关于法线贴图可以参考:Normal map
  • 如果游戏未使用法线贴图,在网格导入的设置中,可以关闭顶点的切线,这可以降低每个顶点的数据量
  • LOD,当物体远离摄像机的时候,降低物体网格的复杂度的技术,可以有效的降低 GPU 需要渲染的顶点数量,并且不会影响视觉表现,具体细节请参考:LODGroup
  • 顶点着色器,是一段shder代码,降低它的复杂度,可以提升性能

发表评论

电子邮件地址不会被公开。 必填项已用*标注