某愿朝闻道译/原文地址

  • 使用物理系统来模拟持续增长的原子核
  • 使用profiler来查看运行性能
  • 测量和显示帧率
  • 防止创建临时字符串
  • 通过帧率的平均值来稳定帧率
  • 对帧率显示着色

在这个教程中我们将创建一个场景, 并考量其运行性能. 我们首先要检查profiler窗口, 之后我们需要创建自己的帧率计数器

本教程需要你有一定的Unity的C#脚本基础. 如果欠缺基础, 请首先看看构造分形
每秒帧数(FPS) - 图1
不断增加球体, 直到你的帧率变成渣

建立原子核

新建一个空场景, 测试高运行性能和低运行性能的情况. 我们将通过不断扩增物体的数量来模拟创建一个原子核. 随着原子核的变大, 运行性能将会逐渐变差.

构成原子核的物体就是被吸引到场景中心的球体, 在那里它们将会挨着堆在一起. 这当然不是真是的原子核的样子, 但是对于我们要做的事情这并不是重点.

我们将使用Unity自带的球体及一个自定义的Nucleon脚本组件来塑造原子核. Nucleon脚本需要可以保障它所属的物体带有刚体(Rigidbody)组件, 并将物体推向场景中心, 也就是世界坐标系的原点. 这个推力的大小, 与我们配置的引力大小以及物体距离原点的距离远近有关, 新建Nucleon脚本, 删除默认代码, 书写下方代码 :

  1. using UnityEngine;
  2. [RequireComponent(typeof(Rigidbody))]
  3. public class Nucleon : MonoBehaviour {
  4. //代表引力的字段
  5. public float attractionForce;
  6. //代表刚体的字段
  7. Rigidbody body;
  8. void Awake () {
  9. //用body存储物体的刚体组件
  10. body = GetComponent<Rigidbody>();
  11. }
  12. void FixedUpdate () {
  13. //对刚体施加作用力
  14. body.AddForce(transform.localPosition * -attractionForce);
  15. }
  16. }

上面的方法签名怎么没有书写访问修饰符?

没错, 我省略了方法声明时写在前面的private访问修饰符, 如果不书写, 则默认是private的.

接着使用球体创建两个核体(原子核的物体简称核体)的预制体, 一个代表质子, 一个代表中子. 为它们分别使用不同的材质, 以便进行区分. 虽然它们可以设置的看起来都一样, 但是那样我们生成的原子核会很单调无聊

什么是预制体(Prefab)?

预制体就是一个不在场景中存在的物体. 你可以用它作为模板来创建它的复制体, 并把复制体放到场景中. 为了创建一个预制体, 首先在场景创建好一个普通的物体, 然后把这个物体拖拽到Project窗口中的某个目录下. 这样就在Project窗口中添加了一个预制体资源文件, 而场景中被拖拽的这个物体也会变成该预制体的一个实例(instance 相当于前面提的复制体), 同时你已经不再需要场景中这个物体了, 可以删掉它, 已经创建的预制体不会被删除这个物体而影响.

每秒帧数(FPS) - 图2 每秒帧数(FPS) - 图3

每秒帧数(FPS) - 图4
一红一篮两个预制体

===========翻译者补充内容开始↓↓↓========

原文没有创建预制体并设置不同材质颜色的描述, 为了防止某些萌新卡在这里, 我补充一下
1) 首先在场景中创建俩3D球体, 在Hierarchy窗口中点击右键, 选择 : 3D Object > Sphere; 俩球体分别起名叫ProtonPrefab 和 NeutronPrefab
2) 创建俩材质资源, 在Project窗口点击中点击右键, 选择 : Create > Material; 俩材质资源分别起名叫ProtonMaterial和NeutronMaterial
3) 在每个材质的Inspector中, 分别设置ProtonMaterial的颜色为红色, NeutronMaterial的颜色为蓝色, 颜色设置位置如下图所示 :Collab
image.png

4) 将两个材质直接拖拽到Hierarchy中对应名称的物体上, 此时物体就根据材质变色了

5) 将Nucleon脚本分别拖拽给两个物体, 记得在Inspector设置Attractions Force的值为10, 默认是0, 不要忘记修改它.
6) 将两个物体依次从Hierarchy窗口拖拽到Project窗口, 然后删除Hierarchy窗口中的两个物体, 此时预制体创建完毕, 如下图 :
image.png

===========翻译者补充内容结束↑↑↑========

为了可以产生核体, 我们需要创建另一个脚本, 叫做NucleonSpawner. 它需要知道每次生成核体的时间间隔, 以及在什么位置生成哪个核体 , 新建该脚本, 将默认代码覆盖为下面的代码 :

  1. using UnityEngine;
  2. public class NucleonSpawner : MonoBehaviour {
  3. //生成时间间隔
  4. public float timeBetweenSpawns;
  5. //生成的位置距离
  6. public float spawnDistance;
  7. //核体数组, 存储可以选择生成的核体
  8. public Nucleon[] nucleonPrefabs;
  9. }

创建一个叫Nucleon Spawner的空物体, 为它添加NucleonSpawner脚本, 根据你的喜好设置脚本在Inspector中的属性(如果没主意, 按照图里设置即可, Nucleon Prefabs属性点击左边小三角展开, 就可以设置Size属性, 设置为2, 下方就会多出两行空位, 把刚才做的中子和质子预制体拖拽到这里)
每秒帧数(FPS) - 图7

为了按照特定间隔产生核体, 我们需要记录上一次上次核体的时间点, 然后根据它进行时间间隔的计时. 我们可以在Unity内置的FixedUpdate方法中编写这些代码 :

  1. //新增记录上一次产生核体后经过的世时间
  2. float timeSinceLastSpawn;
  3. //新增FixedUpdate方法
  4. void FixedUpdate () {
  5. //每次FixedUpdate执行都对timeSinceLastSpawn累加经过的时间, Time.deltaTime的值就是FixedUpdate方法的执行间隔
  6. timeSinceLastSpawn += Time.deltaTime;
  7. //如果经过的时间大于我们设置的时间间隔, 就产生新的核体, 并重置timeBetweenSpawns为0, 重新开始记录时间
  8. if (timeSinceLastSpawn >= timeBetweenSpawns) {
  9. timeSinceLastSpawn -= timeBetweenSpawns;
  10. //使用该方法产生核体, 该方法下文继续介绍具体代码内容
  11. SpawnNucleon();
  12. }
  13. }

为什么不用Update方法而用FixedUpdate方法?

使用FixedUpdate可以使得产生核体的间隔与帧率无关. 如果设置的生成时间间隔小于帧率, 用Update进行时间记录就会导致延迟. 特别是我们这个教程将在场景中让帧率变卡, 使用Update就一定会发生这种情况.
你可以使用While循环代替if来检查是否错失了某次生成机会, 但是这可能会导致
而FixedUpdate的执行间隔与帧率无关, 所以这里使用它, 计算游戏运行时帧率发生变化也不影响程序逻辑的正常工作

新增SpawnNucleon方法来执行产生核体的工作, 分为三步 : 随机选择预制体, 创建该预制体创建核体, 设置核体初始位置 :

  1. //新增方法SpawnNucleon
  2. void SpawnNucleon () {
  3. //随机选择预制体
  4. Nucleon prefab = nucleonPrefabs[Random.Range(0, nucleonPrefabs.Length)];
  5. //创建上述预制体实例, 代表生成的核子
  6. Nucleon spawn = Instantiate<Nucleon>(prefab);
  7. //设置核子的位置 Random.onUnitSphere会返回一个随机坐标点, 该坐标点位于原点
  8. spawn.transform.localPosition = Random.onUnitSphere * spawnDistance;
  9. }

每秒帧数(FPS) - 图8

ReadyActiveIndigowingedparrot.webm (262.26KB)保存代码, 运行后将会不断生成向场景中心移动的质子和中子物体, 聚拢成一个球状整体

保存代码, 运行后将会不断生成向场景中心移动的质子和中子物体, 聚拢成球状. 最初物体较少时, 一段时间内它们会反复的穿过场景中心震荡运动, 直到物体渐渐变多, 它们互相之间发生碰撞和阻挡, 稳定的球体也就渐渐形成了. 这个球体将会随物体继续生成而持续增长, 随之物理系统也会进行更多计算, 如果不停止游戏, 持续运行下去, 某个时间点开始你就会觉察到帧率的下降.

如果太长时间你都没有发现性能变差帧率下降, 你可以增加下生成速率, 也就是降低Inspector中Time Between Spawns属性的值. 增加运行时间系数Time Scale也同样可以提高生成速率. 你可以通过菜单 : Edit / Project Settings / Time.来找到这个设置属性, 在同样的菜单位置你也可以降低Fixed Timestep属性从而减少每次FixedUpdate的运行时间间隔来增加物理系统每秒的运算次数. (此处的各种设置不是必须, 如果你改了, 建议看完帧率降低的效果后再改回去, 避免后面出现问题你也忘了是因为你改了什么)
每秒帧数(FPS) - 图10
Unity的时间相关属性设置

为什么我把TimeScale设置低时物体运动会不流畅?

当TimeScale设置为低值, 比如0.1, 这样程序运行时的时间会流逝的非常缓慢. 这个时间速度的变化会同样会影响FixedUpdate方法的执行时间间隔, 所以物理系统的计算频率也就下降了. 物体直到下一次FixedUpdate方法执行前都会一动不动, 于是会感觉运动变得不流畅了
(所以时间设置这里, 如果你更改了默认值查看效果, 记得改回去, 避免不必要的问题影响你学习教程)

参考源码

使用Profiler

此时我们就有了一个只要不断运行, 就最终可以让任何运行设备都变卡掉帧的一个场景, 那么现在就是时候来衡量下性能的变化到底是个什么情况. 最快的可以让你观察性能变化的就是Game窗口中可以打开的Statistics面板, 它通过Game窗口右上角的Stats按钮打开, 如下图
每秒帧数(FPS) - 图11
Game窗口中的Statistics面板

然而, 帧率(FPS)在这里显示的并不完全准确, 而只是一个大概的估算值. 我们可以通过打开Unity的Profiler(探查器)而更好的观测游戏运行性能, 打开该Profiler的菜单位置为 : Window / Profiler. Profiler为我们提供了很多有用的信息, 尤其是CPU Usage(CPU使用)数据和Memory(内存)数据, 如下图所示
每秒帧数(FPS) - 图12
Profiler窗口, 图中左侧的曲线颜色含义说明, 我们发现此时CPU有相当一部分在处理垂直同步(VSync)

在我们这个例子, 如果开启了垂直同步(V Sync), 则会导致CPU为它提供的性能开销占比较大, 影响我们观察曲线. 所以为了能够更好的观察CPU被我们的场景物体所影响的情况, 应该关闭垂直同步, 你可以前往菜单 : Edit / Project Settings / Quality 然后找到V Sync Count属性, 设置其下拉选项为Don’t Sync, 来关闭垂直同步.
每秒帧数(FPS) - 图13
关闭垂直同步

关掉垂直同步后我发现帧率突然高的离谱啊!

对于简单场景, 关闭垂直同步后会提升很高的帧率, 远超100. 这会给硬件带来不必要的压力. 你可以通过代码, 设置Application.targetFrameRate属性来强制规定一个最大帧率从而避免发生这种情况. 需要注意的是, 如果通过代码设置了帧率最大值, 即使退出运行模式后该帧率限制也会被保存在编辑组数据中. 将帧率最大值设置为-1则表示取消限制.
(注意, 该代码设置的帧率限制在编辑器状态下是不生效的, 它只会在打包后的程序中运行才会生效, 本例中不需要设置这个代码, 不影响教程的进行)
(还有点要特别注意, 如果你已经在代码中使用Application.targetFrameRate设置了一个具体的帧数限制, 此时会自动生效垂直同步机制, 所以你在Profiler中会依然看到垂直同步所占用的CPU资源比例, 你需要去修改你的代码, 将Application.targetFrameRate设置为-1来, 就可以解决这个问题)

关掉垂直同步后我们可以更好地观察CPU的使用情况了. 下图是我运行了一段时间后的Profiler, 可以看到对CPU占用最高的是Physics(物理), 其次是Rendering(渲染), 最后是Scripts(脚本). 这说明在运行一段时间后, 随着原子核球体的不断增长, 所有的东西都在变慢.
每秒帧数(FPS) - 图14
没有垂直同步的Profiler

我们会在Profiler的图表上观察到两种情况. 首先, CPU使用曲线会不时的出现高峰情况. 其次, 内存图表中显示出频繁的GC allocation项的高峰, 这表明有内存被分配并随后被释放. 因为我们除了创建新物体别的什么都没干, 所以这很奇怪.

上面两种现象是因为Unity的编辑器本身导致的. 当你在编辑器中进行操作, 如选择某个选项时, 就会出现CPU使用高峰. 内存分配是因为编辑器调用了GameView.GetMainGameViewRenderRect. 编辑器的这些影响都会带来额外的性能开销, 尤其是如果你的窗口布局可以同时查看场景和游戏窗口时. 简单的说, 编辑器本身的运行同样会反应在Profiler的性能统计中.

尽管你依然可以通过Profiler获得很多有用的信息, 但是如果你希望完全消除编辑器自身对于性能评估的影响, 你就需要将程序构建为一个可以独立运行的应用程序. 你可以通过菜单 : File / Build Settings 进行设置 :

===========翻译者补充内容开始↓↓↓========
此处原文省略了具体的构建应用程序的过程, 我来为萌新读者补充一下
1) 在你制作的Unity场景打开的情况下, 点击菜单 File > Build Settings
2) 按照下图进行选择和设置 :
image.png
3) 点击窗口下方的Build按钮, 在弹出的窗口中为要生成的应用程序文件选择一个目录, 建议新建一个文件夹, 然后选择这个文件夹作为生成目录
4) 点击后, 等待Unity进度条走完, 会自动弹出一个文件窗口打开应用程序生成的目录
5) 双击生成后的应用程序目录中后缀是 .exe的执行文件, 就会打开你的游戏, 此时切回到Unity, 观察Profiler, 它已经自动的开始监测你运行的这个游戏程序
===========翻译者补充内容结束↑↑↑========

每秒帧数(FPS) - 图16 每秒帧数(FPS) - 图17
Profiler与一个独立的应用程序进行了关联

当监测一个独立构建的游戏程序时, 会发现图表中的数据与之前非常不一样, 内存分配现在只会因创建物体而发生, 也没有再出现GC(garbage collection 垃圾回收, 清理内存的一种机制). 你也会发现在我上面的图中, CPU图表的Rendering部分占用了更多的计算时间, 因为我是全屏运行的. 同时, Scripts部分在图表中几乎看不见.

测量每秒帧数

Profiler窗口给我们显示了很多有用的信息, 但是这些信息还不足以让我们很好的去对帧率进行测量. FPS(Frames Per Second, 每秒帧数, 也就是帧率)在图表中不能直接观察到, 需要我们创建一个简单的脚本, 使用代码来告诉我们当前的FPS是多少. 这个脚本也将在本系列教程中第一次使用属性(Property), 创建一个public的属性就够用了, 它是整数类型的, 因为我们不关心FPS的小数部分, 新建脚本FPSCounter :

  1. using UnityEngine;
  2. public class FPSCounter : MonoBehaviour {
  3. //一个public的属性(Property), 我们之前的教程都是使用的字段(Field)
  4. //属性的这种写法会使得除了该脚本之外的其他脚本, 只能读取这个属性值, 而不能设置
  5. //也就是所谓的 "只读(read-only)"属性
  6. public int FPS { get; private set; }
  7. }

属性(Property)是如何工作的?

属性(Property)本质是一个方法(Method), 它的用法就像是一个字段(Field). 我们上面代码是一种简化的属性写法, 它等同于下面一段代码 :

private int fps;//一个非public的字段
public int FPS{
//get用来获取值, 前面没有写修饰符, 那么就按照属性生命的public设置
get{ return fps; }
//set用来设置值, 加了private, 则外部脚本不能调用设置方法, 只有该类自己可以设置值
private set{ fps = value; }
}

这种简写方式不能被Unity进行序列化工作, 所以你再Inspector中也看不到这个属性值, 不过这没关系, 我们不需要保存它也不需要手动设置它.

我们需要在每一次Update方法执行时, 用1除以当前帧的Time.deltaTime, 来计算FPS. 计算的结果使用”(int)”转换为整数型, 会舍弃结果的小数位 :

  1. void Update () {
  2. FPS = (int)(1f / Time.deltaTime);
  3. }

然而上述代码存在一个问题, 那就是Time.deltaTime的值会受到之前我们提到TimeScale设置的影响而加快或减慢. 这意味着如果我们的TimeScale设置不是1, 那么FPS会计算出一个错误的结果. 不过好在Unity给我们提供了另一个不受TimeScale设置影响的类似时间属性 :

  1. void Update () {
  2. //FPS = (int)(1f / Time.deltaTime);
  3. //顾名思义, unscaleDeltaTime, 可以得到没有被被时间系数缩放的deltaTime值
  4. FPS = (int)(1f / Time.unscaledDeltaTime);
  5. }

我们接下来用Unity的UI系统来显示计算出来的FPS值, 创建一个带有panel(面板)的Canvas(画布), 并在Panel中加入一个Text(文本). 上述内容都可以通过菜单选项添加 : GameObject / UI 中的子菜单.

在新增一个Canvas到场景中时, 会同时自动增加一个名为EventSystem的物体, 用来处理Canvas上的输入操作, 但是我们本篇教程不需要它, 所以你可以选择删除它.
每秒帧数(FPS) - 图18
创建好了的UI内容, Panel命名为FPS Panel, Text命名为FPS Label

选中Canvas, 在Inspector中找到下图中的Pixel Perfect选项, 勾选它, 如下图 :

每秒帧数(FPS) - 图19

FPS Panel用来创建一个半透明的黑色背景, 在它上面用FPS Label显示计算出的FPS值. 将FPS Label设置到屏幕的左上角位置, 然后设置它的锚点布局为左上方这样无论游戏窗口如何变化它都将相对左上角位置不变.

将FPS Label按照类似方式进行设置, 并设置文本垂直和水平都居中, 颜色为白色. 调整字体之类的属性, 让它可以刚好显示两位数字
(我用自己做的两张图替换了原文的两张图, 我标记了哪些东西需要该, 并且标记了更改顺序, 按照顺序设置即可)

image.pngimage.png
FPS Panel和FPS Label按照上图所示顺序和状态设置好

每秒帧数(FPS) - 图22 每秒帧数(FPS) - 图23
游戏窗口中在左上角可以看到设置好的UI内容

现在我们需要使用计算后的FPS属性值设置FPS Label的文本内容. 我们可以新创建一个叫做FPSDisplay的脚本来准备处理这件事. 它需要一个Text类型的字段fpsLabel代表文本物体, Text类型需要使用UnityEngine.UI命名空间 :

  1. using UnityEngine;
  2. using UnityEngine.UI;
  3. [RequireComponent(typeof(FPSCounter))]
  4. public class FPSDisplay : MonoBehaviour {
  5. public Text fpsLabel;
  6. }

FPSDisplay脚本创建完成后, 将其拖拽到FPS Panel物体上, 并将FPS Label拖拽到Inspector中的Fps Label属性栏中, 如下图所示 :
每秒帧数(FPS) - 图24

FPSDisplay脚本需要通过FPSCounter脚本在每一帧得到FPS的值, 然后去更新FPS Label的文本. 我们可以在Awake方法中使用一个字段引用FPSCounter脚本, 这样就不需要再Update中每次都调用GetComponent方法 :

  1. FPSCounter fpsCounter;
  2. void Awake () {
  3. //用fpsCounter引用FPSCounter脚本
  4. fpsCounter = GetComponent<FPSCounter>();
  5. }
  6. void Update () {
  7. //设置FPS Label的文本为计算出的FPS值
  8. fpsLabel.text = fpsCounter.FPS.ToString();
  9. }

保存代码, 运行, 现在FPS Label的文本会不断地更新变化了! 但是我们只为它设计了两位数字的显示, 所以它无法正确的显示三位数的帧率. 所以我们需要对FPS的值进行处理, 让任何大于99的帧率都按照99显示 :

  1. void Update () {
  2. //fpsLabel.text = fpsCounter.FPS.ToString();
  3. fpsLabel.text = Mathf.Clamp(fpsCounter.FPS, 0, 99).ToString();
  4. }

每秒帧数(FPS) - 图25
现在左上角的数字最大不会超过99

目前每件事看起来都工作正常, 不过其实还存在一些微小的问题. 我们目前的程序执行时, 会在每次Update执行时创建一个新的字符串对象, 它不会被下一次Update所用而被丢弃. 上述情况会影响到内存管理工作, 因为会针对这个临时的字符串对象不断进行内存分配和释放. 对于桌面应用来说这种开销可以忽略不计, 但是对于移动平台或是低端设备应该尽量避免不必要的性能开销. 对于本教程, 如果不解决这个问题, 也会干扰你观察Profiler
每秒帧数(FPS) - 图26
每次FPSDisplay.Update方法执行都会创建临时的字符串对象
(你自己本地观察不一定与原作者观察到的情况完全一致, 这受到你们电脑配置的影响)

===========翻译者补充内容开始↓↓↓========
原文此处没有告诉上图观察的数据如何显示出来, 我来补充一下 :
1) 打开Profiler窗口, 运行游戏
2) 此时Profiler窗口开始同步运行过程中的性能数据, 在图表中的任意位置单击左键, 就会暂停游戏运行, 并且在你点击的位置垂直显示一条标记线来表示这里被点击了, 如下图 :
image.png

3) Profiler下半部分窗口的左上角, 有个下拉选项, 默认是TimeLine, 把它改成Hierarchy, 如下图 :
image.png

4) 然后就会出现跟教程中一样的数据列表了, 在下图所示层次位置处就可以找到FPSDisplay.Update()方法在你第一步选中的帧处的开销数据了
image.png

===========翻译者补充内容结束↑↑↑========

我们可以解决这个问题吗? 我们代码显示的帧数字是在0到99之间, 也就是100个不同的字符串. 为什么我们不把这100个字符串全部创建好然后不断的循环利用呢? 这样就避免了大量的内存分配与销毁过程带来的性能开销 :

  1. //新建字符串数组, 存储00到99的数字字符串
  2. static string[] stringsFrom00To99 = {
  3. "00", "01", "02", "03", "04", "05", "06", "07", "08", "09",
  4. "10", "11", "12", "13", "14", "15", "16", "17", "18", "19",
  5. "20", "21", "22", "23", "24", "25", "26", "27", "28", "29",
  6. "30", "31", "32", "33", "34", "35", "36", "37", "38", "39",
  7. "40", "41", "42", "43", "44", "45", "46", "47", "48", "49",
  8. "50", "51", "52", "53", "54", "55", "56", "57", "58", "59",
  9. "60", "61", "62", "63", "64", "65", "66", "67", "68", "69",
  10. "70", "71", "72", "73", "74", "75", "76", "77", "78", "79",
  11. "80", "81", "82", "83", "84", "85", "86", "87", "88", "89",
  12. "90", "91", "92", "93", "94", "95", "96", "97", "98", "99"
  13. };
  14. void Update () {
  15. //fpsLabel.text = Mathf.Clamp(fpsCounter.FPS, 0, 99).ToString();
  16. //计算出来的帧率正好对应其字符串数组中同样内容元素的索引
  17. fpsLabel.text = stringsFrom00To99[Mathf.Clamp(fpsCounter.FPS, 0, 99)];
  18. }

通过使用这个固定字符串数组来代表我们可能用到的每一个数字, 我们避免了不断为临时字符串分配和销毁内存的问题!

参考源码

每秒平均帧数

每一帧去更新FPS值会带来一个不太好的效果, 那就是在游戏窗口中显示的文字会在帧率不稳定的时候不断的快速变化, 从而不易对其进行观察和评估. 虽然我们也可以选择间隔较长时间再更新它的文字, 不过这同样会让实际的帧率情况变得不易评估.

一个较为有效的解决办法是获取平均帧率, 将帧率的突然变化变得平滑, 减少数字变化的剧烈程度. 我们需要修改FPSCounter脚本的方法, 让它可以设置根据多少次的FPS计算来进行平均取值. 设置为1次则与我们现在的效果一样, 也就是每次计算出来就直接使用, 我们设置的次数多一些, 就可以降低帧率文字的变化速度与跨度.

  1. public class FPSCounter : MonoBehaviour
  2. {
  3. //新增字段frameRange, 代表对多少次的FPS结果进行平均计算
  4. public int frameRange = 60;

每秒帧数(FPS) - 图30
在Inspector中将新增的Frame Range设置为60

接着将属性FPS更名为AverageFPS, 它能更好的代表我们修改后的属性含义. 手动的或是利用IDE自动的修改每一处用到该属性的代码.

  1. public int AverageFPS { get; private set; }

===========翻译者补充内容开始↓↓↓========

FPS改名后需要修改的代码位置包括 :
FPSCounter脚本 :

  1. void Update()
  2. {
  3. //FPS = (int)(1f / Time.deltaTime);
  4. AverageFPS = (int)(1f / Time.deltaTime);
  5. }

FPSDisplay脚本 :

  1. void Update() {
  2. //fpsLabel.text = stringsFrom00To99[Mathf.Clamp(fpsCounter.FPS,0,99)];
  3. fpsLabel.text = stringsFrom00To99[Mathf.Clamp(fpsCounter.AverageFPS,0,99)];
  4. }

===========翻译者补充内容结束↑↑↑========

现在我们需要一个数组存储多次计算的FPS值, 并且使用一个字段标记下一次要把计算后的FPS值存在数组的第几个元素 :

  1. public class FPSCounter : MonoBehaviour
  2. {
  3. //存储多次计算出的FPS
  4. int[] fpsBuffer;
  5. //存储下一次的FPS存储的数组位置索引
  6. int fpsBufferIndex;

在FPSCounter脚本中新建InitializeBuffer方法来始化该缓存数组, 需要保障frameRange至少为1, 并且将标记索引设置为0 :

  1. void InitializeBuffer () {
  2. if (frameRange <= 0) {
  3. frameRange = 1;
  4. }
  5. fpsBuffer = new int[frameRange];
  6. fpsBufferIndex = 0;
  7. }

FPSCounter脚本的Update方法要改的复杂一点了. 如果我们的程序刚刚运行, 或是在运行过程中改变了frameRange的值, 那么就需要Update重新初始化该数组. 然后我们就要更新该缓存数组, 并在这之后计算平均FPS :

  1. void Update () {
  2. //AverageFPS = (int)(1f / Time.deltaTime);
  3. //为null表示程序刚刚运行, 缓存数组长度与frameRange不同表示运行时修改了frameRange的值
  4. if (fpsBuffer == null || fpsBuffer.Length != frameRange) {
  5. InitializeBuffer();
  6. }
  7. //更新缓存数组方法, 下文会介绍
  8. UpdateBuffer();
  9. //计算平均FPS方法, 下文会介绍
  10. CalculateFPS();
  11. }

新增FPSCounter脚本的UpdateBuffer方法, 通过在当前标记索引处存储当前的FPS值来更新缓存数组, 每次更新之后都需要增加标记索引的值, 也就是指向了下一个数组元素位置 :

  1. void UpdateBuffer () {
  2. fpsBuffer[fpsBufferIndex++] = (int)(1f / Time.unscaledDeltaTime);
  3. }

但是我们的缓存数组存满了之后怎么办? 我们在缓存数组存满后, 需要使用新计算的FPS覆盖旧的FPS值. 可以通过在缓存数组满了之后充值标记索引fpsBufferIndex为0来做到这点, 这样就会不断的循环赋值缓存数组的每一个元素 :

  1. void UpdateBuffer () {
  2. fpsBuffer[fpsBufferIndex++] = (int)(1f / Time.unscaledDeltaTime);
  3. //新增if语句, 当fpsBufferIndex大于等于frameRange也就是数组长度时, 将fpsBufferIndex重置为0
  4. if (fpsBufferIndex >= frameRange) {
  5. fpsBufferIndex = 0;
  6. }
  7. }

然后在FPSCounter脚本新增CalculateFPS方法, 计算缓存数组中存储的FPS的平均值 :

  1. void CalculateFPS () {
  2. int sum = 0;
  3. for (int i = 0; i < frameRange; i++) {
  4. sum += fpsBuffer[i];
  5. }
  6. AverageFPS = sum / frameRange;
  7. }

保存以上代码, 运行程序, 此时将会显示计算的平均帧率, 该平均帧率数字的变动比直接每帧更新FPS的做法更为缓和, 易于我们阅读其数字.

但是我们还可以进一步做一些事情, 可以把计算平均帧率的最高帧率和最低帧率显示出来, 比起只显示平均帧率, 这能多得到一些对评估帧率情况有帮助的信息 :

  1. public class FPSCounter : MonoBehaviour
  2. {
  3. //记录最高帧率
  4. public int HighestFPS { get; private set; }
  5. //记录最低帧率
  6. public int LowestFPS { get; private set; }

我们可以在计算平均帧率的过程中顺便找到这两个值 :

  1. void CalculateFPS () {
  2. int sum = 0;
  3. //新增变量获取最高值
  4. int highest = 0;
  5. //新增变量获取最低值
  6. int lowest = int.MaxValue;
  7. for (int i = 0; i < frameRange; i++) {
  8. //新增变量存储每次求和时用到的FPS值
  9. int fps = fpsBuffer[i];
  10. //sum += fpsBuffer[i];
  11. sum += fps;
  12. //判断本次使用的FPS是否比目的最大值大, 如果是, 则将最大值设置本次FPS
  13. if (fps > highest) {
  14. highest = fps;
  15. }
  16. //判断本次使用的FPS是否比目的最小值小, 如果是, 则将最小值设置本次FPS
  17. if (fps < lowest) {
  18. lowest = fps;
  19. }
  20. }
  21. AverageFPS = sum / frameRange;
  22. //将找到的最大FPS赋值给HighestFPS
  23. HighestFPS = highest;
  24. //将找到的最小FPS赋值给LowestFPS
  25. LowestFPS = lowest;
  26. }

在FPSDispaly脚本中需要将找到的最大FPS和最小FPS绑定到两个新增的文字上 :

  1. //public Text fpsLabel;
  2. //注意, 除了新增了两个Text字段, 原来的字段fpsLabel也被改名变成了averageFPSLabel
  3. public Text highestFPSLabel, averageFPSLabel, lowestFPSLabel;
  4. void Update () {
  5. //fpsLabel.text = stringsFrom00To99[Mathf.Clamp(fpsCounter.AverageFPS,0,99)];
  6. highestFPSLabel.text =
  7. stringsFrom00To99[Mathf.Clamp(fpsCounter.HighestFPS, 0, 99)];
  8. averageFPSLabel.text =
  9. stringsFrom00To99[Mathf.Clamp(fpsCounter.AverageFPS, 0, 99)];
  10. lowestFPSLabel.text =
  11. stringsFrom00To99[Mathf.Clamp(fpsCounter.LowestFPS, 0, 99)];
  12. }

在Hierarchy窗口选中之前的FPS Labe物体, 使用快捷键Ctrl+D两次, 复制两个该物体, 将全部三个物体重新改名并将其拖拽到FPS Display脚本组件的对应属性栏位置, 如下两张图所示 :
每秒帧数(FPS) - 图31每秒帧数(FPS) - 图32

按照你喜欢的方式调整三个文本物体在屏幕上的位置, 你也可以像我一样调整为垂直的三个, 上面显示最大值, 中间是平均值, 下面是最小值 , 如下图所示 :
每秒帧数(FPS) - 图33 每秒帧数(FPS) - 图34

参考源码

设置文字颜色

最后我们再根据FPS值的大小来设置显示的颜色, 这需要用到一个代表颜色与数字关联关系的自定义结构体(Struct), FPSDisplay是唯一需要用到这个结构的脚本, 所以我们直接把这个结构体放在FPSDisplay类的内部, 并使用private修饰, 所以它不能在该类以外被使用. 另外我们在其上方书写序列化语句[System.Serializable], 使得它可以被Unity显示在Inspector中 :

  1. [System.Serializable]
  2. private struct FPSColor {
  3. public Color color;
  4. public int minimumFPS;
  5. }

接着我们添加一个FPSColor类型的数组, 用来配置不同FPS对应的颜色. 我们希望可以在Inspector中编辑该数组, 那么通常做法就是使用public来修饰数组, 但是由于FPSColor结构本身是Private的, 我们只能设置它的数组为private, 那么我们可以在数组上方书写[SerializeField]特性(Attribute)语句, 从而让Unity将该数组显示在Inspector中 :

  1. public class FPSDisplay : MonoBehaviour
  2. {
  3. [SerializeField]
  4. private FPSColor[] coloring;

保存代码, 前往Inspector窗口设置该数组颜色数据. 保证至少有一条数据, 按照FPS由高到低的顺序进行设置, 最后一条数据对应0FPS时的文字颜色 :
每秒帧数(FPS) - 图35
你可以跟图中设置的一样, 也可以自己按照喜好设置

在将这些样色与文字关联之前, 我们需要修改FPSDisplay脚本的Update方法, 使用一个新的方法Display来完成对文本内容的设置 :

  1. //新增Display方法来完成对文本的设置
  2. void Display (Text label, int fps) {
  3. label.text = stringsFrom00To99[Mathf.Clamp(fps, 0, 99)];
  4. }
  5. //将Update方法中之前的代码使用Diplay方法来替代
  6. void Update () {
  7. //highestFPSLabel.text = stringsFrom00To99[Mathf.Clamp(fpsCounter.HighestFPS, 0, 99)];
  8. Display(highestFPSLabel, fpsCounter.HighestFPS);
  9. //averageFPSLabel.text = stringsFrom00To99[Mathf.Clamp(fpsCounter.AverageFPS, 0, 99)];
  10. Display(averageFPSLabel, fpsCounter.AverageFPS);
  11. //lowestFPSLabel.text = stringsFrom00To99[Mathf.Clamp(fpsCounter.LowestFPS, 0, 99)];
  12. Display(lowestFPSLabel, fpsCounter.LowestFPS);
  13. }

继续修改Display方法, 通过循环代码来找到符合对应FPS值的颜色, 并在将该颜色设置给文本后结束循环过程 :

  1. void Display (Text label, int fps) {
  2. label.text = stringsFrom00To99[Mathf.Clamp(fps, 0, 99)];
  3. for (int i = 0; i < coloring.Length; i++) {
  4. //如果当前的颜色对应的FPS值小于文本的FPS值, 则进行颜色设置
  5. if (fps >= coloring[i].minimumFPS) {
  6. label.color = coloring[i].color;
  7. //在循环语句中可以通过break;语句直接结束循环过程, 执行循环后面的程序过程
  8. break;
  9. }
  10. }
  11. }

为什么我的文字不显示了?

属性(Property)本质是一个方法(Method), 它的用法就像是一个字段(Field). 我们
Unity中默认的颜色类型数据包含四个通道(channel), 默认值都是0. 这四个通道中包括代表红绿蓝的三个通道RGB和一个代表透明度的透明通道A, 透明通道决定物体的透明度, 0 代表完全透明.
所以在Inspector中设置coloring数组元素时, 记得将每个颜色的透明通道(即第四个通道A)设置为255. 完全不透明, 这样就可以看到你的文字了

每秒帧数(FPS) - 图36
与FPS值相关的文字颜色
完成了! 快乐的观察你色色的FPS变化吧!

基础章节教程全部技术, 下一个教程章节是移动控制, 该章节第一个教程是滑动球体

参考源码
PDF