Unity 项目实践点滴
发表于2015-07-15
"Unity can only be manifested by the Binary. Unity itself and the idea of Unity are already two."
- Buddha
高精度计时器
The Stopwatch class assists the manipulation of timing-related performance counters within managed code. Specifically, the Frequency field and GetTimestamp method can be used in place of the unmanaged Win32 APIs QueryPerformanceFrequency and QueryPerformanceCounter.
- 可以通过
Stopwatch.IsHighResolution
来判断当前是否为高精度 - 可以通过
StopWatch.Frequency
来获取当前的时钟频率 - 可以通过
StopWatch.GetTimestamp()
来获取当前的时间戳
UnityEngine.Debug.LogFormat("high-res: {0}, freq: {1}, timestamp: {2}", Stopwatch.IsHighResolution, Stopwatch.Frequency, Stopwatch.GetTimestamp());
high-res: True, freq: 10000000, timestamp: 63121800066
- 高精度计时是开启的,在 Mono 2.x 上是可用的
- 频率被归一化为 1e7 了,而非返回实际的 CPU 频率,这个的好处是 ElapsedTicks 从周期数变为了一个有逻辑意义的时间计量,这也就是在说,StopWatch 提供的计时服务最高精度为 0.1 微秒(也即 100 纳秒)
- 时间戳
GetTimestamp()
可用,而且返回的值是一个有效的 64 bits long (大于 2^32),也就是基本不用担心溢出回绕的问题
using (SSTimer t = new SSTimer("_name_tag_")) { // test code foo(); }
'_name_tag_' exec time: 33.332 (ms)
Mono/C# 代码实践
for / foreach 问题
- 直到 Unity 5.0.1 (说好的 5.x 修复呢?) 为止,如果你的代码用 Unity 自带的 Mono 编译器,无论使用的是标准容器 (自带 struct-enumerator 优化) 还是自定义容器 (手动 struct-enumerator 优化),都无法避免 foreach 展开后经由 GetEnumerator() 所获取出的 struct-enumerator 产生的一个额外的 boxing 动作 (及对应的内存分配)。简单地说,由 Unity 自带编译器编译的代码,建议不要使用 foreach。
- 然而,如果你的代码使用 VS 的 C# 编译器以目标为 "Unity 3.5 .net Subset Base Class Libraries" 编译出 dll,并把此 dll 放在项目的 Assets 目录下供 Unity 直接使用的话,就可以得到 struct-enumerator 优化所带来的好处,无需担心额外开销。也就是说,以 dll (由 VS 编译) 方式使用的代码,可以放心用 foreach。
- 再补充一点我实测的,如果是数组的遍历,在这两种方式下都不会产生额外的开销,而且在 il 中不创建 enumerator,也就没有对应的 MoveNext() / Dispose() 调用,这样连 try / finally block 也不再生成,生成的代码短了不少。也就是说,数组使用 foreach 没有任何限制,而且遍历效率较容器要高。(使用上面的高精度计时器测得:遍历百万元素的数组 (int[]),列表 (List
) 和字典 (Dictionary ) 分别耗时 6.263ms / 32.65ms / 32.385ms,后两者耗时是数组的 5 倍多,所以能用数组就尽量用数组吧)
lambda 表达式 vs. 闭包 (closure)
static void Test_lambda_01() { System.Func<int, int> func = (e) => e * e; int result = func(6); ++result; } static int _foo2 = 1; static void Test_lambda_02() { System.Func<int, int> func = (e) => e * e + _foo2; int result = func(6); ++result; } static int _foo3 = 1; static void Test_lambda_03() { System.Func<int, int> func = (e) => e * e + (++_foo3); int result = func(6); ++result; } static void Test_lambda_04() { int _foo4 = 1; System.Func<int, int> func = (e) => e * e + _foo4; int result = func(6); ++result; } static void Test_lambda_05() { int _foo5 = 1; System.Func<int, int> func = (e) => e * e + (++_foo5); int result = func(6); ++result; }
std::vector<int> some_list{ 1, 2, 3, 4, 5 }; int total = 0; auto func = [&total](int x) { total += x; }; std::for_each(begin(some_list), end(some_list), func);
枚举项的 ToString() vs. Enum.GetName()
- ToString() 耗时 553.817ms
- Enum.GetName() 耗时 437.2ms
使用 "as" 转型 vs. 使用 C-Style Cast
“When casting a variable use the post fix “as type” instead of pre fixing with (type) as this is faster.”
- as 方式耗时 5.287ms
- C-Style 方式耗时 4.640ms
expression as type
expression is type ? (type)expression : (type)null
矩阵优化 (I) - 矩阵乘法
// Summary: // A standard 4x4 transformation matrix. public struct Matrix4x4 { // (前略) ... public static Matrix4x4 operator *(Matrix4x4 lhs, Matrix4x4 rhs); // (后略) ... }
优化版本 1 - brute-force (耗时 1596.76%)
public static Matrix4x4 Mul_v1_naive(Matrix4x4 m1, Matrix4x4 m2) { Matrix4x4 result = Matrix4x4.zero; for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) for (int k = 0; k < 4; k++) result[i, j] += m1[i, k] * m2[k, j]; return result; }
- 把拿衣服版本的运算结果与官方版做值比较,得到了一致的结果——这说明了官方版的行为与我们的期望完全一致。
- 把运算的开销与官方版比较,不出所料,拿衣服版本够慢的,百万次耗时为 5413.076 (ms) 左右,是官方版的 15 倍。
优化版本 2 - 循环展开 (耗时 100.54%)
public static Matrix4x4 Mul_v2_naive_expanded(Matrix4x4 m1, Matrix4x4 m2) { // Matrix4x4 is a struct so 'new' would still leave it on stack return new Matrix4x4 { m00 = m1.m00 * m2.m00 + m1.m01 * m2.m10 + m1.m02 * m2.m20 + m1.m03 * m2.m30, m01 = m1.m00 * m2.m01 + m1.m01 * m2.m11 + m1.m02 * m2.m21 + m1.m03 * m2.m31, m02 = m1.m00 * m2.m02 + m1.m01 * m2.m12 + m1.m02 * m2.m22 + m1.m03 * m2.m32, m03 = m1.m00 * m2.m03 + m1.m01 * m2.m13 + m1.m02 * m2.m23 + m1.m03 * m2.m33, m10 = m1.m10 * m2.m00 + m1.m11 * m2.m10 + m1.m12 * m2.m20 + m1.m13 * m2.m30, m11 = m1.m10 * m2.m01 + m1.m11 * m2.m11 + m1.m12 * m2.m21 + m1.m13 * m2.m31, m12 = m1.m10 * m2.m02 + m1.m11 * m2.m12 + m1.m12 * m2.m22 + m1.m13 * m2.m32, m13 = m1.m10 * m2.m03 + m1.m11 * m2.m13 + m1.m12 * m2.m23 + m1.m13 * m2.m33, m20 = m1.m20 * m2.m00 + m1.m21 * m2.m10 + m1.m22 * m2.m20 + m1.m23 * m2.m30, m21 = m1.m20 * m2.m01 + m1.m21 * m2.m11 + m1.m22 * m2.m21 + m1.m23 * m2.m31, m22 = m1.m20 * m2.m02 + m1.m21 * m2.m12 + m1.m22 * m2.m22 + m1.m23 * m2.m32, m23 = m1.m20 * m2.m03 + m1.m21 * m2.m13 + m1.m22 * m2.m23 + m1.m23 * m2.m33, m30 = m1.m30 * m2.m00 + m1.m31 * m2.m10 + m1.m32 * m2.m20 + m1.m33 * m2.m30, m31 = m1.m30 * m2.m01 + m1.m31 * m2.m11 + m1.m32 * m2.m21 + m1.m33 * m2.m31, m32 = m1.m30 * m2.m02 + m1.m31 * m2.m12 + m1.m32 * m2.m22 + m1.m33 * m2.m32, m33 = m1.m30 * m2.m03 + m1.m31 * m2.m13 + m1.m32 * m2.m23 + m1.m33 * m2.m33, }; }
优化版本 3 - 使用 ref 处理参数和返回值 (耗时 25.52%)
public static void Mul_v3_ref(ref Matrix4x4 result, ref Matrix4x4 m1, ref Matrix4x4 m2) { result.m00 = m1.m00 * m2.m00 + m1.m01 * m2.m10 + m1.m02 * m2.m20 + m1.m03 * m2.m30; // (中略) ... result.m33 = m1.m30 * m2.m03 + m1.m31 * m2.m13 + m1.m32 * m2.m23 + m1.m33 * m2.m33; }
优化版本 4 - 利用 3D 变换矩阵的特点 (耗时 20.97%)
public static void Mul_v4_for_3d_trans(ref Matrix4x4 result, ref Matrix4x4 m1, ref Matrix4x4 m2) { // (前略,同上) ... result.m23 = m1.m20 * m2.m03 + m1.m21 * m2.m13 + m1.m22 * m2.m23 + m1.m23 * m2.m33; result.m30 = 0; result.m31 = 0; result.m32 = 0; result.m33 = 1; }
矩阵优化 (II) - 变换矩阵的缓存
Matrix4x4 ret = Camera.main.worldToCameraMatrix * _inputMat;
public static void ApplyTransform(Matrix4x4[] outputMatrices, Matrix4x4[] inputMatrices) { for (int i = 0; i < inputMatrices.Length; i++) outputMatrices[i] = Camera.main.worldToCameraMatrix * inputMatrices[i]; }
public static void ApplyTransform(Matrix4x4[] outputMatrices, Matrix4x4[] inputMatrices) { Matrix4x4 trans = Camera.main.worldToCameraMatrix; for (int i = 0; i < inputMatrices.Length; i++) outputMatrices[i] = trans * inputMatrices[i]; }
其他的一些零碎常识
- 利用 string 的 immutable 特性,在内存中单一实例 (Interning) 的特性
- 利用 string 的比较性能好 (当引用方式为 object 时进行地址比较) 的特性
- 在需要时使用 StringBuilder
- 利用好容器的 Capacity 来优化内存访问
- 利用 ref 和 struct 来把堆 (heap) 上的访问往栈 (stack) 上挪
- 避免使用 LINQ 来降低零碎的内存分配
内存相关的实践
Mono 2.6.5 的 GC 特性和应对方案
- 基于 Mark/Sweep,无分代/并行
- 执行时所有线程阻塞 (Stop-The-World)
- 堆越接近满的状态,执行得越频繁
- 每次标记都会扫描访问到所有可到达的对象
标记阶段 (Mark Phase) 的性能数据 (仅作为参考)
- 在小对象的情况下,1.4GHz Itanium 能达到 500MB/Sec 的速度
- 每个对象 90 个时钟周期左右 (大量时间是 cache-missing 所致)
- 算下来每秒 15M 数目的对象,也就是每毫秒标记 15000 个左右
清除本身开销很小,但 Finalization 较耗时 (取决于对象的 finalizer)
- (Mono 实现) 无法精确地读取寄存器和栈,且无法区分一个给定值是指针还是标量,这会造成大块的内存无法正常回收,而且难以压缩空闲列表
- (Mono 实现) 碎片化会导致直接的新堆分配,即使空间仍充足(也就是说 Mono 没有做 Copy Optimization 相关的 Defragmentation)
- (Mono 实现) Mono 的 Finalizer 运行在独立的线程上,因此 GC.Collect() 和 obj.Dispose() 是需要线程同步的。
- (Mono 实现) 由于第一条,GC.Collect() 不会处理栈,寄存器,静态变量(这些东东被称为所谓的"Roots")
- (Mono 实现) GC 的开销与堆的尺寸是正相关的 (分配得越多,堆尺寸越大,新的分配和回收就会越慢)
实践中的 GC 控制手法
避免无谓的反复分配,尤其是隐含的每帧分配
典型的例子是在 Update() 函数里面拼接字符串在可能的时刻主动触发 GC,这些时刻包括:
- “刚刚进入某张地图时”
- “刚刚打开某个(静态)界面时”
- “结束掉某一段剧情/新手引导时”
使用对象池策略性地重用对象
- 把对象的引用归还到对象池,主动有计划地持有引用,而非交给 GC
- 做好平衡和取舍(最小化分配/释放的行为,同时妥善考虑内存占用量的调整)
在 GC.Collect() 之前,确保置空所有能被清理的对象,以最大化 GC.Collect() 运行一次的性价比
- 2 和 3 的意义在于,对于每次 GC 而言,如果没有需要释放的对象,速度会非常快。
- 可以连续触发多帧的 GC ,就能在 Profiler 中看到,时间消耗的峰值就是第一次 GC。
- 所以尽量手动 GC 的好处就是,会降低 GC 发生在你不期望的时间的几率,也能降低万一发生时的时间开销。
- 考虑到很多 Unity 程序员之前有过丰富的 C++ 经验,对象池就不再展开细说了。
内存布局的效率改善 (以对象为单位 vs. 以类型为单位)
struct Stuff { int a; float b; bool c; string leString; } Stuff[] arrayOfStuff;
int[] As; float[] Bs; bool[] Cs; string[] leStrings;
导致内存碎片化的各种常见点
前面已经提到的,这里汇总一下
- foreach
- FindObject()
- LINQ
- ToString()
Unity 接口中一些导致零碎内存分配的常见点
.tag
GetComponents<T>
(这个据说还要调到 native code 里面去)Vector3[] Mesh.vertices
Camera[] Camera.allCameras
美术资源相关的运行时控制
UnloadUnusedAssets() 和 UnloadAsset()
- 会扫描所有的未引用资源
- 发现时就会触发回收操作
- 是一个异步操作
- 在加载一个关卡后自动调用
- 由程序员主动调用
- Unity 扫描开销比前者低很多 (只考虑相关的依赖关系)
资源控制常识
- 绝大部分 Mesh 是不需要 CPU 端的读写的,可以把 Read/Write 关掉 (少一份 copy)
- 不要对 Mesh 做非标准的缩放 (少一份 copy)
- Instantiate() 内做了下面这些事
- 克隆整个对象树 (GameObject Hierarchy)
- 克隆它们的组件 (Components)
- 复制它们的属性 (Properties)
- Awake()
- 清除各种状态
- 内部状态缓存
- 预计算
- 需要的话应用变换 (Apply Transform)
工程相关的实践
耗电发热问题改善
- 后台运行多个任务导致CPU超载;
- 系统I/O处理遇到瓶颈和阻塞;
- 手机充电时导致过热;
- 后台多个应用消耗一定的电量;
- 手机硬件连接网络时电量损耗最多;
- 在特定的界面控制帧率,降低 CPU/GPU 的使用率
检测后台应用并提示关闭,提示关闭 GPS 和 蓝牙
- 或者提供一键关闭,游戏关闭或退到后台时再自动恢复)
亮度动态调整
- (甚至可考虑当前地图的光照风格)
提示关闭背景数据和关闭自动同步,退出时再自动恢复
- (但将无法及时接收到邮件)
IO 异步化,串行化,可等待化,可丢弃化,Throttling (流速控制)