16 Unity中的渲染优化技术
本章中,我们将阐述一些 Unity 中常见的优化技术。这些优化技术都是和渲染相关的,例如,使用批处理、LOD 技术等。
游戏优化不仅是程序员的工作,更需要美工人员在游戏的美术上进行一定的权衡。例如,避免使用全屏的屏幕特效,避免使用计算复杂的 Shader,减少透明混合造成的 overdraw 等。也就是说,这是由程序员和美工人员各个部分共同参与的工作。
1 移动平台的特点
和 PC 平台相比,移动平台上的 GPU 架构有很大的不同。由于处理资源等条件的限制,移动设备上的 GPU 架构专注于尽可能使用更小的带宽和功能,也由此带来了许多和 PC 平台完全不同的现象。
例如,为了尽可能移除那些隐藏的表面,减少 overdraw(即一个像素被绘制多次),PowerVR 芯片(通常用于 IOS 设备和某些 Andriod 设备)使用了**基于瓦片的延迟渲染(Tiled-based Deferred Rendering,TBDR)**架构,把所有的渲染图像装入下一个瓦片中,再由硬件找到可见的片元,而只有这些可见片元才会执行片元着色器。另一些基于瓦片的 GPU 架构,如 Adreno(高通的芯片)和 Mali(ARM的芯片)则会使用 Early-Z 或相似的技术进行一个低精度的深度检测,来剔除那些不需要渲染的片元。还有一些 GPU,如 Tegra(英伟达的芯片),则使用了传统的架构设计,因此在这些设备上,overdraw 更可能造成性能的瓶颈。
由于这些芯片架构造成的不同,一些游戏往往需要针对不同的芯片发布不同的版本,以便对每个芯片进行更有针对性的优化。尤其是在 Android 平台上,不同设备使用的硬件,如图形芯片、屏幕分辨率等,大相径庭,这对图形优化提出了更高的挑战。相比于 Android 平台,IOS 平台的硬件条件则相对统一,读者可以在 Unity 手册的 IOS 硬件指南中找到相关的资料。
2 影响性能的因素
首先,在学习如何优化之前,我们得了解影响游戏性能的因素有哪些,才能对症下药。对于一个游戏来说,它主要需要使用两种计算资源:CPU 和 GPU。它们会互相合作,来让我们的游戏可以在预期的帧率和分辨率下工作。其中,CPU 主要负责保证帧率,GPU 主要负责分辨率相关的一些处理。
据此,我们可以把造成游戏性能瓶颈的主要原因分成以下几个方面:
- CPU
- 过多的 draw call
- 复杂的脚本或者物理模拟
- GPU
- 顶点处理
- 过多的顶点
- 过多的逐顶点计算
- 片元处理
- 过多的片元(既有可能是由于分辨率造成的,也可能是 overdraw 造成的)
- 过多的逐片元计算
- 顶点处理
- 带宽
- 使用了尺寸很大且未压缩的纹理
- 分辨率过高的帧缓存
对于 CPU 来说,CPU 在每次通知 GPU 进行渲染之前,都需要
- 提前准备好顶点数据(如位置、发现、颜色、纹理坐标等)
- 调用一系列 API 把它们都放到 GPU 可以访问到的指定位置
- 调用一个绘制命令,通知 GPU 渲染,
这样一次调用一个绘制命令,就会产生一个 draw call。过多的 draw call 会造成 CPU 的性能瓶颈,这是因为每次调用 draw call 时,CPU 往往都需要改变很多渲染状态的设置,而这些操作是非常耗时的。如果一帧中需要的 draw call 数目过多的话,就会导致 CPU 把大多数时间都花费在提交 draw call 的工作上面了。还要物理、布料模拟、蒙皮、粒子模拟等都可能造成 CPU 瓶颈,这些都是计算量很大的操作。
对于 GPU 来说,它负责整个渲染流水线。从处理 CPU 传递过来的模型数据开始,进行顶点着色器、片元着色器等一些列工作,最后输出屏幕上的每个像素。因此,GPU 性能瓶颈和需要处理的顶点数目、屏幕分辨率、显存等因素有关。而相关的优化策略可以从减少处理的数据规模(包括顶点数目和片元数目)、减少运算复杂度等方面入手。
后续章节涉及到的优化技术有:
- CPU 优化
- 使用批处理技术减少 draw call 数目
- GPU 优化
- 减少需要处理的顶点数目
- 优化几何体
- 使用模型的 LOD(Level of Detail) 技术
- 使用遮挡剔除技术(Occlusion Culling)
- 减少需要处理的片元数目
- 控制绘制顺序
- 警惕透明物体
- 减少实时光照
- 减少计算复杂度
- 使用 Shader 的 LOD(Level of Detail)技术
- 代码方面的优化
- 减少需要处理的顶点数目
- 节省内存带宽
- 减少纹理大小
- 利用分辨率缩放
在开始优化之前,我们首先知道是哪个步骤造成了性能瓶颈。而这可以利用 Unity 提供的一些渲染分析工具来实现。
3 Unity 中的渲染分析工具
Unity 内置了一些工具,来帮助我们方便的查看和渲染相关的各个统计数据。这些数据可以帮助我们分析游戏渲染性能,从而更有针对性地进行优化。在 Unity5 中,这些工具包括了渲染统计窗口(Rendering Statistics Window)、性能分析器(Profiler)、以及帧调试器(Frame Debugger)。需要注意的是,在不同的目标平台上,这些工具中显示的数据也会发生变化。
3.1 认识 Unity 5 的渲染统计窗口
Unity 5 提供了一个全新的窗口,即**渲染统计窗口(Redering Statistics Window)**来显示当前游戏的各个渲染统计变量,我们可以通过在 Game 视角右上方的菜单中单机 Stats 按钮来打开它,渲染统计窗口主要包含了 3 个方面的信息:音频(Audio)、图像(Graphics)和网格(Network)。我们这里只关注第二个方面,即图像相关的渲染统计结果。
渲染统计窗口中显示了很多重要的渲染数据,例如 FPS、批处理数目、顶点和三角网格的数目等。下表列出了渲染统计窗口中显示的各个信息
信息名称 | 描述 |
---|---|
每帧的时间和 FPS | 在 Graphics 右侧显示,给出了处理和渲染一帧所需的时间,以及 FPS 数据 |
Batches | 一帧中需要进行的批处理数目 |
Saved by batching | 合并的批处理数目,这个数字表明了批处理为我们节省了多少 draw call |
Tris 和 Verts | 需要绘制的三角面片和顶点数目 |
Screen | 屏幕的大小,以及它占用的内存大小 |
SetPass | 渲染使用的 Pass 数目,每个 Pass 都需要 Unity 的 runtime 来绑定一个新的 Shader,这可能造成 CPU 的瓶颈 |
Visible Skinned Meshes | 渲染的蒙皮网格的数目 |
Animation | 播放的动画数目 |
Unity 5 的渲染统计窗口相较于之前版本中的有了一些变化,最明显的区别之一就是去掉了 draw call 数目的显示,而添加了批处理数目的显示。Batches 和 Saved by batching 更容易让开发者理解批处理的优化结果。当然,如果我们想要查看 draw call 的数目等其他更详细的数据,可以通过 Unity 编辑器的性能分析器来查看。
3.2 性能分析器的渲染区域
我们可以通过单击 Window->Profiler 来打开 Unity 的性能分析器(Profiler)。性能分析器中的渲染区域(Rendering Area)提供了更多关于渲染的统计信息。
性能分析器显示了绝大部分在渲染统计窗口中提供的信息,例如,绿线显示了批处理数目、蓝线显示了 Pass 数目等,同时还给出了需要的其他非常有用的信息,例如,draw call 数目、动态批处理/静态批处理的数目、渲染纹理的数目和内存占用等。
结合渲染统计窗口和性能分析器,我们可以查看与渲染相关的绝大多数重要的数据。一个值得注意的现象是,性能分析器给出的 draw call 数目和批处理数目、Pass 数目并不相等,并且看起来好像大于我们估算的数目,这是因为 Unity 在背后需要进行很多工作,例如,初始化各个缓存、为阴影更新深度纹理和阴影映射纹理等,因此需要花费比“预期”更多的 draw call。一个好消息是,Unity 5 引入了一个新的工具来帮助我们查看每一个 draw call 的工作,这个工具就是帧调试器。
3.3 再谈帧调试器
我们已经在之前的章节中多次看到**帧调试器(Frame Debugger)**的应用,例如 5.5.3 节中解释了如何使用帧调试器来对 Shder 进行调试。我们可以通过 Window->Frame Debugger来打开它。在这个窗口中,我们可以清楚地看到每一个 draw call 的工作和结果。
帧调试器的调试面板上显示了渲染这一帧所需要的所有渲染时间,在本例中,事件数目为 14,而其中包含了 10 个 draw call 事件(其他渲染事件多为清空缓存等)。通过单击面板上的每个事件,我们可以在 Game 视图查看该事件的绘制结果,同时渲染统计面板上的数据也会显示成截止到当前事件为止的各个渲染统计数据。
以下面这个帧调试器为例,渲染一帧一共花费了 7 个 draw call,其中 4 个用于渲染不透明物体,1 个用于渲染天空盒,2 个用于渲染透明物体。
在 Unity 的渲染统计窗口、分析器和帧调试器这 3 个利器的帮助下,我们可以获得很多有用的优化信息。但是,很多诸如渲染时间这样的数据是基于当前的开发平台得到的,而非真机上的结果。事实上,Unity 正在和硬件生产商合作,来首先让使用英伟达图睿(Tegra)的设备可以出现在 Unity 的性能分析器中。我们有理由相信,在后续的 Unity 版本中,直接在 Unity 中对移动设备分析不再是梦想。然而,在这个梦想实现之前,我们仍需要一些外部的性能分析工具的帮助。
3.4 其他性能分析工具
对于移动平台上的游戏来说,我们更希望得到在真机上运行游戏时的性能数据。这时,Unity 目前提供的各个工具可能就不再能满足我们的需求了。
对于 Andriod 平台来说,高通的 Adreno 分析工具可以对不同的测试机进行详细的性能分析。英伟达提供了 NVPerfHUD 工具来帮助我们得到几乎所有需要的性能分析数据,例如,每个 draw call 的 GPU 时间,每个 shader 花费的 cycle 数目等。
对于 IOS 平台来说,Unity 内置的分析器可以得到整个场景花费的 GPU 时间。PowerVRAM 的 PVRUniSCo shader 分析器也可以给出一个大致的性能评估。Xcode 中的 OpenGL ES Driver Instruments 可以给出一些宏观上的性能信息,例如,设备利用率、渲染器利用率等。但相对于 Androird 平台,对 IOS 的性能分析更加困难(工具较少)。而且 PowerVR 芯片采用了基于瓦片的延迟渲染器,因此,想要得到每个 draw call 花费的 GPU 时间几乎不可能的。这时,一些宏观上的统计数据可能更有参考价值。
4 减少 draw call 数目
读者最常看见的优化技术大概就是**批处理(batching)**了。批处理的实现原理就是为了减少每一帧需要的 draw call 数目。为了把一个对象渲染到屏幕上,CPU 需要检查哪些光源影响了该物体,绑定 shader 并设置它的参数,再把渲染命令发送给 GPU。当场景中包含了大量对象时,这些操作就会非常耗时。比如,我们需要渲染 1000 个三角形,把它们按 1000 个单独的网格进行渲染所花费的时间要远远大于渲染一个包含了 1000 个三角形的网格。在这两种情况下,GPU 的性能消耗其实并没有多大的区别,但 CPU 的 draw call 数据就会成为性能瓶颈。因此,批处理的思想很简单,就是在每次面对 draw call 时尽可能多地处理多个物体。我们已在 2.2 节中详细地讲述了 draw call 和批处理之间的联系,本节旨在介绍如何在 Unity 中利用批处理技术来优化渲染。
对于使用同一个材质的物体,它们之间的不同仅仅在于顶点数据的差别,我们可以把这些顶点数据合并在一起,再一起发送给 GPU,就可以完成一次批处理
Unity支持的两种批处理方式:
- 动态批处理(动态合批,Dynamic batching):
- 优点:Unity 自动完成,不需要任何操作,且物体可移动
- 缺点:限制很多,Unity无法动态批处理一些使用了相同材质的物体
- 静态批处理(静态合批,Static batching):
- 优点:自由度很高,显示很少
- 缺点:会占用更多的内存,且经过静态批处理后的所有物体都不可以再移动了
4.1 动态批处理
如果场景中有一些模型共享了同一个材质并满足了一些条件,Unity 就可以自动把它们进行批处理,从而只需要花费一个 draw call 就可以渲染所有的模型。
- 基本原理:每一帧把可以进行批处理的模型网格进行合并,再把合并后的模型数据传递给 GPU,然后使用同一个材质对其渲染。
- 优点:实现方便,经过批处理的物体仍然可以移动(处理每帧 Unity 时都会重新合并一次网格)
虽然 Unity 的动态批处理不需要哦我们进行任何额外工作,但是有满足条件的模型和材质才可以被动态批处理。需要注意的是,随着 Unity 版本的变化,这些条件也有一些改变,以下是一些主要的条件限制:
- 能够进行动态批处理的网格的顶点属性规模要小于 900。如果 Shader 中需要使用顶点位置、法线和纹理坐标这 3 个顶点属性,那么要想让模型能够被动态批处理,它的顶点数目不能超过 300.
- 一般来说,所有对象需要使用同一个缩放尺度。Unity 5 中这对模型缩放的限制已经不存在了。
- 使用光照纹理(lightmap)的物体需要小心处理。这些物体需要额外的渲染参数,例如在光照纹理上的索引、偏移量和缩放信息等。因此,为了让这些物体可以被动态批处理,需要保证它们只想光照纹理中的同一个位置。
- 多 Pass 的 shader 会中断批处理。在前向渲染中,我们有时需要使用额外的 Pass 来为模型添加更多的光照效果,但这样以来就不会被动态批处理了。
我们给出以下场景,要渲染一个包含了 4 个物体的场景共需要两个批处理。其中,一个批处理用于绘制经过动态批处理合并后的 3 个立方体网格,另一个批处理用于绘制球体。我们可以从 Save by batching 看出批处理帮我们节省了两个 draw call。
现在,我们在向场景中添加一个点光源,并调整它的位置使它可以照亮场景中的 4 个物体。由于场景中的物体都使用了多个 Pass 的 shader,因此,点光源会对它们产生光照影响。
可以发现,渲染一帧所需要的批处理数目增大到了 8,而 Save by batching 的数目也变成了 0。这是因为,使用了多个 Pass 的 shader 在需要应用多个光照的情况下,破坏了动态批处理的机制,导致 Unity 不能对这些物体进行动态批处理。而由于平行光和点光源需要对 4 个物体分别产生影响,因此,需要 2 × 4 2\times4 2×4 个批处理操作。需要注意的是,只有物体在点光源的影响范围内,Unity 才会调用额外的 Pass 来处理它。因此,如果场景中点光源距离物体很远,那么它们仍然会被动态批处理的。
4.2 静态批处理
Unty 提供了另一种批处理方式,即静态批处理。相比于动态批处理来说,静态批处理适用于任何大小的几何模型。它的实现原理是,只在运行开始阶段,把需要进行静态批处理的模型合并到一个新的网格结构中,这意味着这些模型不可以在运行时刻被移动。但由于它只需要进行一次合并操作,因此,比动态批处更加高效。静态批处理的另一个缺点在于,它往往需要占用更多的内存来存储合并后的几何结构。这是因为,如果在静态批处理前一些物体共享了相同的网格,那么在内存中每一个物体都会对应一个该网格的复制品,即一个网格会变成多个网格再发送给 GPU。如果这类使用同一个网格的对象很多,那么这就会成为一个性能瓶颈了。例如,如果在一个使用了 1000 个相同树模型的森林中使用静态批处理,那么,就会多使用 1000 倍的内存,这会造成严重的内存影响。这种时候,解决方法要么忍受这种牺牲内存换取性能的方法,要么不要使用静态批处理,而使用动态批处理技术(但要小心控制模型的顶点属性数目),或者自己编写批处理的方法。
我们给出一个测试静态批处理的场景,场景中包含 3 个 Teapot 模型,它们使用同一个材质,同时还包含了一个使用不同材质的立方体。场景中还包含了一个平行光,但我们关闭了它的阴影效果,以避免阴影计算对批处理数目的影响。
尽管 3 个 Teapot 模型使用了相同的材质,但它们仍然没有被动态批处理。这是因为,Teapot 模型包含的顶点数目是 393,而它们使用的 shader 中需要使用 4 个顶点属性(顶点位置、法线方向、切线方向和纹理坐标),超过了动态批处理中限定的 900 限制,此时,要想减少 draw call 就需要使用静态批处理。
静态批处理的实现非常简单,只需要把物体面板上的 Static 复选框勾选上即可(实际上只需要勾选 Batching Static 即可)
这时,我们观察渲染统计窗口中的批处理数目,运行程序后,可以发现变化。
此时我们可以发现,现在的批处理数目变成了 2,而 Save by batching 数目也显示为 2。此时,如果我们在运行时查看每个模型使用的网格,会发现它们都变成了一个名为 Combined Mesh(roo:scene) 的东西。这个网格是 Unity 合并了所有被标识为 “Static” 的物体的结果,在我们的例子里,就是 3 个 Teapot 和一个立方体。这个合并后的网格其实包含了 4 个子网格,即场景中的 4 个对象。对于合并后的网格,Unity 会判断其中使用同一个材质的子网格,然后对它们进行批处理。
在内部实现上,Unity 首先把这些静态物体变换到世界空间下,然后为它们构建一个更大的顶点和索引缓存。对于使用了同一材质的物体,Unity 只需要调用一个 draw call 就可以绘制全部物体。而对于使用了不同材质的物体,静态批处理同样可以提升渲染性能。尽管这些物体仍然需要调用多个 draw call,但静态批处理可以减少这些 draw call 之间的状态切换,而这些切换往往是费时的操作。从合并后的网格结构中我们还可以发现,尽管 3 个 Teapot对象使用了同一个网格,但合并后却变成了 3 个独立网格。而且,我们可以从 Unity 的分析器中观察到在应用静态批处理前后 VBO total 的变化,VBO(Vetex Buffer Object,顶点缓存对象)的数目变大了。这正是因为静态批处理会占用更多的内存的缘故,正如本节一开头所讲,静态批处理需要占用更多内存来存储合并后的几何结构。如果一些物体共享了相同的网格,那么在内存中一个物体都会对应一个该网格的复制品。
如果场景中包含了除平行光以外的其他光源,并且在 shader 中定义了额外的 Pass 来处理它们,这些额外的 Pass 部分时不会被批处理的,但是,处理平行光的 Base Pass 部分仍然会被静态批处理,因此,我们仍然可以节省两个 draw call。
注意:
- 只有共享同一个材质的物体才能在一个 draw call 中被渲染。
- 使用多个 Pass 的 Shader 在需要应用多个光照的情况下,会破坏动态批处理的机制,无法进行动态批处理,但是,可以进行静态批处理。
- 静态批处理不会减少 draw call,只会减少渲染状态切换的消耗。
- 静态批处理不要求所有的模型要共享同一材质,但是共享同一材质的静态物体会被放到一个 drawcall 中渲染。
- 静态批处理可以作用于实时的光照。
4.3 共享材质
从之前的内容可以看出,无论是动态批处理还是静态批处理,都要求模型之间需要共享同一个材质。但不同的模型之间总会需要有不同的渲染属性,例如,使用不同的纹理、颜色等。这时,我们需要一些策略来尽可能地合并材质。
如果两个材质之间只有使用的纹理不同,我们可以把这些纹理合并到一张更大的纹理中,这张更大的纹理被称为是一张图集(atlas)。一旦使用了同一张纹理,我们就可以使用同一个材质,再使用不同的采样纹理坐标对纹理采样即可。
但有时,除了纹理不同外,不同的物体在材质上还有一些微小的参数变化,例如,颜色不同、某些浮点属性不同。但是,不管是动态批处理还是静态批处理,它们的前提都是要使用同一个材质。是同一个,而不是使用了同一种 Shader 的材质,也就是说它们指向的材质必须是同一个实体。这意味着,只要我们调整了参数,就会影响到所有使用这个材质的对象。那么想要微小的调整怎么办呢?一种常用的方法就是使用网格的顶点数据(最常见的就是顶点颜色数据)来存储这些参数。
前面说过,经过批处理后的物体会被处理成更大的 VBO 发送给 GPU,VBO 中的数据可以作为输入传递给顶点着色器,因此,我们可以巧妙地对 VBO 中的数据进行控制,从而达到不同效果的目的。一个例子是,森林场景中所有的树使用了同一种材质,我们希望它们可以通过批处理来减少 draw call,但不同树的颜色可能不同。这时,我们可以利用网格的顶点的颜色数据来调整。
需要注意的是,如果我们需要在脚本中访问共享材质,应该使用 Renderer.sharedMaterial 来保证修改的是和其他物体共享的材质,但这意味着修改会应用到所有使用该材质的物体上。另一个类似的 API 是 Renderer.material ,如果使用 Renderer.material 来修改材质,Unity 会创建一个该材质的复制品,从而破坏批处理在该物体上的应用,这可能并不是我们希望看到的。
4.4 批处理的注意事项
在选择使用动态批处理还是静态批处理时,我们有一些小小的建议。
- 尽可能选择静态批处理,但得时刻小心对内存的消耗,并且记住经过静态批处理的物体不可以再被移动。
- 如果无法进行静态批处理,而要使用动态批处理的话,那么请小心上面的各种条件限制。例如,尽可能让这样的物体少并且尽可能让这些物体包含少量的顶点属性和顶点数目。
- 对于游戏中的小道具,例如可以捡拾取的金币等,可以使用动态批处理(打包为图集)。
- 对于包含动画的这类物体,我们无法全部使用静态批处理,但其中如果有不动的部分,可以把这部分标识成“Static”。
除了上述提示外,在使用批处理时还有一些需要注意的地方。由于批处理需要把多个模型变换到世界空间下再合并它们,因此,如果 shader 中存在一些基于模型空间下的坐标的运算,那么往往会得到错误的结果。一个解决方法时,在 shader 中使用 Disable Batching 标签来强制使用该 Shader 的材质不会被批处理。另一个注意事项是,使用半透明材质的物体通常需要使用严格的从后往前的绘制顺序来保证透明混合的正确性。对于这些物体,Unity 会首先保证它们的绘制顺序,再尝试对它们进行批处理。这意味着,当绘制顺序无法满足时,批处理无法在这些物体上被成功应用。
尽管在 Unity 5.2 中,只实现了对一些渲染部分的批处理。而诸如渲染摄像机的深度纹理等部分,还没有实现批处理。
5 减少需要处理的顶点数目
尽管 draw call 是一个重要的性能指标,但顶点数目同样有可能成为 GPU 的性能瓶颈。
5.1 优化几何体
3D 游戏制作通常都是由模型制作开始的。而在建模时,有一条规则我们需要记住:尽可能减少模型中三角面片的数目,一些对于模型没有影响、或是肉眼非常难察觉到区别的顶点都要尽可能去掉。为了尽可能减少模型中的顶点数目,美工人员往往需要优化网络结构。在很多三维建模软件中,都有相应的优化选项,可以自动优化网络结构。
在 Unity 的渲染统计窗口中,我们可以查看到渲染当前帧需要的三角面片数目和顶点数目。需要注意的是,Unity 中显示的数目往往要多于建模软件里显示的顶点数,通常 Unity 中显示的数目要大很多。其实,这是因为在不同的角度上计算的,都有各自的道理,但我们真正关心的是 Unity 里显示的数目。
我们在这里简单解释一下造成这种不同的原因。三维软件更多地是站在我们人类的角度理解顶点的,即组成几何的每一个点就是一个单独的点。而 Unity 是站在 GPU 的角度上去计算顶点数的。在 GPU 看来,有时需要把一个顶点拆分成两个或更多的顶点。这种将顶点一分为多的原因主要有两个:一个是为了分离纹理坐标(uv split),另一个是为了产生平滑的边界(smoothing splits)。它们的本质,其实都是因为对于 GPU 来说,顶点的每一个属性和顶点之间必须是一对一的关系。而分离纹理坐标,是因为建模时一个顶点的纹理坐标有多个。例如,对于一个立方体,它的 6 个面之间虽然使用了一些相同的顶点,但在不同面上,同一个顶点的纹理坐标可能并不相同。对于 GPU 来说,这是不可理解的,因此,它必须把这个顶点拆分成多个具有不同纹理坐标的顶点。而平滑边界也是类似的,不同的是,此时一个顶点会对应多个法线信息或切线信息。这通常是因为我们要决定一个边是一条硬边(hard edge)还是一条平滑边(smooth edge)。
对于 GPU 来说,它本质上只关心有多少个顶点。因此,尽可能减少顶点的数目其实才是我们真正需要关心的事情。因此,最后一条几何体优化建议就是:移除不必要的硬边以及纹理衔接,避免边界平滑和纹理分离。
5.2 模型的 LOD 技术
另一个减少顶点数目的方法是使用 LOD(Levels of Detail)技术。这种技术的原理是,当一个物体离摄像机很远时,模型上的很多细节是无法被察觉到的。因此,LOD 允许当对象逐渐远离摄像机时,减少模型上的面片数量,从而提高性能。
在 Unity 中,我们可以使用 LOD Group 组件来为一个物体构建一个 LOD。我们需要为同一个对象准备多个包含不同细节程序的模型,然后把它们赋给 LOD Group 组件中的不同等级,Unity 就会自动判断当前位置上需要使用哪个等级的模型。
5.3 遮挡剔除技术
我们最后要介绍的顶点优化策略就是遮挡剔除(Occulusion culling)技术。遮挡剔除可以用来消除那些在其他物体后面看不到的物体,这意味着资源不会浪费在计算那些看不到的顶点上,进而提升性能。
我们需要把遮挡剔除和摄像机的视锥体剔除(Frustum Culling)区分开来。视锥体剔除只会剔除掉那些不在摄像机的视野范围内的对象,但不会判断视野中是否有物体被其他物体挡住。而遮挡剔除会使用一个虚拟的摄像机来遍历场景,从而构建一个潜在可见的对象集合的层级结构。在运行时刻,每个摄像机会使用这个数据来识别哪些物体是可见的,而哪些被其他物体挡住不可见。使用遮挡剔除技术,不仅可以减少处理的顶点数目,还可以减少 overdraw,提高游戏性能。要在 Unity 中使用遮挡剔除技术,我们需要进行一系列额外的处理工作。具体步骤可以参见 Unity 手册。
模型的 LOD 技术和遮挡剔除技术可以同时减少 CPU 和 GPU 的负荷。CPU 可以提交更少的 draw call,而 GPU 需要处理的顶点和片元数目也减少了。
6 减少需要处理的片元数目
另一个造成 GPU 瓶颈的是需要处理过多的片元。这部分优化的重点在于减少 overdraw。简单来说,oerdraw 指的就是一个像素被绘制了多次。
Unity 还提供了查看 overdraw 的视图,我们可以在 Scene 视图左上方的下拉菜单中选中 Overdraw 即可。实际上,这里的视图只是提供了查看物体相互遮挡的层数,并不是真正的最终屏幕绘制的 overdraw。也就是说,可以理解为它显示的是,如果没有使用任何深度测试和其他优化策略时的 overdraw。这种视图是通过把所有对象都渲染成一个透明的轮廓,通过查看透明颜色的累计程度,来判断物体之间的遮挡。当然,我们可以使用一些措施来防止这种最坏情况的出现。
6.1 控制绘制顺序
为了最大限度的避免 overdraw,一个重要的有优化策略就是控制绘制顺序。由于深度测试的存在,如果我们可以保证物体都是从前往后绘制的,那么就可以很大程度上减少 overdraw。这是因为,在后面绘制的物体由于无法通过深度测试,因此就不会再进行后面的渲染处理。
在 Unity 中,那些渲染队列数目小于 2500(如 ”Background” “Geometry” 和 “AlphaTest”)的对象都被认为是不透明(opaque)的物体,这些物体总体上是从前往后绘制的,而使用其他的队列(如 “Transparent” “Overlay” 等)的物体,则是从后往前绘制的。这意味着,我们可以尽可能地把物体的队列设置为不透明的渲染队列,而尽量避免使用半透明队列。
而且,我们还可以充分利用 Unity 的渲染队列来控制绘制顺序。例如,在第一人称射击游戏中,对于游戏中的主要人物角色来说,他们使用的 shader 往往比较复杂,但是,由于他们通常会挡住屏幕的很大一部分区域,因此我们可以先绘制它们(使用更小的渲染队列)。而对于一些地方角色,它们通常会出现在各种掩体后面,因此,我们可以在所有常规的不透明物体后面渲染它们(使用更大的渲染队列)。而对于天空盒子来说,它几乎覆盖了所有的像素,而且我们知道它永远会出现在所有物体的后面,因此,它的队列可以设置为“Geometry + 1”。这样,就可以保证不会因为它造成 overdraw。
这些排序的思想往往可以节省掉很多渲染时间。
6.2 时刻警惕透明物体
对于半透明物体来说,由于它们没有开启深度写入,因此,如果要得到正确的渲染效果。就必须从后往前渲染。这意味着,半透明物体几乎一定会造成 overdraw。如果我们不注意这一点,在一些机器上可能造成严重的性能下降。例如,对于 GUI 对象来说,它们大多数被设置成了半透明,如果屏幕中 GUI 占据的比例太多,而主摄像机又没有进行调整而是投影整个屏幕,那么 GUI 就会造成大量的 overdraw。
因此,如果场景中包含了大面积的半透明对象,或者有很多相互覆盖的半透明对象(即使它们每个的面积可能都不大),或者是透明的粒子效果,在移动设备上也会造成大量的 overdraw。这是应该尽量避免的。
对于上述 GUI 的这种情况,我们可以尽量减少窗口中 GUI 所占的面积。如果实在无能为力,我们可以把 GUI 的绘制和三维场景的绘制交给不同的摄像机,而其中负责三维场景的摄像机的视角范围尽量不要和 GUI 的相互重叠。当然,这样会对游戏的美观度产生一定影响,因此,可以在代码中对机器的性能进行判断,例如,首先关闭一些耗费性能的功能,如果发现这个机器表现非常良好,再尝试开启一些特效功能。
在移动平台上,透明度测试也会影响游戏性能。虽然透明度测试没有关闭深度测试,但由于它的实现使用了 discard 或 clip 操作,而这些操作会导致一些硬件的优化策略失效,例如,我们之前讲过 PowerVR 使用的基于瓦片的延迟渲染技术,为了减少 overdraw 它会在调用片元着色器前就判断哪些瓦片被真正渲染的。但是,由于透明度测试在片元着色器上使用了 discard 函数改变了片元是否会被渲染的结果,因此,GPU 就无法使用上述的优化策略了。也就是说,只要在执行了所有的片元着色器后,GPU 才知道哪些片元会被真正渲染到屏幕上,这样,原先那些可以减少 overdraw 的优化都无效了。这种时候,使用透明度混合的性能往往比使用透明度测试好。
6.3 减少实时光照和阴影
实时光照对于移动平台是一种非常昂贵的操作。如果场景中包含了过多的点光源,并且使用了多个 Pass 的 Shader,那么很有可能会造成性能下降。例如,一个场景里如果包含了 3 个逐像素的点光源,而且使用了逐像素的 Shader,那么很有可能 draw call 数目(CPU 的瓶颈)提高了 3 倍,同时也会增加 overdraw (GPU 的瓶颈)。这是因为,对于逐像素的光源来说,被这些光源照亮的物体需要再被渲染一次。更糟糕的是,无论是静态批处理还是动态批处理,对于这种额外的处理逐像素光源的 Pass 都无法进行批处理,也就是说,它们会中断批处理。
当然,游戏场景还是需要光照才能得到出色的画面效果。我们看到很多成功的移动平台的游戏,它们的画面效果看起来好像包含了很多光源,但其实这都是骗人的。这些游戏往往使用了烘焙技术,把光照提前烘焙到一张光照纹理(lightmap)中,然后再运行时刻只需要根据纹理采样得到光照结果即可。另一个模拟光源的方法是使用 God Ray。场景中很多小型光源的效果都是靠这种方法模拟的。它们一般不是真的光源,很多情况是通过透明纹理模拟得到的。更多信息可以参见本章的扩展阅读部分。在移动平台上,一个物体使用的逐像素光源数目应该小于 1(不包括平行光)。如果一定要使用更多的实时光,可以选择逐顶点光照来代替。
在游戏《ShadowGun》中,游戏角色看起来使用了非常复杂高级的光照计算,但这实际上是优化后的结果。开发者们把复杂的光照计算存储到一张查找纹理(lookup texture,也被称为查找表,lookup table,LUT)中。然后在运行时刻,我们只需要使用光源方向、视角方向、法线方向等参数,对 LUT 采样得到的光照结果即可。使用这样的查找纹理,不仅可以让我们使用更出色的光照模型,例如,更加复杂的 BRDF 模型,还可以利用查找纹理的大小来进一步优化性能,例如,主要角色可以使用更大分辨率的 LUT,而一些 NPC 就使用较小的 LUT。《ShadowGun》的开发者开发了一个 LUT 烘培工具,来帮助美工人员快速调整光照模型,并把及如果存储到 LUT 中。
实时阴影同样是一个非常消耗性能的效果。不仅是 CPU 需要提交更多的 draw call,GPU 也需要进行更多的处理。因此,我们应该尽量减少实时阴影,例如,使用烘焙把静态物体的阴影信息存储到光照纹理中,而只对场景中的动态物体使用适当的实时阴影。
7 节省带宽
大量使用未经压缩的纹理以及使用过大的分辨率都会造成由于带宽引发的性能瓶颈。
7.1 减少纹理大小
之前提到过,使用纹理图集可以帮助我们减少 draw call 的数目,而这些纹理的大小同样是一个需要考虑的问题。需要注意的是,所有纹理的长宽比最好是正方形,而且长宽值最好是 2 的整数幂。这是因为有很多优化策略只有在这种时候才可以发挥最大效用。在 Unity5 中,即使我们导入的纹理长宽值并不是 2 的整数幂,Unity 也会自动把长宽转换到离它们最近的 2 的整数幂值。但我们仍然应该在制作美术资源时就把这条规则谨记在心,防止由于放缩而造成不好的影响。
除此之外,我们还应该尽可能使用多级渐远纹理技术(mipmapping)和纹理压缩。在 Unity 中,我们可以通过纹理导入面板来查看纹理的各个导入属性。通过把纹理类型设置为 Advanced,就可以自定义许多选项,例如,是否生成多级渐远纹理(mipmaps),如图 16.12 所示。当勾选了 Generate Mip Maps 选项后,Unity 就会为同一张纹理创建出很多不同大小的小纹理,构成一个纹理金字塔。而在游戏运行中就可以根据距离物体的远近,来动态选择使用哪一个纹理。这是因为,在距离物体很远的时候,就算我们使用了非常精细的纹理,但肉眼也是分辨不出来的。这种时候,我们完全可以使用更小、更模拟的纹理来代替,这可以让 GPU 使用分辨率更小的纹理,大量节省访问的像素数目。在某些设备上,关闭多级渐远纹理往往会造成严重的性能问题。因此,除非我们确定该纹理不会发生缩放,例如 GUI 和 2D 游戏中使用的纹理等,都应该为纹理生成相应的多级渐远纹理。
纹理压缩同样可以节省带宽。但对于像 Andriod 这样的平台,有很多不同架构的 GPU,纹理压缩就变得有点复杂,因为不同的 GPU 架构有它自己的纹理压缩格式,例如 PowerVRAM 的 PVRTC 格式、Tegra 的 DXT 格式、Adreno 的 ATC 格式。所幸的是,Unity 可以根据不同的设备选择不同的压缩格式,而我们只需要把纹理压缩格式设置为自动压缩即可,但是,GUI 类型的纹理同样是个例外,一些时候由于对画质的要求,我们不希望对这些纹理进行压缩。
7.2 利用分辨率缩放
过高屏幕分辨率也是造成性能下降的原因之一,尤其是对于很多低端手机,除了分辨率高,其他硬件条件并不尽如人意,而这恰恰是游戏性能的两个瓶颈:过大的屏幕分辨率和糟糕的 GPU。因此,我们可能需要对于特定的机器分辨率的放缩。当然,这样可能会造成游戏效果的下降,但性能和画面之间永远都是个需要权衡的话题。
在 Unity 中设置屏幕分辨率可以直接调用 Screen.SetResolution,实际使用中可能会遇到一些情况。
8 减少计算复杂度
计算复杂度同样会影响游戏的渲染性能。在本节中,我们会介绍两个方面的技术来减少计算的复杂度。
8.1 Shader 的 LOD 技术
和 16.5.2 节中提到的模型的 LOD 技术类似,Shader 的 LOD 技术可以控制使用的 Shader 等级。它的原理是,只有 Shader 的 LOD 值小于某个设定的值,这个 Shader 才会被使用,而使用了那些超过设定值的 Shader 的物体将不会被渲染。
我们通常会在 SubShader 中使用类型下面的语句来指明该 shader 的 LOD 值:
SubShader {
Tags { "RenderType" = "Opaque" }
LOD 200
}
我们也可以在 Unity Shader 的导入面板上看到该 Shader 使用的 LOD 值。在默认情况下,允许的 LOD 等级是无限大的。这意味着,任何被当前显卡支持的 Shader 都可以被使用。但是,在某些情况下我们可能去掉一些使用了复杂计算的 Shader 渲染。这时,我们可以使用 Shader.maximumLOD 或 Shader.globalMaximumLOD 来设置允许的最大 LOD 值。
Unity 内置的 Shader 使用了不同的 LOD 值,例如,Diffuse 的 LOD 为 200,而 Bumped Specular 的 LOD 为 400.
8.2 代码方面的优化
在实现游戏效果时,我们可以选择在哪里进行某些特定的运算。通常来讲,游戏需要计算的对象、顶点和像素的数目排序是对象数 < 顶点数 < 像素数。因此,我们应该尽可能地把计算放在每个对象或逐顶点上。例如,在第 13 章实现高斯模糊和边缘检测时,我们把采样坐标的计算放在了顶点着色器中,这样的做法好于把它们放在片元着色器中。
而在具体的代码编写上,不同的硬件甚至需要不同的处理。因此,一些普遍的规则在某些硬件上可能并不成立。更不幸的是,通常 Shader 代码优化并不那么直观,尤其是一些平台上缺少相关的分析器,例如 IOS 平台。尽管如簇,在本节我们还是会给出一些被认为是普遍成立的优化策略,但读者如果发现这些设备上性能反而有所下降的话,这并不奇怪。
首先第一点是,尽可能使用低精度的浮点值进行运算。最高精度的 float/highp 适用于存储诸如顶点坐标等变量,但它们的计算速度是最慢的,我们应该尽量避免在片元着色器中使用这种精度进行计算。而 half/mediummp 适用于一些标量、纹理坐标等变量,它的计算速度大约是 float 的两倍。而 fixed/lowp 适用于绝大多数颜色变量和归一化后的方向矢量,在进行一些对精度要求不高的计算时,我们应该尽量使用这种精度的变量。它的计算速度大约是 float 的 4 倍,但要避免对这些低精度变量进行频繁的 swizzle 操作(如 color.xwxw)。还需要注意的是,我们应当尽量避免在不同的精度之间转换,这有可能造成一定的性能下降。
对于绝大多数 GPU 来说,在使用插值寄存器把数据从顶点着色器传递给下一个阶段时,我们应该使用尽可能少的插值变量。例如,如果需要对两个纹理坐标进行插值,我们通常会把它们打包在同一个 float4 类型的变量中,两个纹理坐标分别对应了 xy 分量和 zw 分量。然而,对于 PowerVR 平台来说,这种插值变量是非常廉价的,直接把不同的纹理坐标存储在不同的插值变量中,有时反而性能更好。尤其是,如果在PowerVR 上使用类似 tex2D(_MainTex, uv.zw)这样的语句来进行纹理采样,GPU 就无法进行一些纹理的预读取,因为它会认为这些纹理采样时需要依赖其他数据的。因此,如果我们特别关心游戏在 PowerVR 上的性能,就不应该把两个纹理坐标打包在同一个四维变量中。
尽可能不要使用全屏的屏幕后处理效果。如果美术风格实在是需要使用类似 Bloom、热扰动这样的屏幕特效,我们应该尽量使用 fixed、lowp 进行低精度运算(纹理坐标除外,可以使用 half/mediump)。那些高精度的运算可以使用查找表(LUT)或者转移到顶点着色器中进行处理。除此之外,尽量把多个特效合并到一个 Shader 中。例如,我们可以把颜色校正和添加噪声等屏幕特效在 Bloom 特效的最后一个 Pass 中进行合成。还有一个方法就是使用 16.8.3 节中介绍的缩放思想,来选择性地开启特效。
还有一些读者经常会听到的代码优化规则:文章来源:https://www.toymoban.com/news/detail-800627.html
- 尽可能不要使用分支语句和循环语句。
- 尽可能避免使用类似 sin、tan、pow、log 等较为复杂的数学运算。我们可以使用查找表来作为替代。
- 尽可能不要使用 discard 操作,因为会影响硬件的某些优化。
8.3 根据硬件条件进行缩放
诸如 IOS 和 Andriod 这样的移动平台,不同设备之前的性能千差万别。我们很容易找到一台手机的渲染性能是另一台手机的 10 倍。那么,如何确保游戏可以同时流畅得运行在不同性能的移动设备上呢?一个非常简单且实用的方式是使用所谓的放缩(scaling)思想。我们首先保证游戏最基本的配置可以在所有的平台上运行良好,而对于一些具有更高表现能力的设备,我们可以开启一些更“养眼”的效果,比如使用更高的分辨率,开启屏幕后处理特效,开启粒子效果等。文章来源地址https://www.toymoban.com/news/detail-800627.html
到了这里,关于Unity中的渲染优化技术 -- Shader入门精要学习(15)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!