返回介绍

移动优化实用指南 — 优化脚本

发布于 2021-06-19 18:03:23 字数 27837 浏览 898 评论 0 收藏 0

本节介绍了如何优化游戏使用的实际脚本和方法,同时也详细说明了介绍了优化能够发挥作用的原因,以及为何在某些情况下应用优化可以带来诸多益处。

分析器 (Profiler) 才是王道(Unity 专业版)

事实上,并不存在这样一个供勾选的复选框清单,可以确保工程顺利运行。如需优化运行缓慢的工程,必须通过分析找到占用过多时间的特定“罪犯”。在没有进行性能分析,或没有充分理解分析器提供 的结果的前提下优化,无疑是盲目的优化。

因此,如果想要技术要求非常严苛的游戏在移动平台上运行,可能需要使用 Unity 专业版的分析器 (Profiler)

Indie 怎么样?

您可以使用内部分析器判断哪些过程导致游戏运行缓慢,是物理、脚本还是渲染,但您不能再深入探讨详细的脚本和方法,找到问题的根源所在。但是,通过在游戏中构建可以启用和禁用某些功能的交换器,可以明显缩小问题根源的范围。例如,如果删除敌人角色的 AI 脚本,且帧速率变成两倍,您将知道,是脚本或是脚本带入游戏中的其他方面必须进行优化。唯一的问题,是在找到问题之前,必须尝试不同的方法。

如需了解移动设备性能分析的更多详细信息,请参阅性能分析部分

通过设计优化

尝试开发从一开始就能快速运行的游戏具有很大的风险,因为必须权衡像在没有优化的情况下一样,游戏照样快速运行所花费的时间,和由于游戏太慢在稍后必须对其进行删除或替换所花费的时间。就此而言,开发人员对硬件必须具备敏锐的直觉和丰富的知识,特别是由于每种游戏都与众不同,一种游戏的关键优化对另一种游戏而言可能完全不起作用。

对象池 (Object Pooling)

优化脚本方法简介中,我们将对象池 (Object Pooling) 作为同时具有合理的游戏设置和良好的代码设计的示例。将对象池用作短暂的对象比创建并摧毁这些对象更加快速,因为它让内存分配更加简单,并清除了动态内存分配占用和垃圾收集 (Garbage Collection,或称 GC)。

内存分配

function showhide(link) { var theDiv = document.getElementById(link.id+"_div"); if (theDiv) { var curState = theDiv.style.display != 'none'; theDiv.style.display = (curState ? 'none' : 'block'); var str = (curState ? "+ Show [" : "- Hide [") + link.title + (curState ? "] +" : "] -"); link.innerHTML = str; } } + Show [自动内存管理的简单阐释] +

在 Unity 编写的脚本使用自动内存管理。基本上所有脚本语言均是如此。与此相反,C 和 C++ 等低级别语言则使用手动内存分配,程序设计员可以直接从内存地址读取和编写,因此,他负责清除其创建的所有对象。例如,如果在 C++ 中创建对象,那么在完成之后,必须手动解除对象占据的内存分配。在脚本语言中使用 objectReference = null; 即可。

请注意:如果拥有一个像 GameObject myGameObject; 或者 var myGameObject : GameObject; 一样的对象变量,写入 myGameObject = null; 时,为什么不会摧毁这个对象?

  • 游戏对象依然被 Unity 引用。因为 Unity 必须保持对对象的引用,才能进行绘制、更新等。调用 Destroy(myGameObject); 可清除参考并删除对象。

但是,如果创建一个 Unity 一无所知的对象,例如,没有继承任何东西的类实例(相比之下,大部分类或“脚本组件”都继承自 MonoBehaviour),然后设置其参考变量为空值,事实上,就您的脚本和 Unity 而言,对象已经丢失;它们不能访问且不会再看到对象,但它依然保存在内存中。在一段时间之后,垃圾收集器 (Garbage Collector) 将运行并清除内存中没有任何引用的所有数据。之所以能够做到这一点,是因为在场景后,每个内存块的参考数量都一致处于追踪之下。这也是脚本语言之所以比 C++ 慢的一个原因。

在 Unity 编写的脚本使用自动内存管理。基本上所有脚本语言均是如此。与此相反,C 和 C++ 等低级别语言则使用手动内存分配,程序设计员可以直接从内存地址读取和编写,因此,他负责清除其创建的所有对象。例如,如果在 C++ 中创建对象,那么在完成之后,必须手动解除对象占据的内存分配。在脚本语言中使用 objectReference = null; 即可。

请注意:如果拥有一个像 GameObject myGameObject; 或者 var myGameObject : GameObject; 一样的对象变量,写入 myGameObject = null; 时,为什么不会摧毁这个对象?

  • 游戏对象依然被 Unity 引用。因为 Unity 必须保持对对象的引用,才能进行绘制、更新等。调用 Destroy(myGameObject); 可清除参考并删除对象。

但是,如果创建一个 Unity 一无所知的对象,例如,没有继承任何东西的类实例(相比之下,大部分类或“脚本组件”都继承自 MonoBehaviour),然后设置其参考变量为空值,事实上,就您的脚本和 Unity 而言,对象已经丢失;它们不能访问且不会再看到对象,但它依然保存在内存中。在一段时间之后,垃圾收集器 (Garbage Collector) 将运行并清除内存中没有任何引用的所有数据。之所以能够做到这一点,是因为在场景后,每个内存块的参考数量都一致处于追踪之下。这也是脚本语言之所以比 C++ 慢的一个原因。

如何避免分配内存

每创建一个对象都将分配内存。您经常会在代码中创建对象,甚至自己都没有发现。

  • Debug.Log("boo" + "hoo"); 创建一个对象。
    • 在处理大量字符串时,使用 System.String.Empty 而不是 ""。
  • 快速模式 GUI (UnityGUI) 很慢,考虑到性能时,在任何时间都不应使用。
  • 类和结构的不同之处:
+ Show [类与结构] +

类是目标,起引用作用。如 Foo 是一个类,并且

 Foo foo = new Foo();
 MyFunction(foo); 
 

那么,MyFunction 将收到对分配在堆上的原始 Foo 对象的引用。在 MyFunction 内对 foo 作出的任何更改都可以在任何引用 foo 的位置看到。

结构是数据,具有相同的功能。如果 Foo 是结构并且

 Foo foo = new Foo();
 MyFunction(foo); 
 

那么,MyFunction 将收到 foo 的副本。foo 既不在堆上分配,也不会作为垃圾收集。如果 MyFunction 修改 foo 的副本,其他 foo 不会受到任何影响。

类是目标,起引用作用。如 Foo 是一个类,并且

 Foo foo = new Foo();
 MyFunction(foo); 
 

那么,MyFunction 将收到对分配在堆上的原始 Foo 对象的引用。在 MyFunction 内对 foo 作出的任何更改都可以在任何引用 foo 的位置看到。

结构是数据,具有相同的功能。如果 Foo 是结构并且

 Foo foo = new Foo();
 MyFunction(foo); 
 

那么,MyFunction 将收到 foo 的副本。foo 既不在堆上分配,也不会作为垃圾收集。如果 MyFunction 修改 foo 的副本,其他 foo 不会受到任何影响。

  • 保存时间很长的对象应为类,而短暂存在的对象应为结构。Vector3 可能是最著名的结构。如果它是一个类的话,运行的速度会明显更慢。

为什么对象池 (Object Pooling) 更快?

非常重要的一点是,经常使用实例化和摧毁 (Instantiate and Destroy) 给垃圾收集器 (Garbage Collector) 带来了大量工作,这可能在游戏设置中导致“故障“。按照自动内存管理页面的介绍,还有其他的方式可以处理实例化和摧毁 (Instantiate and Destroy) 面临的一般性能故障,如在没有任何运行时手动触发垃圾收集器;或经常触发垃圾收集器,阻止未使用内存的大量积压。

另一个原因是,在特定的预设 (prefab) 首次实例化时,某些额外的数据会加载至 RAM,或者纹理、网格需要上传至 GPU。这也可能导致故障,而有了对象池,这些过程将在关卡加载时发生,而不是游戏设置时。

想象一下,一个木偶操纵师拥有一个容量无限大的箱子,里面装了大量木偶,每次剧本需要一个角色上场时,他都会在箱子外面得到一个新木偶的复制品,而每次角色在舞台上退出时,他都将抛弃当前的复制品。对象池就相当于在表演开始之前将所有木偶从箱子里面取出来,在不需要出场的时候,将木偶放在舞台后面的桌子上。

为什么对象池 (Object Pooling) 会更慢

一个问题是,对象池的创建将减少可用作其他目的的堆内存;因此,如果将分配内存放置在刚刚创建的对象池上,可能需要更加频繁地触发垃圾收集器。不仅如此,每次收集的速度也将更慢,因为收集所花费的时间会随着活跃对象的数量而增加。了解了这些问题之后,应该可以很明显地发现如果分配过大的对象池,或在其包含的对象无需使用的一段时间内保持其有效,都将使性能受到影响。此外,很多类型的对象并不是非常适合对象池。例如,游戏可能包含持续相当长一段时间的法术效果,或出现大量敌人,但是只能随着游戏的继续而逐个杀死。在这样的情况下,对象池的性能开销大大超过了它所带来的益处,因而不应使用。

实施

下面将两种简单炮弹的脚本做了并列比较,其中一种使用实例化 (Instantiation),另一种使用对象池 (Object Pooling)。

+ Show [对象池示例] +
 // GunWithInstantiate.js
 
 #pragma strict
 
 var prefab : ProjectileWithInstantiate;
 
 var power = 10.0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 function Update () {
     if(Input.GetButtonDown("Fire1")) {
         var instance : ProjectileWithInstantiate = 
 	Instantiate(prefab, transform.position, transform.rotation);
         instance.velocity = transform.forward * power;
     }
 }
 
 
 
 
 
 
 
 
 
 // GunWithObjectPooling.js
 
 #pragma strict
 
 var prefab : ProjectileWithObjectPooling;
 var maximumInstanceCount = 10;
 var power = 10.0;
 
 private var instances : ProjectileWithObjectPooling[];
 
 static var stackPosition = Vector3(-9999, -9999, -9999);
 
 function Start () {
     instances = new ProjectileWithObjectPooling[maximumInstanceCount];
     for(var i = 0; i < maximumInstanceCount; i++) {
         // place the pile of unused objects somewhere far off the map
         instances[i] = Instantiate(prefab, stackPosition, Quaternion.identity);
         // disable by default, these objects are not active yet.
         instances[i].enabled = false;
     }
 }
 
 function Update () {
     if(Input.GetButtonDown("Fire1")) {
         var instance : ProjectileWithObjectPooling = GetNextAvailiableInstance();
         if(instance != null) {
             instance.Initialize(transform, power);
         }
     }
 }
 
 function GetNextAvailiableInstance () : ProjectileWithObjectPooling {
     for(var i = 0; i < maximumInstanceCount; i++) {
         if(!instances[i].enabled) return instances[i];
     }
     return null;
 }
 
 // ProjectileWithInstantiate.js
 
 #pragma strict
 
 var gravity = 10.0;
 var drag = 0.01;
 var lifetime = 10.0;
 
 var velocity : Vector3;
 
 private var timer = 0.0;
 
 
 
 
 
 
 
 
 
 function Update () {
     velocity -= velocity * drag * Time.deltaTime;
     velocity -= Vector3.up * gravity * Time.deltaTime;
     transform.position += velocity * Time.deltaTime;
 
     timer += Time.deltaTime;
     if(timer > lifetime) {
 
         Destroy(gameObject);
     }
 }
 
 // ProjectileWithObjectPooling.js
 
 #pragma strict
 
 var gravity = 10.0;
 var drag = 0.01;
 var lifetime = 10.0;
 
 var velocity : Vector3;
 
 private var timer = 0.0;
 
 function Initialize(parent : Transform, speed : float) {
     transform.position = parent.position;
     transform.rotation = parent.rotation;
     velocity = parent.forward * speed;
     timer = 0;
     enabled = true;
 }
 
 function Update () {
     velocity -= velocity * drag * Time.deltaTime;
     velocity -= Vector3.up * gravity * Time.deltaTime;
     transform.position += velocity * Time.deltaTime;
 
     timer += Time.deltaTime;
     if(timer > lifetime) {
         transform.position = GunWithObjectPooling.stackPosition;
         enabled = false;
     }
 }
 
 // GunWithInstantiate.js
 
 #pragma strict
 
 var prefab : ProjectileWithInstantiate;
 
 var power = 10.0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 function Update () {
     if(Input.GetButtonDown("Fire1")) {
         var instance : ProjectileWithInstantiate = 
 	Instantiate(prefab, transform.position, transform.rotation);
         instance.velocity = transform.forward * power;
     }
 }
 
 
 
 
 
 
 
 
 
 // GunWithObjectPooling.js
 
 #pragma strict
 
 var prefab : ProjectileWithObjectPooling;
 var maximumInstanceCount = 10;
 var power = 10.0;
 
 private var instances : ProjectileWithObjectPooling[];
 
 static var stackPosition = Vector3(-9999, -9999, -9999);
 
 function Start () {
     instances = new ProjectileWithObjectPooling[maximumInstanceCount];
     for(var i = 0; i < maximumInstanceCount; i++) {
         // place the pile of unused objects somewhere far off the map
         instances[i] = Instantiate(prefab, stackPosition, Quaternion.identity);
         // disable by default, these objects are not active yet.
         instances[i].enabled = false;
     }
 }
 
 function Update () {
     if(Input.GetButtonDown("Fire1")) {
         var instance : ProjectileWithObjectPooling = GetNextAvailiableInstance();
         if(instance != null) {
             instance.Initialize(transform, power);
         }
     }
 }
 
 function GetNextAvailiableInstance () : ProjectileWithObjectPooling {
     for(var i = 0; i < maximumInstanceCount; i++) {
         if(!instances[i].enabled) return instances[i];
     }
     return null;
 }
 
 // ProjectileWithInstantiate.js
 
 #pragma strict
 
 var gravity = 10.0;
 var drag = 0.01;
 var lifetime = 10.0;
 
 var velocity : Vector3;
 
 private var timer = 0.0;
 
 
 
 
 
 
 
 
 
 function Update () {
     velocity -= velocity * drag * Time.deltaTime;
     velocity -= Vector3.up * gravity * Time.deltaTime;
     transform.position += velocity * Time.deltaTime;
 
     timer += Time.deltaTime;
     if(timer > lifetime) {
 
         Destroy(gameObject);
     }
 }
 
 // ProjectileWithObjectPooling.js
 
 #pragma strict
 
 var gravity = 10.0;
 var drag = 0.01;
 var lifetime = 10.0;
 
 var velocity : Vector3;
 
 private var timer = 0.0;
 
 function Initialize(parent : Transform, speed : float) {
     transform.position = parent.position;
     transform.rotation = parent.rotation;
     velocity = parent.forward * speed;
     timer = 0;
     enabled = true;
 }
 
 function Update () {
     velocity -= velocity * drag * Time.deltaTime;
     velocity -= Vector3.up * gravity * Time.deltaTime;
     transform.position += velocity * Time.deltaTime;
 
     timer += Time.deltaTime;
     if(timer > lifetime) {
         transform.position = GunWithObjectPooling.stackPosition;
         enabled = false;
     }
 }
 

当然,对于复杂的大型游戏,可能需要制定一个适用于所有预设 (prefab) 的通用解决方案。

另一个示例:Coin Party!

脚本编写方法部分给出的“大量旋转效果、动态光照、可即刻在屏幕上收集的金币”示例可用来演示如何使用脚本代码、粒子系统 (Particle System) 等 Unity 组件以及自定义着色器创造炫目的游戏效果,而不会给稍显落后的移动硬件带来沉重负担。

想象一下,如果这种效果运用于二维侧面滚动游戏中,游戏拥有大量掉落的金币、弹跳和旋转效果。这些金币由点光照动态照亮。我们希望捕捉金币上闪烁的光线,让游戏更加夺人眼球。

如果拥有强大的硬件,可以使用标准方法解决这一问题。让每个金币成为一个对象,在前端使用顶点光照或延时光照着色,然后将光晕作为图像效果添加至顶端,获得闪闪发光的金币,并将光照效果混合至周围区域。

但是大量对象可能导致移动硬件卡机,光晕效果更将无法实现。那么,我们应该怎样做?

动画子画面粒子系统

如果想要显示大量对象,它们均以相同的方式移动且玩家不会仔细检查,那么使用粒子系统可以很快渲染大量对象。下面是这一技术的几种常规应用:

有一个叫 Sprite Packer 的简单的编辑器扩展可以促进动画子画面粒子系统的创建。它可以将对象帧渲染成纹理,然后将其作为粒子系统上的动画子画面表。而对于我们的示例,将在旋转的金币上使用。

参考实施

Sprite Packer 工程包含一个示例,演示这一确切问题的解决方案。

它使用不同种类的资源家族,可以在低计算预算的前提下获得眼花缭乱的效果。

  • 控制脚本
  • 由 SpritePacker 输出创建的专用纹理
  • 专用着色器将与控制脚本和纹理紧密连接。

示例中还包含一个自述文件,介绍系统的工作原理和方式,并概括了确定需要哪些特性以及如何实现这些特性的过程。此文件如下所示:

+ Show [Coin Party 自述] +

这个问题被定义为“大量旋转效果、动态光照、可立即在屏幕上收集的金币”。

最简单的方法是实例化一堆金币预设 (prefab) 副本,但是相反,我们将使用粒子渲染金币。这种方法将带来一系列必须克服的挑战。

  • 粒子系统没有可视角度,这将成为一个问题。
    • 假设相机位于右上方,而金币沿着 Y-轴旋转。
    • 使用通过 SpritePacker 打包的动画纹理,创建金币旋转的错觉。
      • 这将带来一个新的问题:所有硬币都以同样的速度和方向旋转,过于单调
      • 我们自己记录旋转和寿命,然后在脚本中“渲染”旋转至粒子寿命,以修复这一问题。
  • 法线也是一个问题,因为粒子没有法线,并且我们需要实时照明。
    • 在由 Sprite Packer 生成的每个动画帧内,为金币正面生成单个法向向量。
    • 根据上个步骤中获取的法向向量,在脚本中对每个粒子应用 Blinn-Phong 照明。
    • 将结果作为颜色应用至粒子。
    • 在着色器中分别处理金币的正面和边缘。
      • 带来一个新的问题:着色器如何知道边缘的位置,以及边缘可以看见的部分?
        • 不能使用 UV,它们已经在动画中使用。
        • 使用纹理贴图。
          • Y-位置需要与金币相对。
          • 需要二进制的"正面 (on face)" 和 "边缘 (on rim)"。
        • 我们不想引入其他纹理,更多的纹理读取也就意味着更多的纹理内存。
        • 将所需信息与一个通道结合,并用它替换一种纹理颜色通道。
          • 现在,金币颜色错误!我们应该怎样做?
          • 使用着色器重建缺失的通道,作为余下两个通道的组合。
  • 假设我们想要金币上闪耀的光芒更加耀眼。发布过程对于移动设备来说过于昂贵。
    • 创建另一个粒子系统,并设计更加柔软光亮的硬币动画。
    • 只有在对应的金币颜色超级明亮时为光晕着色。
    • 不能在每一帧对所有金币进行光晕渲染,这会扼杀填充率。
      • 每帧重新设定光晕,仅放置亮度 > 0 的光晕。
  • 物理是一个问题,收集金币也是一个问题 — 粒子不能进行良好的碰撞。
    • 是否可以使用内置的粒子碰撞?
    • 相反,将碰撞写入脚本即可。
  • 最后,我们碰到了另一个问题 — 脚本发挥了很大的作用,但运行缓慢!
    • 性能与有效金币的数量呈线性比例!
      • 限制最大金币。此项有助于实现我们的目标:100 个金币,2 种光照,可在移动设备上快速运行。
  • 进一步优化还可尝试其他方法:
    • 将游戏世界分割成组块,为每个组块中的每个旋转帧计算光照条件,而不是分别为每个金币计算光照。
      • 用作查找表,以金币位置和金币旋转作为指数。
      • 位置使用双线性插值,增加保真度。
      • 对查找表进行稀疏更新,或者彻底使用静态查找表。
      • 为此使用 Light Probe?
    • 使用法线贴图粒子,而不是在脚本中计算光照?
      • 使用"显示法线 (Display Normals)" 着色器烘焙法线帧动画。
      • 限制光照数量。
      • 修复运行缓慢的脚本问题。

这个问题被定义为“大量旋转效果、动态光照、可立即在屏幕上收集的金币”。

最简单的方法是实例化一堆金币预设 (prefab) 副本,但是相反,我们将使用粒子渲染金币。这种方法将带来一系列必须克服的挑战。

  • 粒子系统没有可视角度,这将成为一个问题。
    • 假设相机位于右上方,而金币沿着 Y-轴旋转。
    • 使用通过 SpritePacker 打包的动画纹理,创建金币旋转的错觉。
      • 这将带来一个新的问题:所有硬币都以同样的速度和方向旋转,过于单调
      • 我们自己记录旋转和寿命,然后在脚本中“渲染”旋转至粒子寿命,以修复这一问题。
  • 法线也是一个问题,因为粒子没有法线,并且我们需要实时照明。
    • 在由 Sprite Packer 生成的每个动画帧内,为金币正面生成单个法向向量。
    • 根据上个步骤中获取的法向向量,在脚本中对每个粒子应用 Blinn-Phong 照明。
    • 将结果作为颜色应用至粒子。
    • 在着色器中分别处理金币的正面和边缘。
      • 带来一个新的问题:着色器如何知道边缘的位置,以及边缘可以看见的部分?
        • 不能使用 UV,它们已经在动画中使用。
        • 使用纹理贴图。
          • Y-位置需要与金币相对。
          • 需要二进制的"正面 (on face)" 和 "边缘 (on rim)"。
        • 我们不想引入其他纹理,更多的纹理读取也就意味着更多的纹理内存。
        • 将所需信息与一个通道结合,并用它替换一种纹理颜色通道。
          • 现在,金币颜色错误!我们应该怎样做?
          • 使用着色器重建缺失的通道,作为余下两个通道的组合。
  • 假设我们想要金币上闪耀的光芒更加耀眼。发布过程对于移动设备来说过于昂贵。
    • 创建另一个粒子系统,并设计更加柔软光亮的硬币动画。
    • 只有在对应的金币颜色超级明亮时为光晕着色。
    • 不能在每一帧对所有金币进行光晕渲染,这会扼杀填充率。
      • 每帧重新设定光晕,仅放置亮度 > 0 的光晕。
  • 物理是一个问题,收集金币也是一个问题 — 粒子不能进行良好的碰撞。
    • 是否可以使用内置的粒子碰撞?
    • 相反,将碰撞写入脚本即可。
  • 最后,我们碰到了另一个问题 — 脚本发挥了很大的作用,但运行缓慢!
    • 性能与有效金币的数量呈线性比例!
      • 限制最大金币。此项有助于实现我们的目标:100 个金币,2 种光照,可在移动设备上快速运行。
  • 进一步优化还可尝试其他方法:
    • 将游戏世界分割成组块,为每个组块中的每个旋转帧计算光照条件,而不是分别为每个金币计算光照。
      • 用作查找表,以金币位置和金币旋转作为指数。
      • 位置使用双线性插值,增加保真度。
      • 对查找表进行稀疏更新,或者彻底使用静态查找表。
      • 为此使用 Light Probe?
    • 使用法线贴图粒子,而不是在脚本中计算光照?
      • 使用"显示法线 (Display Normals)" 着色器烘焙法线帧动画。
      • 限制光照数量。
      • 修复运行缓慢的脚本问题。

这个示例的最终目标或者“故事寓意”是,如果存在游戏真正需要的东西,并且它在通过常规方法实现时引起延迟,这并不意味着它无法实施,而是意味着您必须在自己运行更快的系统中投入一些工作。

同时管理数以千计目标的技术

这些特定的脚本优化可应用于包含成千上万个动态目标的环境中。将这些技术应用到游戏的所有脚本是一个可怕的想法;对于要在运行时处理大量对象或数据的大型脚本,它们应作为工具和设计指南使用。

  • 避免或尽量少在大型数据集上进行 O(n2) 运算
+ Show [命令与平方] +

在计算机科学中,Order 是一种运算,表示为 O(n),它代表一种方式,也就是随着其应用至 (n) 的对象数量增加,必须评估的运算次数也随之增加。

例如,考虑基本排序算法。我有 n 个数字,希望从小到大排序。

 void sort(int[] arr) {
 	int i, j, newValue;
 	for (i = 1; i < arr.Length; i++) {
 		// record
 		newValue = arr[i];
 		//shift everything that is larger to the right
 		j = i;
 		while (j > 0 && arr[j - 1] > newValue) {
 			arr[j] = arr[j - 1];
 			j--;
 		}
 		// place recorded value to the left of large values
 		arr[j] = newValue;
 	}
 }

非常重要的部分是这里有两处循环,一个套着另一个。

 for (i = 1; i < arr.Length; i++) {
 	...
 	j = i;
 	while (j > 0 && arr[j - 1] > newValue) {
 		...
 		j--;
 	}
 }
 

我们假设使用这个算法最困难的情况:输入的数字已排序,但是顺序相反。在这种情况下,最里面的循环将运行 j 次。通常,i 将从 1 开始到 arr.Length-1,j 将是 arr.Length/2。按照 O(n),arr.Length 就是我们的 n,因此,总的来说,最里面的循环需运行 n*n/2 次或 n2/2 次。但是就 O(n) 而言,我们抛弃了所有常量,如 1/2,因为我们希望探讨运算次数增加的方式,而不是运算的实际次数。因此,算法为 O(n2)。如果数据集为大型数据集,那么运算顺序具有非常重要的作用,因为运算次数将以指数方式剧增。

O(n2) 运算在游戏内的一个示例是 100 个敌人,每个敌人的 AI 都要考虑到其他每一个敌人的移动。将贴图分割成单元,并将每个敌人的运动记录在最近的单元中,然后每个敌人都从距离最近的几个单元中采样。这样可能更快。这就是一种 O(n) 运算。

在计算机科学中,Order 是一种运算,表示为 O(n),它代表一种方式,也就是随着其应用至 (n) 的对象数量增加,必须评估的运算次数也随之增加。

例如,考虑基本排序算法。我有 n 个数字,希望从小到大排序。

 void sort(int[] arr) {
 	int i, j, newValue;
 	for (i = 1; i < arr.Length; i++) {
 		// record
 		newValue = arr[i];
 		//shift everything that is larger to the right
 		j = i;
 		while (j > 0 && arr[j - 1] > newValue) {
 			arr[j] = arr[j - 1];
 			j--;
 		}
 		// place recorded value to the left of large values
 		arr[j] = newValue;
 	}
 }

非常重要的部分是这里有两处循环,一个套着另一个。

 for (i = 1; i < arr.Length; i++) {
 	...
 	j = i;
 	while (j > 0 && arr[j - 1] > newValue) {
 		...
 		j--;
 	}
 }
 

我们假设使用这个算法最困难的情况:输入的数字已排序,但是顺序相反。在这种情况下,最里面的循环将运行 j 次。通常,i 将从 1 开始到 arr.Length-1,j 将是 arr.Length/2。按照 O(n),arr.Length 就是我们的 n,因此,总的来说,最里面的循环需运行 n*n/2 次或 n2/2 次。但是就 O(n) 而言,我们抛弃了所有常量,如 1/2,因为我们希望探讨运算次数增加的方式,而不是运算的实际次数。因此,算法为 O(n2)。如果数据集为大型数据集,那么运算顺序具有非常重要的作用,因为运算次数将以指数方式剧增。

O(n2) 运算在游戏内的一个示例是 100 个敌人,每个敌人的 AI 都要考虑到其他每一个敌人的移动。将贴图分割成单元,并将每个敌人的运动记录在最近的单元中,然后每个敌人都从距离最近的几个单元中采样。这样可能更快。这就是一种 O(n) 运算。

  • 缓存引用,而不是执行不必要的搜索。
+ Show [引用缓存] +

如果游戏中有 100 个敌人,他们都向玩家移动。

 // EnemyAI.js
 var speed = 5.0;
 
 function Update () {
 	transform.LookAt(GameObject.FindWithTag("Player").transform);
 	// this would be even worse:
 	//transform.LookAt(FindObjectOfType(Player).transform);
 
 	transform.position += transform.forward * speed * Time.deltaTime;
 }

如果有大量敌人同时移动的话,游戏可能非常缓慢。鲜为人知的事实:MonoBehaviour 中的所有组件访问器 (accessors),如 变换 (transform)、渲染器 (renderer) 和 音频 (audio) 之类的数据,都相当于其 GetComponent(Transform) 对应物,并且事实上它们的运行稍显缓慢。游戏对象 (GameObject).FindWithTag 已经过优化,但是某些情况下,这个脚本可能运行缓慢,如在内部循环中,或在大量实例上运行的脚本中。

该脚本还有一个更好的版本。

 // EnemyAI.js
 var speed = 5.0;
 
 private var myTransform : Transform;
 private var playerTransform : Transform;
 
 function Start () {
 	myTransform = transform;
 	playerTransform = GameObject.FindWithTag("Player").transform;
 }
 
 function Update () {
 	myTransform.LookAt(playerTransform);
 
 	myTransform.position += myTransform.forward * speed * Time.deltaTime;
 }
 

如果游戏中有 100 个敌人,他们都向玩家移动。

 // EnemyAI.js
 var speed = 5.0;
 
 function Update () {
 	transform.LookAt(GameObject.FindWithTag("Player").transform);
 	// this would be even worse:
 	//transform.LookAt(FindObjectOfType(Player).transform);
 
 	transform.position += transform.forward * speed * Time.deltaTime;
 }

如果有大量敌人同时移动的话,游戏可能非常缓慢。鲜为人知的事实:MonoBehaviour 中的所有组件访问器 (accessors),如 变换 (transform)、渲染器 (renderer) 和 音频 (audio) 之类的数据,都相当于其 GetComponent(Transform) 对应物,并且事实上它们的运行稍显缓慢。游戏对象 (GameObject).FindWithTag 已经过优化,但是某些情况下,这个脚本可能运行缓慢,如在内部循环中,或在大量实例上运行的脚本中。

该脚本还有一个更好的版本。

 // EnemyAI.js
 var speed = 5.0;
 
 private var myTransform : Transform;
 private var playerTransform : Transform;
 
 function Start () {
 	myTransform = transform;
 	playerTransform = GameObject.FindWithTag("Player").transform;
 }
 
 function Update () {
 	myTransform.LookAt(playerTransform);
 
 	myTransform.position += myTransform.forward * speed * Time.deltaTime;
 }
 

  • 最小化代价不菲的数学函数
+ Show [代价不菲的数学函数] +

超越函数 (Transcendental functions)(Mathf.Sin、Mathf.Pow 等)、除法、平方根所花费的时间约为乘法的 100 倍。(从大局来说,它们几乎不花费时间,但是,如果您每帧调用数千次,时间将可以累积。)

这方面最常见的示例是向量规格化。如果您正在反复规格化同一个向量,那么请考虑规格化一次,然后将结果缓存,以便日后使用。

如果您使用向量长度并同时规划化此向量,用向量乘以长度的倒数,而不要使用 .normalized 属性,这样可以更快取得规格化的向量。

如果您正在对比距离,您无需对比实际距离。相反,可以通过使用 .sqrMagnitude 属性并保存一至两个平方根,以此对比距离的平方。

另外,如果您正在反复除以常量 c,您可以乘以它的倒数。首先用 1.0/c 计算这个常量的倒数。

超越函数 (Transcendental functions)(Mathf.Sin、Mathf.Pow 等)、除法、平方根所花费的时间约为乘法的 100 倍。(从大局来说,它们几乎不花费时间,但是,如果您每帧调用数千次,时间将可以累积。)

这方面最常见的示例是向量规格化。如果您正在反复规格化同一个向量,那么请考虑规格化一次,然后将结果缓存,以便日后使用。

如果您使用向量长度并同时规划化此向量,用向量乘以长度的倒数,而不要使用 .normalized 属性,这样可以更快取得规格化的向量。

如果您正在对比距离,您无需对比实际距离。相反,可以通过使用 .sqrMagnitude 属性并保存一至两个平方根,以此对比距离的平方。

另外,如果您正在反复除以常量 c,您可以乘以它的倒数。首先用 1.0/c 计算这个常量的倒数。

  • 仅在少数情况下执行费时的运算,如 Physics.Raycast()
+ Show [稀有调用] +

如果必须执行费时的运算,可以通过减少使用频率并缓存结果,优化这一运算。例如,使用 Raycast 的爆弹脚本:

 // Bullet.js
 var speed = 5.0;
 
 function FixedUpdate () {
 	var distanceThisFrame = speed * Time.fixedDeltaTime;
 	var hit : RaycastHit;
 
 	// every frame, we cast a ray forward from where we are to where we will be next frame
 	if(Physics.Raycast(transform.position, transform.forward, hit, distanceThisFrame)) {
 		// Do hit
 	} else {
 		transform.position += transform.forward * distanceThisFrame;
 	}
 }
 

可以使用 Update 替换 FixedUpdate,并使用 deltaTime 替换 fixedDeltaTime,即刻改进脚本。FixedUpdate 是指物理更新,它比帧更新更为频繁。但是,只需每 n 秒进行一次光线投射便可更进一步。n 值更小,瞬时分辨率更大,而 n 值更大,性能将更佳。如果目标更大更慢,在时间映频混扰 (temporal aliasing) 发生之前可使用更大的 n 值。(出现延迟,玩家击中目标,但在目标 n 秒前出现的位置发生爆炸,或者玩家击中目标,但是爆弹顺利通过)。

 // BulletOptimized.js
 var speed = 5.0;
 var interval = 0.4; // this is 'n', in seconds.
 
 private var begin : Vector3;
 private var timer = 0.0;
 private var hasHit = false;
 private var timeTillImpact = 0.0;
 private var hit : RaycastHit;
 
 // set up initial interval
 function Start () {
 	begin = transform.position;
 	timer = interval+1;
 }
 
 function Update () {
 	// don't allow an interval smaller than the frame.
 	var usedInterval = interval;
 	if(Time.deltaTime > usedInterval) usedInterval = Time.deltaTime;
 
 	// every interval, we cast a ray forward from where we were at the start of this interval
 	// to where we will be at the start of the next interval
 	if(!hasHit && timer >= usedInterval) {
 		timer = 0;
 		var distanceThisInterval = speed * usedInterval;
 
 		if(Physics.Raycast(begin, transform.forward, hit, distanceThisInterval)) {
 			hasHit = true;
 			if(speed != 0) timeTillImpact = hit.distance / speed;
 		}
 
 		begin += transform.forward * distanceThisInterval;
 	}
 
 	timer += Time.deltaTime;
 
 	// after the Raycast hit something, wait until the bullet has traveled
 	// about as far as the ray traveled to do the actual hit
 	if(hasHit && timer > timeTillImpact) {
 		// Do hit
 	} else {
 		transform.position += transform.forward * speed * Time.deltaTime;
 	}
 }
 

如果必须执行费时的运算,可以通过减少使用频率并缓存结果,优化这一运算。例如,使用 Raycast 的爆弹脚本:

 // Bullet.js
 var speed = 5.0;
 
 function FixedUpdate () {
 	var distanceThisFrame = speed * Time.fixedDeltaTime;
 	var hit : RaycastHit;
 
 	// every frame, we cast a ray forward from where we are to where we will be next frame
 	if(Physics.Raycast(transform.position, transform.forward, hit, distanceThisFrame)) {
 		// Do hit
 	} else {
 		transform.position += transform.forward * distanceThisFrame;
 	}
 }
 

可以使用 Update 替换 FixedUpdate,并使用 deltaTime 替换 fixedDeltaTime,即刻改进脚本。FixedUpdate 是指物理更新,它比帧更新更为频繁。但是,只需每 n 秒进行一次光线投射便可更进一步。n 值更小,瞬时分辨率更大,而 n 值更大,性能将更佳。如果目标更大更慢,在时间映频混扰 (temporal aliasing) 发生之前可使用更大的 n 值。(出现延迟,玩家击中目标,但在目标 n 秒前出现的位置发生爆炸,或者玩家击中目标,但是爆弹顺利通过)。

 // BulletOptimized.js
 var speed = 5.0;
 var interval = 0.4; // this is 'n', in seconds.
 
 private var begin : Vector3;
 private var timer = 0.0;
 private var hasHit = false;
 private var timeTillImpact = 0.0;
 private var hit : RaycastHit;
 
 // set up initial interval
 function Start () {
 	begin = transform.position;
 	timer = interval+1;
 }
 
 function Update () {
 	// don't allow an interval smaller than the frame.
 	var usedInterval = interval;
 	if(Time.deltaTime > usedInterval) usedInterval = Time.deltaTime;
 
 	// every interval, we cast a ray forward from where we were at the start of this interval
 	// to where we will be at the start of the next interval
 	if(!hasHit && timer >= usedInterval) {
 		timer = 0;
 		var distanceThisInterval = speed * usedInterval;
 
 		if(Physics.Raycast(begin, transform.forward, hit, distanceThisInterval)) {
 			hasHit = true;
 			if(speed != 0) timeTillImpact = hit.distance / speed;
 		}
 
 		begin += transform.forward * distanceThisInterval;
 	}
 
 	timer += Time.deltaTime;
 
 	// after the Raycast hit something, wait until the bullet has traveled
 	// about as far as the ray traveled to do the actual hit
 	if(hasHit && timer > timeTillImpact) {
 		// Do hit
 	} else {
 		transform.position += transform.forward * speed * Time.deltaTime;
 	}
 }
 

  • 内部循环尽量避免调用堆栈开销
+ Show [调用堆栈 (Callstack) 开销] +

仅调用本身开销非常少的函数。如果每帧调用 x = Mathf.Abs(x) 数千次,最好使用 x = (x > 0 ? x : -x); 取而代之。

仅调用本身开销非常少的函数。如果每帧调用 x = Mathf.Abs(x) 数千次,最好使用 x = (x > 0 ? x : -x); 取而代之。

优化物理性能

Unity 使用的 NVIDIA PhysX 物理引擎可在移动设备上使用,但是,移动平台比桌面平台更容易达到硬件的性能限制。

下面是优化物理提示,以便在移动设备上取得更佳性能:-

  • 可以调整固定时间步 (Fixed Timestep) 设置(位于时间管理器 (Time manager) 中),减少物理更新占用的时间。增加时间步将以物理精确度为代价,减少 CPU 开销。通常,更低的精确度是提高速度可接受的折中方案。
  • 在时间管理器 (Time manager) 中设置最大允许时间步 (Maximum Allowed Timestep) 为 8-10fps 之间,以便在最坏的情况下限制物理所花费的时间。
  • 网格碰撞体比原始碰撞体拥有更高的性能开销,因此,请谨慎使用。通常可使用支持原始碰撞体的子对象来接近网格形状。子碰撞体作为单一复合碰撞体,由父碰撞体的刚体统一控制。
  • 对立体对象而言,车轮碰撞体并非严格意义上的碰撞体,但是它们依然拥有较高的 CPU 开销。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文