找回密码
 立即注册
查看: 614|回复: 0

[笔记] Unity 中的 10个代码优化小技巧

[复制链接]
发表于 2020-11-25 14:01 | 显示全部楼层 |阅读模式
Unity 中的 10个代码优化小技巧

又到了开学导员开会的时候。满屋子的学生听着比讲课还无聊空洞的东西,于是整齐地打开了手机。于是就在知乎看到了 默然的这篇文章:
Unite Talk 让INSIDE实现稳定60帧的工具,技巧和技术
该文翻译了 Unite 2016 《INSIDE》开发人员的演讲。其中介绍了 10 个 Unity 代码优化技巧。我觉得非常有意思和有启发性,但里面有些地方我持存疑态度,所以写篇文章进行记录,和验证。
1、通过合理的计算顺序来减少矢量操作次数

来看下面这几行代码:
Vector3 vec = new Vector3(1, 2, 3);
Vector3 nvec = vec * 3f * 2f;
Vector3 与 float 相乘,实际上是 Vector3 中的三个分量分别与 float 相乘,也就是进行了 三次乘法运算,然后返回乘后的 Vector3
那么对于上面的代码,vec 与 3f 相乘,进行了 3次 乘法运算,然后得到一个临时的 Vector3 我们称其为 temp。 然后 temp 与 2f 相乘,又进行了 3次 乘法运算。那么总共就进行了 6次 乘法运算。 但我们可以通过调整运算顺序来减少乘法次数:
Vector3 vec = new Vector3(1, 2, 3);
Vector3 nvec = vec * (3f * 2f);
这里我们只是加了一个括号,让 3f * 2f 先进行运算。那么这行代码的运算过程就是这样的:首先计算 3f * 2f 只需要一次乘法运算 得出 6f,然后计算 vec * 6f 需要三次乘法运算,得出最终结果。那么总共进行了 4次 乘法运算,与之前的 6次 相比减少了 2次 乘法运算。
这里我做了简单的测试:






结果显示为,在 100000 次运算下,两者运算时间差了 6ms
2、缓存 transform

故名思意,就是我们在 MonoBehavior 中可以先把物体的 transform 缓存一下,之后使用这个缓存的 transform 指针。这里直接给出测试代码和结果:
不缓存时:






缓存后:






可以看出,在每帧 100000 次计算下,缓存后每帧运算时间优化了近 20ms。
原文并没有解释缓存后性能得到优化的原理。个人猜测是因为过多的继承关系,导致直接获取 transform 指针较慢。
3、尽可能使用localPosition 而不是 position

原因是因为在 transform 中只存储和计算了 localPosition,而在获取 position 时,会根据当前物体的层级结构来一层一层地向上进行坐标转换计算,直到最终转换为世界坐标。
那么,如果该物体所在层级越深,获取 position 时 计算量就越大。下面给出测试代码和结果:
层级:


直接使用 position:






使用 localPosition:



可以看到,在拥有 11 个父物体的层级下,每帧计算 100000 次,使用 localPosition 比 使用 position 优化了 70ms 左右。
同样的道理,使用 localRotation 也能起到优化效果。
4、减少引擎调用

这里给出的例子是,我们对 transform.localPosition 进行修改时,不直接修改,而是缓存一份 transform.localPosition,然后对缓存进行修改,再把缓存值给 transform.localPosition
下面给出测试代码 和 结果:
缓存前:



缓存后:






在每帧 100000 次计算下, 缓存后 比 缓存前 优化了 10ms 左右
同样,缓存 localRotation 也能起到优化效果
原文并没有给出这一优化的原理,本人水平有限,也没有想出合适的解释。
5、不使用 getter 和 setter

getter 和 setter 也就属性。下面给出测试代码 和 结果:
使用属性:






不使用属性:






在 每帧计算 100000 次的情况下,不使用属性 比 使用属性 优化了 4ms 左右。
原文这里仍没有给出解释。这里本人猜测为 属性对于字段又封装了一层,导致获取字段值是会慢一点。
即使用属性,会隐式调用方法,进行栈操作。
同时还要注意,原文指出,在 mono 下 这样做是有优化效果的,但是在 il2cpp 下反而运算更慢了。
6、不要使用矢量运算符

这里说的是 vec1 += vec2 要比 vec1.x += vec2.x; vec1.y += vec2.y; vec1.z += vec2.z; 的效率要慢。
即 两个矢量直接计算,比我们把矢量拆开分别对分量进行计算要慢。下面给出测试代码 和 结果:
矢量直接相加:






矢量的分量对应相加:






可以看出,在每帧 100000 次运算下,把矢量拆分对分量分别进行计算会优化近 5ms。
在原文中,对于这一原理的解释为 Vector3 之间直接进行运算时,会在堆上面分配出一个临时的Vector3 变量。 而对分量直接进行计算,就是直接在分量上进行修改,不会产生临时变量。
个人觉得这一说法不太准确,矢量直接运算确实会封装出一个临时变量,但是矢量作为一个结构(struct) 这个临时变量应该是在栈上分配的。
如果觉得每次向量间进行计算,都要分成三行来写非常麻烦,可以封装出一个方法:


这是本人写的一个扩展方法,因为用了 ref 关键字进行修饰,所以不需要额外封装出一个矢量进行返回。
但因为使用时调用了拓展方法,仍有一定性能损失。每帧进行 100000次 运算情况下,其会比直接写三行代码要慢 2~3 ms
至于为什么封装之后仍慢 2~3ms,因为向量间直接运算会结果封装出一个新的向量,这是在栈上生成的。 而把向量分量进行计算避免了栈操作。
而封装出方法在调用时要进行出栈操作,所以相比下仍要慢一点点。
7、缓存 Time.deltaTime

这里先直接给出测试代码 和 结果:
缓存 Time.deltaTime 之前:






缓存 Time.deltaTime 之后:






可以看出,在每帧 100000 次运算下,缓存 Time.deltaTime 后,优化了 20ms 左右
原文没有给出该优化原理的解释。个人猜测为,每次获取 Time.deltaTime 时 Unity 都会重新计算一次 deltaTime,但这是没有必要的,因为我们只要确保 deltaTime 一帧能更新一次即可。
8、不要使用 foreach 循环

因为 foreach 会产生垃圾回收(GC)。
然而在本人实际测试中,foreach 并没有产生 GC…
我们创建 10 个物体并添加一个脚本,然后在另一个脚本中,每帧遍历 10000 次这 10个物体,并调用它们身上脚本的方法。
下面给出测试代码 和 结果:
使用 foreach 循环:










使用 for 循环:






结果发现并没有产生明显优化。 后来本人又实验了几次,发现 for 循环还是稳定地要比 foreach 快的。
并且发现 foreach 并没有产生 GC。之前我一直觉得 foreach 会产生 GC 是基础常识,结果发现竟没有 GC 也是吃了一惊。于是又做了一些测试,下面是结果:
遍历 数组、列表、字典、哈希表,都没有 GC
不知道是我地测试代码有问题,还是时代变了…
补充: foreach 产生 gc 的问题,在之前版本已经解决。
9、使用 Array(数组),而不是 List

原文说 List 和 数组 相比,使用下标访问时,List 是从头遍历到 对应下标位置,时间复杂度为 O(n),而数组为 O(1)
这个说法是有误的。但是从下标访问的速度而言,数组 确实比 List 要快。
如果 List 的下标访问,其 时间复杂度为 O(n),那么 访问 头元素,和 访问 尾元素的时间应该是不一样的。但经过测试,访问 头、尾 和 中间的元素,所用时间都是一样的。但是也都比 数组访问要慢。
下面给出 测试代码 和 结果:






在计算 100000次 的情况下,List 的下标访问会比 数组慢 2~3 ms
这给了我启发:
在一些集合初始化时,我们无法确定集合的大小,因此会选用 List,但是在初始化完毕之后,我们一般不会再向里面添加新的元素。
例如一些配置文件的初始化。那么我们就可以把这个 List 再转换为数组,这样在其它地方进行下标查找时会更快。
10、尽量避免使用 Update 和 FixedUpdate

原文提到 Unity 的 Update 和 FixedUpdate 存在性能问题,应当避免使用。
那避免之后,一些需要每帧调用,或者一些计时任务该怎么办呢?
那就只能自己实现一个计时回调器了。关于计时回调器的实现,足以另起几篇文章来讲,因此这里不作赘述。
最后总结:

    通过合理的计算顺序来减少矢量操作次数,一维向量先运算完毕之后再与 多维向量进行运算。缓存 transform尽可能使用本地坐标下的变量,例如 localPosition 和 loaclRotation,而不是直接使用 position 和 rotation减少引擎API的调用,例如 缓存 transform.localPosition 和 transform.loaclRotation减少使用 getter 和 setter 即属性不使用矢量运算符,而是对矢量的分量进行一一运算。缓存一个全局的 deltaTime,保障其每帧会更新一次即可,然后其它脚本共用这一个 deltaTime。不要使用 foreach,而是使用 for如果 List 初始化完毕之后,以后不会再向里面添加元素,那么最后把它转换为 数组。尽量避免过多的 Update 和 FixedUpdate 存在。可以自行实现计时回调器。
最后的最后

Unity 最近的几个实验功能,其里面的代码都使用到了 Job System 和 Brust编译器。
所以 Unity 之后的内部优化路线应该就是通过底层多线程调度来提高运行效率。
在最后,原文也指出了,在面向对象这一概念产生之后,所形成的代码规范和习惯,对于游戏开发来说,在性能上都是比较低效的。但是面向对象对于开发的效率而言是无需言喻的。
所以,要做出性能最佳的游戏程序,就要跳回到面向对象产生之前的编码方式。
这一思路便诞生了 ECS 框架,即面向数据而非面向对象。
现在 Unity 也提高了现成的 ECS 框架。 但我觉得使用 ECS,就意味着代码更加 难以书写、臃肿 和 难以阅读。且需要做大量的底层重构。
因此在开发前要明确好具体的框架使用。如果使用面向对象的框架来开发,那么为了开发效率,我觉得可以按照面向对象的传统习惯来编码,功能实现完毕之后再对照优化技巧来修改代码。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-22 23:37 , Processed in 0.136442 second(s), 29 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表