Unity基础教程系列(新)(四)——测量性能(MS and FPS)

被刻印的时光 ゝ 提交于 2021-02-04 04:04:18

目录

  1 分析Unity

        1.1 游戏窗口 Statistics

        1.2 动态合批

        1.3 GPU Instancing

        1.4 Frame Debugger

        1.5 额外的灯光

        1.6 Profiler

        1.7 分析一次构建

  2 展示帧率

        2.1 UI面板

        2.2 Text

        2.3 更新显示

        2.4 平均帧率

        2.5 最好和最差

        2.6 帧持续时间

        2.7 内存分配

  3 自动进行函数切换

        3.1 函数循环

        3.2 随机函数

        3.3 函数插值

        3.4 过渡


本文重点内容:
1、使用 game window stats, frame debugger, 和 profiler
2、比较动态批处理, GPU instancing, and SRP batcher
3、显示帧率
4、循环自动的执行函数
5、不同函数之间平滑过渡

这是关于学习使用Unity的基础知识的系列教程中的第四篇。对测量性能的介绍。我们还将在函数库中添加从一个函数转换为另一个函数的功能。

本教程是CatLikeCoding系列的一部分,原文地址见文章底部。

本教程使用Unity 2019.4.12f1制作。

(介于波浪和球体之间)


1 分析Unity


Unity持续渲染新帧。为了使任何运动看起来都流畅,它必须足够快地执行此操作,以便我们将图像序列看起来是连续运动的。通常,每秒至少需要30帧(简称FPS),而60 FPS是理想的目标。这些数字经常出现是因为许多设备的显示刷新率为60赫兹。如果不关闭垂直同步功能,则绘制帧的速度不能超过此(垂直同步)速度,这会导致图像撕裂。如果无法达到一致的60 FPS,则下一个最佳速率是30 FPS,即每两个显示刷新一次。降低15帧/秒将不足以进行流畅的图像运动。


其他常见的监视器刷新率是多少?
75Hz,85Hz和144Hz台式显示器也很常见。对于竞赛类型的游戏场景,刷新率更高。因此,如果你的应用程序可以可靠地达到80FPS,则在所有显示器上启用VSync后,它的性能都将很好。如果只能达到60FPS,则75Hz的显示器将以37.5FPS的速度下降一半,85Hz的显示器将减至42.5FPS的一半,而144Hz的显示器将以48FPS的速度下降至三分之一。但是,这是在假定性能稳定的前提下。实际上,帧速率可能在刷新速率的倍数之间波动。

是否可以达到目标帧速率取决于处理单个帧需要多长时间。为了达到60FPS,我们必须在不到16.67毫秒的时间内更新和渲染每个帧。30FPS的时间预算为每帧33.33 ms。


当图形运行时,我们可以通过简单地观察它来了解其运动的平滑程度,但这是一种非常不精确的测量其性能的方法。如果运动看起来很平稳,则可能超过30FPS,如果看起来卡顿,则可能会小于30FPS。由于性能不一致,当前可能会很平稳,而下一刻可能就会卡顿。这可能是由于我们应用程序的差异引起的,也可能是由于同一设备上运行的其他应用程序引起的。如果我们勉强达到60FPS,那么我们最终可能会在30FPS和60FPS之间来回快速变动,尽管平均FPS很高,但仍会感到不流畅。因此,要更好地了解正在发生的请客,我们需要更精确地衡量性能。Unity有一些工具可以帮助我们解决这个问题。


1.1 游戏窗口 Statistics


游戏窗口有一个Statistics覆盖面板,可以通过其Stats工具栏按钮激活该面板。它显示对最后渲染的帧进行的测量。虽然它并不能告诉我们太多信息,但是它是我们可以用来了解正在发生的情况的最简单的工具。在编辑模式下,游戏窗口通常仅在某些更改后才偶尔更新。在播放模式下,它会一直刷新。


以下统计信息是针对使用默认渲染管道的torus函数和分辨率为100的图形绘制的,从现在开始,我将其称为DRP。我为游戏窗口打开了VSync,因此刷新与我的60 Hz显示屏同步。

(DRP的统计信息)


统计数据显示,CPU主线程花费了23.6ms,渲染线程花费了27.8ms。你可能会得到不同的结果,这取决于你的硬件。在我的例子中,它预示渲染整个帧需要51.4ms,但是统计面板报告的是36FPS,匹配渲染线程时间。FPS指标似乎取了两者中最坏的,并假设与帧速率匹配。这是一个过分简化,只考虑CPU方面,忽略了GPU和显示。实际帧率可能更低。

什么是线程?
在Unity应用程序的情况下,线程是子进程。可以有多个线程同时并行运行。统计信息显示在上一帧期间Unity的主线程和渲染线程运行了多长时间。

除了持续时间和FPS指示之外,统计面板还显示有关渲染内容的各种详细信息。有30003批次,显然通过合批的次数为零。这些是发送到GPU的绘制命令。我们的图形包含10000点,因此似乎每个点都被渲染了3次。一次用于深度Pass,一次用于阴影投射器(也单独列出了),一次用于渲染每个点的最终立方体。其他三个批次用于其他工作,例如与我们的图形无关的天空盒和阴影处理。还有六个set-pass调用,这可以通过将GPU重新配置为以不同的方式呈现(例如使用不同的材质)来实现。

(URP的统计信息)


如果我们切换到URP,统计数据是不同的。它渲染速度更快,在这种情况下,主CPU线程比渲染线程慢。原因很容易猜到:只有20001批,比DRP少了10000个批次。这是因为URP没有为定向阴影使用单独的深度通道。统计数据显示零阴影投射器,但那是因为这一项只能显示DRP的数据。


另一个奇怪的事情是,Saved by batching可能显示负数。发生这种情况的原因是,默认情况下,URP使用SRP批处理程序,而统计信息面板不理解它。SRP批处理程序不会消除单个绘制命令,但可以使它们效率更高。为了说明这一点,请选择我们的URP资产,并在其检查器底部的Advanced部分下禁用SRP Batcher。

(URP高级设置)


禁用了SRP batcher后,URP的性能会差得多。

(没有SRP batcher的URP统计窗口)


1.2 动态合批


除了SRP Batcher,URP还具有另一个用于动态批处理的开关。这是一项古老的技术,可以将小网格物体动态地组合成一个较大的网格物体,然后将其渲染。为URP启用它会将批次减少到10023,并且统计面板显示已节省了9978次Draw Call。

(开启了动态合批的URP统计数据)


在我的例子中,SRP批处理程序和动态批处理具有相当好的性能,因为立方体网格是动态批处理的理想(网格小)对象。


SRP批处理不适用于DRP,但是我们可以为其启用动态批处理。可以在Player项目设置的Other Settings 部分找到切换开关,该设置位于将颜色空间设置为线性的位置下方。仅在不使用可编写脚本的渲染管道设置时可见。

(带有动态批处理的DRP统计信息)


动态批处理对于DRP更有效,节省了29964批,将它们减少到仅39个批次。这是一个重大优化,但仍然不如URP快。


1.3 GPU Instancing


提高渲染性能的另一种方法是启用GPU实例化。这样就可以使用单个绘制命令来告诉GPU使用相同的材质绘制一个网格的许多实例,从而提供一系列转换矩阵以及其他可选的实例数据。在这种情况下,我们必须针对每种材质启用它。我们有一个Enable GPU Instancing开关。

( GPU Instancing开启的材质)


与GPU实例化相比,URP更喜欢SRP批处理程序,因此为了使其适用于我们的点阵,需要禁用SRP batcher。然后我们可以看到批处理数量减少到只有45,比动态批处理好得多。我们稍后会解释造成这种差异的原因。

(开启了GPU instancing 的URP统计)


从这些数据我们可以得出结论,对于URP GPU Instancing最好,然后是动态批处理,然后是SRP批处理器。但是差异很小,指示的FPS在所有情况下都比我的显示刷新率高,因此对于我们的视图来说,它们似乎等效。唯一明确的结论是,不使用这些都不是一个好主意。


对于DRP,GPU实例化似乎比动态批处理要好一些,这两种方法都比不使用动态批处理要好得多。

(开启了GPU instancing的DRP统计面板)


1.4 Frame Debugger


统计信息面板可以告诉我们使用动态批处理与使用GPU实例化有所不同,但没有告诉我们原因。为了更好地了解发生了什么,我们可以使用通过Window/ Analysis / Frame Debugger打开的帧调试器。通过其工具栏按钮启用后,它将显示发送到GPU的游戏窗口最后一帧的所有绘制命令的列表,这些列表按概要采样分析分组。该列表显示在其左侧。在其右侧显示了特定选定绘制命令的详细信息。此外,游戏窗口将显示渐进的绘制状态,绘制手动选择的命令。

为什么我的电脑突然变热了?
Unity使用的技巧就是需要反复渲染相同的帧来显示绘制帧的中间状态。只要帧调试器处于活动状态,它就会执行此操作。所以确保在不需要帧调试器时禁用它。

在这里,我们必须处于播放模式,因为那是我们的图形被绘制的时候。启用帧调试器将暂停播放模式,这允许我们检查绘制命令的层次结构。让我们先为DRP做这个,先不使用动态批处理或GPU实例化。

(DRP的帧调试器数据)


我们看到总共有30007个draw调用,比统计面板报告的还要多,因为还有一些命令没有被计数为批,比如清除目标缓冲区。在UpdateDepthTexture下为我们的点阵绘制的30000被单独列出为Draw Mesh Point(Clone),RenderShadowMap,以及RenderForward.RenderLoopJob。


如果我们在启用了动态批处理的情况下再次尝试,那么命令结构将保持不变,只是每组10000次Draw被减少为12次Draw动态调用。这是一个显著的改进。

(DRP开启了动态批处理)


如果我们使用GPU instancing,那么每组将减少到20个Draw Mesh (Instanced) Point(Clone)调用。这也是一个很大的改进,但是方法不同。

(DRP开启了GPU instancing)


我们可以看到,URP也会发生相同的情况,但是命令层次不同。在我的例子中,将绘制两次点,首先在Render Main Shadowmap下,再在Render Opaques下。一个显着的区别是,动态批处理似乎不适用于阴影贴图,这解释了为什么它对URP的有效性较低。我们最终也得到了22个批处理,而不是12个批处理,这表明URP材质比标准DRP依赖更多的网格顶点数据,因此单个批处理中的点较少。与动态批处理不同,GPU实例化确实适用于阴影,因此在这种情况下它更出色。



(URP 分别再 不开启任何优化, dynamic batching,和GPU instancing的表现)


最后,在启用SRP的情况下,将10000点列为11个SRP Batch命令,但请记住,这些仍然是单独的绘制调用,只是非常有效的调用。

(URP 开启SRP batcher)


1.5 额外的灯光


到目前为止,我们得到的结果是针对我们的视图的,带有一个单一的方向光,以及我们使用的其他项目设置。让我们看看当我们向场景中添加第二个灯光时,特别是通过GameObject/ Light / Point Light的点光源时会发生什么。将其位置设置为零,并确保它不投射阴影,这是其默认设置。DRP支持点光源的阴影,但URP仍然不支持。

(原点上不带阴影的点光源)


现在,有了额外的灯光,DRP绘制所有点需要更多的时间。帧调试器向我们展示了RenderForward.RenderLoopJob渲染的次数是以前的两倍。更糟糕的是,动态批处理现在仅适用于深度和阴影通道,而不适用于前向通道了。


(DRP没有和有动态批处理)


发生这种情况是因为DRP每个光源绘制一次每个对象。它具有一个主要通道,该通道与单个定向光源一起使用,然后在上面渲染其他通道。发生这种情况是因为这是一个老式的前向渲染通道。动态批处理无法处理这些不同的Pass,因此不会被使用。

对于GPU实例化也是一样的,除了它仍然在主要通道上工作。额外的additional light passes 不能从中受益。

(DRP开启GPU instancing)


但第二个光源对于URP似乎没有影响,因为它是一种现代的前向渲染器,可一次应用所有光源。因此,即使GPU每次绘制需要执行更多的光照计算,命令列表仍保持不变。

这些结论对于影响所有点的单个额外的光来说是成立的。如果你添加更多的灯光并移动它们,不同的点会受到不同灯光的影响,事情就会变得更加复杂,当GPU实例化被使用时,批次会被分割。对于一个简单的场景是正确的,对于一个复杂的场景可能并不正确。


延迟渲染呢?
DRP和HDRP都具有正向和延迟渲染模式,而URP当前没有。延迟渲染的想法是对象被绘制一次,然后将其可见表面属性存储在GPU缓冲区中。此后,一个或多个灯光Pass,仅将照明应用于可见的区域。与正向渲染相比,它具有优点和缺点,但是在本教程系列中我们不会进行介绍。


1.6 Profiler


为了更好地了解CPU方面的情况,可以打开profiler窗口。关闭点光源,然后通过Window/ Analysis / Profiler打开窗口。 它将在播放模式下记录性能数据并存储以供以后检查。


Profiler被分为两个部分。它的顶部包含显示各种性能图的模块列表。第一个是CPU使用率,这是我们将要关注的。选中该模块后,窗口的底部将显示我们可以在图中选择的帧的详细分解。


(Profiler 显示CPU使用的时间线,分别展示的是DRP和URP)


CPU使用率的默认底部视图是时间线。它可以可视化在一个帧中花费了多少时间。它显示了每个帧都以PlayerLoop开始,后者花费了大部分时间调用RunBehaviourUpdate。再往后两步,我们看到这主要是对Graph.Update方法的调用。你可以选择一个时间轴块来查看其全名和持续时间(以毫秒为单位)。


在初始的player loop片段之后是一个简短的EditorLoop部分,之后是另一个player片段,用于帧的渲染部分,CPU告诉GPU做什么。工作在主线程、渲染线程和一些作业工作线程之间被分割,但是DRP和URP的具体方法不同。这些线程并行运行,但当一个线程必须等待另一个线程的结果时,它们也有同步点。


在渲染部分之后,当渲染线程仍然忙碌时,如果URP被使用,在下一帧开始会出现另一个编辑器段。

如果您对线程的确切时间不感兴趣,则可以通过左侧的下拉列表将Timeline视图替换为Hierarchy视图。层次结构在单个可排序列表中显示相同的数据。通过此视图,可以更轻松地查看花费时间最长的时间以及发生内存分配的位置。


1.7 分析一次构建


分析器很明显地看出来,编辑器自身为应用程序增加了很多开销。因此,在单独运行我们的应用程序时,对它进行配置很有用。为此,我们需要构建我们的应用程序,专门用于调试。我们可以在“构建设置”窗口(通过File / Build Settings.... 打开)中配置应用的构建方式。如果尚未进行配置,则Scenes in Build 部分为空。这没什么问题,因为默认情况下将使用当前打开的场景。


你可以选择目标平台,但当前开发平台上的一般最方便。然后启用Development Build和Autoconnect Profiler选项。

(Development Build 模式)


要最终创建通常称为构建的独立应用程序,请按Build按钮,或者在构建过程完成后,单击Build and Run立即打开该应用程序。你也可以通过File / Build and Run或指示的快捷方式触发另一个构建。

构建过程需要多长时间?
使用URP时,首次构建花费的时间最长,并且可能会忙几分钟。之后,如果可能,Unity将重用以前生成的构建数据,从而大大加快了该过程。除此之外,项目越大,花费的时间越长。


一旦构建自行运行,请过一会儿将其退出,然后切换回Unity。Profiler现在应包含有关其执行方式的信息。在首次构建后,这种情况并不总是会发生,如果是的话,请再试一次。还需要记住,即使启用了Clear on Play功能,Profiler也不会清除附加到内部版本的旧数据,因此,如果仅运行应用程序几秒钟,请确保你正在查看相关的帧。


(分析构建后的版本 DRP和URP)


因为没有编辑器开销,所以Build之后的性能应比Unity编辑器中的播放模式更好。Profiler确实将不再显示编辑器循环部分。在我的示例中,使用URP时,CPU现在还必需要等待VSync,这表明帧速率受显示刷新率的限制。同样,渲染线程似乎延伸到下一帧以进行URP。发生这种情况是因为Unity可以利用并行性在渲染线程完成之前启动主线程上下一帧的更新循环。我们将在下一部分稍后再讨论。


2 展示帧率


我们并不总是需要详细的性能分析信息,通常只要大致了解一下帧速率即可。另外,我们(或其他人)可能在没有Unity编辑器可用的地方运行我们的应用程序。对于这些情况,我们可以做的是在一个小的覆盖面板中测量并在应用程序本身中显示帧。此类功能默认情况下不可用,因此我们将自行创建。


2.1 UI面板


可以使用Unity的游戏界面创建一个小的overlay面板。我们还将使用TextMeshPro创建文本以显示帧频。TextMeshPro是一个单独的程序包,其中包含高级文本显示功能,优于默认的UI文本组件。如果尚未安装其软件包,请通过软件包管理器添加它。这也会自动安装Unity UI软件包,因为TextMeshPro依赖于它。


我们使用TextMeshPro创建文本以显示帧频。TextMeshPro是一个单独的程序包,其中包含高级文本显示功能,优于默认的UI文本组件。如果尚未安装其软件包,请通过软件包管理器添加它。这也会自动安装Unity UI软件包,因为TextMeshPro依赖于它。

一旦UI包成为项目的一部分,就可以通过GameObject/ UI / Panel创建一个面板。这将创建一个覆盖整个UI画布的半透明面板。画布与游戏窗口大小匹配,但在场景窗口中更大。最简单的方法是通过场景窗口工具栏启用2D模式,然后进行缩小。

(面板覆盖了画布)


每个UI都有一个canvas根对象,它是在我们添加面板时自动创建的。面板是画布的子元素。它创建了一个EventSystem游戏对象,它负责处理UI输入事件。我们不会使用这些,所以可以忽略甚至删除它。

(UI游戏对象层次)


画布有一个scaler组件,可用于配置UI的比例。默认设置假设像素大小不变。如果你使用的是高分辨率或视网膜显示,那么你就必须增加比例因子,否则UI就会太小。你还可以尝试其他的缩放模式。

(UI Canvas 对象)


UI游戏对象具有专门的RectTransform组件,该组件替代了常规的Transform组件。除了通常的位置,旋转和缩放之外,它还显示宽度,高度,枢轴和锚点。锚控制对象相对于其父对象的相对位置和大小调整行为。更改它的最简单方法是通过单击方形锚图像打开的弹出窗口。

(UI Panel)


我们将帧速率计数器面板放在窗口的右上方,因此将面板的锚点设置在右上方。然后将宽度设置为38,将高度设置为70,将XY位置设置为这些尺寸的一半。另外,我们也可以在两个维度上都将枢轴设置为1,然后将位置设置为零。然后将图像组件的颜色设置为黑色,并保持其Alpha不变。

(右上角 深色的Panel)


2.2 Text


要将文本放入面板,请通过GameObject/ UI / Text-TextMeshPro创建一个TextMeshPro UI文本元素。如果这是你第一次创建TextMeshPro对象,则将显示Import TMP Essentials弹出窗口。按照建议导入。这将创建一个TextMesh Pro资产文件夹,其中包含一些资产,我们无需直接处理。


创建文字游戏对象后,使其成为面板的子节点,将其锚定为两个方向的拉伸模式。这将用右侧和底部字段替换宽度和高度。现在,使其与整个面板重叠,这可以通过将left,top,right和bottom设置为零来完成。还要给它一个描述性名称,例如Frame Rate Text。

(UI Text)


接下来,对TextMeshPro-文本(UI)组件进行一些调整。将Font Size设置为14,将Alignment设置为居中居中。然后用占位符文本(特别是FPS)填充文本输入区域,然后是三行,每行三个零。

(Text 设置)


现在,我们可以看到帧速率计数器的外观。三行显示为0的就是我们稍后将显示的统计信息的占位符。

(Frame rate text)


2.3 更新显示


要更新计数器,我们需要一个自定义组件。为FrameRateCounter组件创建一个新的C#脚本资产。给它一个可序列化的TMPro.TextMeshProUGUI字段,以保存对用于显示其数据的文本组件的引用。

将此组件添加到文本对象并连接显示。

(帧率计数器组件)


要显示帧速率,我们需要知道前一帧和当前帧之间经过了多少时间。可通过Time.deltaTime获得此信息。但是,此值受可用于时间调整(例如时间停止或项目符号时间)的时间刻度的限制。我们需要改用Time.unscaledDeltaTime。在FrameRateCounter中新的Update方法开始时对其进行检索。

下一步是调整显示的文本。我们可以通过使用文本字符串参数调用其SetText方法来做到这一点。提供与我们已有的相同的占位符文本。在双引号之间写入一个字符串,并使用特殊的\ n字符序列写入一个换行符。

TextMeshProUGUI具有各种SetText方法,这些方法可以接受附加的float参数。将帧持续时间添加为第二个参数,然后在大括号内将字符串的第一个三零行替换为一个零。这表明应该在字符串中插入float参数的位置。

帧持续时间告诉我们经过了多少时间。为了显示帧速率表示为每秒帧数,我们必须显示其倒数,因此将其除以帧持续时间。

这将显示一个有意义的值,但是它将有很多数字,例如59.823424。我们可以指示文本四舍五入到小数点后的特定位数,方法是在零后面加上颜色和所需的数字。我们将舍入为整数,所以加零。

(显示上一帧的帧率)


2.4 平均帧率


由于连续帧之间的时间几乎永远不会完全相同,因此显示的帧速率最终会迅速变化。通过显示平均帧速率而不是仅显示最后一帧的速率,可以减少不稳定现象。通过跟踪已渲染的帧数和总持续时间,然后显示帧的数量除以它们的合并持续时间,可以做到这一点。

这将使我们的帧率趋势变为运行时间越长,越趋向于稳定的平均值,但是该平均值适用于我们应用的整个运行时间。由于我们需要最新的信息,因此我们必须重新设置并重新开始,并采样新的平均值。可以通过添加可序列化的采样持续时间字段(默认设置为一秒钟)来使其可配置。给它一个合理的范围,例如0.1–2。持续时间越短,我们得到的结果就越精确,但是随着变化速度的加快,将会变的很难理解。


(采样时间设定为1秒)


从现在开始,我们仅在累计持续时间等于或超过配置的采样持续时间时调整显示。我们可以使用>=大于等于运算符进行检查。更新显示后,将累积的帧和持续时间设置回零。

(1秒的平均帧率)


2.5 最好和最差


平均帧率波动是因为我们的应用程序的性能不是恒定不变的。有时它会变慢,这是因为它暂时有更多工作要做,或者是因为同一台计算机上运行的其他进程妨碍了它。为了了解这些波动有多大,我们还将记录并显示在采样期间发生的最佳和最差帧持续时间。默认情况下,将最佳持续时间设置为float.MaxValue,这是最坏的最佳持续时间。

每次Update都会检查当前帧持续时间是否小于到目前为止的最佳持续时间。如果是,则使其成为新的最佳持续时间。还要检查当前帧持续时间是否大于迄今为止最差的持续时间。如果是这样,则使其成为新的最差持续时间。

现在,我们将最佳帧速率放在第一行,将平均帧放在第二行,将最差帧速率放在最后一行。通过向SetText添加两个额外参数并向字符串添加更多占位符来实现。它们是索引,因此第一个数字以0表示,第二个数字以1表示,第三个数字以2表示。此后,还重置最佳和最差持续时间。

(最好、平均和最差帧率)


请注意,即使启用了VSync,最佳帧率也可能超过显示刷新率。同样,最坏的帧速率不必一定是显示刷新速率的倍数。这是可能的,因为我们不是测量显示的帧之间的持续时间。而是在测量Unity帧之间的持续时间,这是其更新循环的区间迭代。


Unity的Update循环无法与显示器完美同步。当Profiler显示当前帧的渲染线程仍在忙时,下一帧的播放器循环开始时,我们已经看到了提示。渲染线程完成后,GPU仍有一些工作要做,此后仍需要一些时间才能刷新显示。因此,我们显示的FPS不是真实的帧速率,而是Unity告诉我们的。理想情况下,这些是相同的,但是正确处理是复杂的。

有一篇关于Unity如何在这方面改进的博客文章,但这并没有讲述完整的内容。
https://blogs.unity3d.com/2020/10/01/fixing-time-deltatime-in-unity-2020-2-for-smoother-gameplay-what-did-it-take/


2.6 帧持续时间


每秒帧数是衡量感知性能的一个很好的单位,但是当尝试达到目标帧速率时,显示帧持续时间会更有用。例如,当尝试在移动设备上实现稳定的60FPS时,每个毫秒都非常重要。因此,我们将显示模式配置选项添加到我们的帧频计数器中。

在FrameRateCounter中为FPS和MS定义一个DisplayMode枚举,然后添加该类型的可序列化字段,默认情况下设置为FPS。


(可配置的显示模式)


然后,当我们在Update中刷新显示时,请检查模式是否设置为FPS。如果是,请执行我们已经在做的事情。否则,将FPS标头替换为MS并使用反参数。将它们也乘以1000,即可将秒数转换为毫秒数。

(单帧最好、平均和最差的毫秒)



帧持续时间通常以十分之一毫秒为单位。我们可以通过将数字舍入从零增加到1来将显示精度提高一级。


(更高的精度)


2.7 内存分配


我们的帧频计数器已经完成,但是在继续之前,我们先检查一下它对性能的影响。显示UI需要每帧更多的绘制调用,但实际上并没有什么不同。在播放模式下使用profiler,然后搜索我们在其中更新文本的帧。事实证明,这并不需要很多时间,但是它确实分配了内存。通过层次结构视图按GC Alloc列排序最容易检测到。

(内存分配情况)


文本字符串是对象。当我们通过SetText创建一个新的字符串时,这将产生一个新的字符串对象,该对象负责分配48个字节。然后,Unity的UI刷新将其增加到5 KB。尽管数量不多,但它会累积,在某个时候触发内存垃圾回收过程,这将导致不希望的帧持续时间尖峰。


注意临时对象的内存分配并尽可能地消除重复出现的对象是很重要的。幸运的是,因为各种原因,SetText和Unity的UI update只在编辑器中执行这些内存分配,比如更新文本输入字段。如果我们对一个Build进行剖析,那么我们将不会发现这些分配。所以这是建立概要文件的必要条件。编辑器播放模式下的性能分析只对第一印象好。


3 自动进行函数切换


现在,我们知道了如何分析应用程序,我们可以在显示不同功能时比较其性能。如果某个功能需要更多的计算,则CPU必须做更多的工作,从而降低帧速率。尽管如何计算对GPU没有影响。但如果分辨率相同,GPU将必须执行相同的工作量。


wave 和torus功能之间的最大区别是CPU的使用率,我们可以通过分析器比较它们的差别。我们可以比较配置了不同功能的两个单独的运行,也可以在播放模式下进行配置文件并在播放期间进行切换。

(从torus 到wave的切换出现了峰值)


CPU图显示,从圆环切换为波浪形后,负载确实减小了。切换发生时,还会出现巨大的帧持续时间尖峰。发生这种情况的原因是,通过编辑器进行更改时,播放模式会暂时暂停。由于取消选择和编辑器焦点更改,后来也出现了一些其他峰值。


峰值属于另一种类型。通过切换左侧的类别标签,可以过滤CPU图,这样我们只能看到相关的数据。禁用另一个类别时,计算量的变化更明显。

(其他的种类,没有展示)


由于暂停,通过检查器进行的切换功能很难进行配置。更糟糕的是,我们必须重新构建一个新的版本来分析单独的功能。我们可以通过自动或通过用户输入通过其检查器添加将功能切换到图形的功能来改进此功能。我们将在本教程中选择第一个选项。


3.1 函数循环


我们的想法是让所有功能自动循环。每个功能将显示固定的时间,此后将显示下一个功能。要使功能持续时间可配置,请为其在Graph上添加一个可序列化的字段,默认值为一秒钟。还可以通过为其赋予Min属性来将其最小值设置为零。持续时间为零将导致每帧切换到不同的功能。


(函数持续时间)


从现在开始,我们需要跟踪当前功能的激活时间,并在需要时切换到下一个功能。这会使我们的Update方法复杂化。它的当前代码仅用于更新当前函数,因此让我们将其移至单独的UpdateFunction方法,并让Update调用它。这样可以使我们的代码井井有条。

现在,添加一个持续时间字段,并在更新开始时将其增加(可能是按比例缩放的)增量时间。然后,如果持续时间等于或超过配置的持续时间,则将其重置为零。之后是UpdateFunction的调用。

我们很可能永远不会完全达到功能持续时间,我们会稍微超过它一点。可以忽略这一点,但是要与功能开关的例外时序保持合理的同步,应该从下一个功能的持续时间中减去额外的时间。我们通过从当前持续时间中减去所需的持续时间而不是将其设置为零来实现。

为了遍历函数,我们将在FunctionLibrary中添加GetNextFunctionName方法,该方法采用一个函数名称并返回下一个。由于枚举是整数,因此我们可以在其参数中加一个并返回它。

但是我们还需要循环回第一个函数才行,否则,当移到最后一个函数在循环时,将得到一个无效的名称。因此,仅当提供的名称小于枚举数时,我们才可以增加它。否则,我们将返回第一个函数,即wave。可以使用if-else块来执行此操作,每个块都返回适当的结果。

通过将名称(以int形式)与函数数组的长度减去一个(与最后一个函数的索引匹配)的长度进行比较,可以使该方法与函数名称无关。如果最后我们也可以返回零,这是第一个索引。这种方法的优点是,如果以后更改函数名称,则无需调整方法。

也可以通过使用?:三元条件运算符将方法主体简化为单个表达式。这是带有-的if-then-else表达式。和:分离各部分。两种选择都必须产生相同类型的值。

在适当的时候使用Graph.Update中的新方法切换到下一个函数。

(函数循环)


现在,我们可以通过对build进行概要分析来依次查看所有功能的性能。

(对循环函数进行Profile)


在我的例子中,所有函数的帧速率都是一样的,因为它从不低于60FPS。通过等待垂直同步来消除这些差异。隐藏VSync可以使函数的不同加载更容易在图中看到。

(垂直同步关闭)


事实证明,Wave最快,其次是Ripple,然后是Multi Wave,其次是Sphere,而Torus最慢。我们有代码,这符合我们的期望。


3.2 随机函数


让我们通过添加一个在函数之间随机切换而不是循环固定序列的选项来使我们的图更有趣。将一个GetRandomFunctionName方法添加到FunctionLibrary中以支持此方法。它可以通过调用零的Random.Range和函数数组长度作为参数来选择随机索引。选择的索引是有效的,因为这是方法的整数形式,为此提供的范围是包含所有值的范围。

我们可以更进一步,确保我们永远不会连续两次获得相同的功能。为此,将我们的新方法重命名为GetRandomFunctionNameOtherThan并添加一个函数名称参数。将Random.Range的第一个参数增加为1,因此永远不会随机选择索引零。然后检查选择是否等于要避免的名称。如果是这样,则返回名字,否则返回所选名字。因此,我们用零代替了不允许的索引,而没有引入偏差的方式。

返回到Graph,为过渡模式添加配置选项,可以是循环或随机的。再次使用自定义枚举字段执行此操作。

选择下一个功能时,请检查转换模式是否设置为循环。如果是这样,则调用GetNextFunctionName,否则调用GetRandomFunctionName。因为这会使选择下一个函数变得复杂,所以我们也将这段代码放在一个单独的方法中,以使Update保持简单。

(选择随机函数)


3.3 函数插值


我们通过使功能之间的过渡更加有趣来结束本教程。无需突然切换到另一个函数,我们就可以将图形平滑地变形为下一个。这对于性能分析也很有趣,因为它需要在过渡期间同时计算两个函数。


首先在FunctionLibrary中添加一个Morph函数,该函数将负责过渡。为它提供与函数方法相同的参数,外加两个Function参数和一个float参数以控制变形进度。

我们使用Function参数而不是FunctionName参数,因为这样Graph可以在每次更新时按名称检索一次函数,因此我们不必每个点访问两次函数数组。


为什么要在Graph检索中每个Update Graph的函数?
我们也可以将函数存储在Graph的字段中,而不用获取每次更新。我们之所以不这样做,是因为Function类型的字段值不能在热重载中生存,而FunctionName字段却可以。而且,每次更新检索一个或两个功能不会对性能产生有意义的影响。但是,每次更新每个点都要这样做,这会带来很多不必要的额外工作。


进度是一个0–1的值,我们将使用它来从第一个提供的函数插入到第二个函数。我们可以为此使用Vector3.Lerp函数,将两个函数的结果和进度值传递给它。

Lerp是线性插值的缩写。它将在两个函数之间产生一个直线的恒速转换。我们可以通过放慢开始和结束的进度来让它看起来更流畅一些。这是通过将原始进程替换为对Smoothstep的调用,使用0、1和progress作为参数来实现的。它应用了函数,通常称为平滑步长。平滑步长的前两个参数是这个函数的偏移量和比例,我们不需要它,所以用0和1。

(0~1平滑步长VS线性)


Lerp方法限制了它的第三个参数,因此它在0–1范围内。Smoothstep方法也可以做到这一点。我们将后者配置为输出0–1的值,因此不需要额外的Lerp钳位。对于这种情况,有另一种LerpUnclamped方法,所以我们改用它。


3.4 过渡


函数之间的过渡期需要一个持续时间,因此请为它添加一个配置选项到Graph,并且最小和默认值与函数持续时间相同。


(过渡持续时间)


现在,我们的图形可以处于两种模式,即过渡与否。我们将使用布尔类型的布尔型字段来跟踪此情况。我们还需要跟踪要转换的函数的名称。

UpdateFunction方法用于显示单个功能。复制它,并将新的命名为UpdateFunctionTransition。对其进行更改,使其同时获得两个功能并计算进度,即当前持续时间除以过渡持续时间。然后让它调用Morph而不是在其循环中调用单个函数。

最后,请检查我们是否正在过渡。如果是这样,则调用UpdateFunctionTransition,否则调用UpdateFuction。

一旦持续时间超过了function duration时间,我们就进入下一个持续时间。在选择下一个函数之前,请先说明我们正在过渡,并使过渡函数等于当前函数。

但是,如果我们已经在过渡,则必须做其他事情。因此,首先检查我们是否正在过渡。只有在这种情况下,才需要检查是否超过了功能持续时间。

如果要过渡,则必须检查是否超过过渡持续时间。如果是这样,请从当前持续时间中减去过渡持续时间,然后切换回单功能模式。

(不同函数之间的过渡)


现在,如果我们进行概要分析,我们可以看到确实在过渡期间Graph.Update需要花费更长的时间。究竟需要多少时间取决于它在两个功能之间的融合。


(Profiler构建显示过渡的额外工作,有和没有垂直同步)


需要重申的是,你获得的性能分析结果取决于你的硬件,并且可能与我在本教程中显示的示例完全不同。在开发自己的应用程序时,请确定你支持哪些最低硬件规格并通过这些最低规格进行测试。你的开发机器仅用于初步测试。如果要针对多个平台或硬件规格,则需要多个测试设备。

下一章节 计算着色器。


欢迎扫描二维码,查看更多精彩内容。点击 阅读原文 可以跳转原教程。










本文翻译自 Jasper Flick的系列教程

原文地址:

https://catlikecoding.com/unity/tutorials








本文分享自微信公众号 - 壹种念头(OneDay1Idea)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!