这两年,游戏行业的重心逐渐迁移到了手游,引擎开发工具大有被 Unity 席卷之势,其掀起的声浪可谓是横扫了当年曾在国内风光一时的 Unreal / Gamebryo / OGRE 等诸前辈。好了,废话不多说,今天我们在这里简单地聊一下 Unity 项目中的一些具体的实践。"Unity can only be manifested by the Binary. Unity itself and the idea of Unity are already two."
- Buddha高精度计时器在日常开发中,出于测试或优化的需要,我们常常需要使用比毫秒数更精确的计时器。因此在深入之前,我们先了解一下如何在 Unity 平台上获取足够的计时精度。我们知道,在传统的 Win32 平台上,游戏开发者通常使用 QueryPerformanceFrequency 和 QueryPerformanceCounter 来计时,这两个函数依赖 rdtsc 指令来获取时钟周期数,能得到相对比较精确的计时结果。而在 .Net 平台上,System.Diagnostics.StopWatch的功能与这两个函数类似,在 MSDN 文档中有提到: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 在可能的情况下,会使用与 QueryPerformanceFrequency/QueryPerformanceCounter 类似的机制来达到与其相当的精度。这里同时还提到 StopWatch 的几个有趣的接口:可以通过Stopwatch.IsHighResolution来判断当前是否为高精度可以通过StopWatch.Frequency来获取当前的时钟频率可以通过StopWatch.GetTimestamp()来获取当前的时间戳我们在 Unity 中简单验证一下这些接口在 Mono 2.x 上的可用性: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),也就是基本不用担心溢出回绕的问题我在 StopWatch 上封装了一个简单的 Timer 类,用法如下:using(SSTimert=newSSTimer("_name_tag_")){// test codefoo();}输出如下:'_name_tag_' exec time: 33.332 (ms)这个工具 SSTimer 的代码在这里。后面的性能相关的对比数据,均由此计时器统计而来。Mono/C# 代码实践for / foreach 问题这个问题之前有一些争论,不过这里 ("Should you avoid foreach loops?" 一节的最后的 [EDIT] 补充部分)和这里 (见 @王建飞 和 @权然 的回答)已经说得很清楚了。我自己也分别看了不同情况下 VS 和 Mono 编译出来的 il 代码,肯定了他们的观点,在这里简单归纳一下结论吧:直到 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)我们知道,闭包 (closure) 本质上是包含了"外部"变量状态的 lambda 表达式。从这里 (见 “Should you avoid closures and LINQ?” 一段)可以知道,lambda 表达式能够被编译和替换为对应类的一个静态字段,而 closure 由于储存了额外的修改状态,编译器需要创建一个新类来表示和引用,由于C#有比较丰富的类型信息,不仅创建时开销比前者高,而且也隐含着 100 字节以上的内存开销。简单地说,跟闭包相比,lambda 表达式要轻量得多,可以放心使用。但是这里才是真正的问题——由于普通的 lambda 表达式和闭包长得非常相似,实践中如果不小心,是非常容易弄混淆的。不信的话,试试判断一下,下面这几个函数中的func,有几个会被 Unity 认为是闭包?staticvoidTest_lambda_01(){System.Funcfunc=(e)=>e*e;intresult=func(6);++result;}staticint_foo2=1;staticvoidTest_lambda_02(){System.Funcfunc=(e)=>e*e+_foo2;intresult=func(6);++result;}staticint_foo3=1;staticvoidTest_lambda_03(){System.Funcfunc=(e)=>e*e+(++_foo3);intresult=func(6);++result;}staticvoidTest_lambda_04(){int_foo4=1;System.Funcfunc=(e)=>e*e+_foo4;intresult=func(6);++result;}staticvoidTest_lambda_05(){int_foo5=1;System.Funcfunc=(e)=>e*e+(++_foo5);intresult=func(6);++result;}如果你能无需思考地给出答案,那么还请受俺真心的一拜,佩服佩服。C# 下的闭包与普通的 lambda 表达式难以区分的原因是,C# 的闭包不像 C++ 那样需要显式地指明自己引用的外部变量。C++ 通过所谓的方括号“捕获”语法 ( square brackets capturing ) 来声明一个闭包,非常清晰地指明自己需要的外部环境,比如:std::vectorsome_list{1,2,3,4,5};inttotal=0;autofunc=[&total;](intx){total+=x;};std::for_each(begin(some_list),end(some_list),func);这里的方括号显式地以引用方式“捕获”了total作为闭包的一部分,而 C# 把这个简化成由编译器推导了。这里俺觉得还是"When in Doubt, Be Explicit"好一些。嗯,刚才的练习俺就卖个关子,不给出答案了。代码在这里,感兴趣的同学运行这些代码所在的测试 Unity 工程,一试便知。关于 lambda 表达式和闭包,这次先讲这么多吧。要点是保持短小即可。对于这种嵌套的逻辑,随着代码量的增加,可读性会急剧下降,就会损失局部定义的好处。枚举项的 ToString() vs. Enum.GetName()简单说一下吧。有种说法是说不要使用枚举的 ToString() 来获取单个枚举项对应的字符串,应该使用Enum.GetName(typeof(Foo), Foo.Bar)据说后者的速度是前者的两倍。我实测了一下,一个含有 10 个枚举项的枚举FooTypes在遍历并转换每一项 10000 次的结果是:ToString() 耗时 553.817msEnum.GetName() 耗时 437.2ms测试中的差距并不十分显著,所以结论是直接使用枚举项的 ToString() 并无不妥,可读性亦较 Enum.GetName 更佳。但有一点要注意的是,这种字符串化的绝对开销不低,算下来转 1000 次会花 5ms 左右,这个开销感觉对游戏来说已经比较高了。具体的测试代码在这里。使用 "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.287msC-Style 方式耗时 4.640ms同样是差距并不显著,而 as 方式甚至更慢一点。这是为什么呢?查阅 MSDN 可以在这里看到,expression as type实际上等价于expression is type ? (type)expression : (type)null也就是说,对于有效转型,"as" 的开销 = "is" 的开销 + "c-style cast" 的开销。这样就解释了前面的测试结果。结论,在使用 "as" 还是 C-Style Cast 这个问题上,不要考虑性能影响,应按照它们各自提供的功能去选择。进一步说,如果你需要自动地把错误的转型以 null 为结果返回的话,使用 "as" 关键字;如果你认为这个转型足够重要,不希望别人由于忘记检查返回值导致没有处理该关键错误,那么就需要使用 C-Style Cast,这种方式会在转型失败时抛出 InvalidCastException 异常。多说一句,在 Unity 的框架内即使不处理,这个异常也会被 Unity 吞掉,然而我个人对这种不管三七二十一都一网打尽的方式持保留意见。具体的测试代码在这里。矩阵优化 (I) - 矩阵乘法在UnityEngine.dll的struct Matrix4x4定义 (网页版在这里) 中,我们可以看到 Unity 的标准矩阵相乘函数的定义如下:// Summary:// A standard 4x4 transformation matrix.publicstructMatrix4x4{// (前略) ...publicstaticMatrix4x4operator*(Matrix4x4lhs,Matrix4x4rhs);// (后略) ...}那么它的性能具体如何呢?我简单地测试了一下,在 AMD 的 3.5G 八核处理器上,百万次矩阵相乘的时间为 339.681 (ms)。operator *是 Unity 引擎提供给用户的标准矩阵相乘的接口。对于一个典型的 3D 游戏来说,这个操作可能会被频繁地用到,因此对其进行适当的优化还是很有必要的。下面我们一起来看看,对矩阵相乘操作可以做哪些优化尝试,具体效果又能达到什么程度。为了方便,operator *的版本,下面我们简单地称为官方版。优化版本 1 - brute-force (耗时 1596.76%)首先,我们实现一个拿衣服版本的矩阵相乘 Mul_v1_naive:publicstaticMatrix4x4Mul_v1_naive(Matrix4x4m1,Matrix4x4m2){Matrix4x4result=Matrix4x4.zero;for(inti=0;i<4;i++)for(intj=0;j<4;j++)for(intk=0;k<4;k++)result[i,j]+=m1[i,k]*m2[k,j];returnresult;}这个是很直接的教科书式的矩阵相乘运算。这个运算告诉了我们如下的信息:把拿衣服版本的运算结果与官方版做值比较,得到了一致的结果——这说明了官方版的行为与我们的期望完全一致。把运算的开销与官方版比较,不出所料,拿衣服版本够慢的,百万次耗时为 5413.076 (ms) 左右,是官方版的 15 倍。优化版本 2 - 循环展开 (耗时 100.54%)拿衣服的 v1 版本有三重循环嵌套,光是肉眼看起来就很慢。那么我们把循环展开一下,写个 v2 测一下看看有什么变化。这是 Mul_v2_naive_expanded:publicstaticMatrix4x4Mul_v2_naive_expanded(Matrix4x4m1,Matrix4x4m2){// Matrix4x4 is a struct so 'new' would still leave it on stackreturnnewMatrix4x4{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,};}这个版本跑下来已经跟官方版很接近了,上文提到官方版百万次是 339.681 (ms),而 Mul_v2_naive_expanded 的开销是 341.534 (ms),是前者耗时的 100.54%。有理由推断,官方版的operator *或多或少就是如此实现的。优化版本 3 - 使用 ref 处理参数和返回值 (耗时 25.52%)我们注意到,由于Matrix4x4是一个 struct,参数传递时是传值而不是传引用,这意味着每次调用会产生三次矩阵的复制,算下来是 16 * 3 个 float 也就是 192 bytes 的复制。我们改为 ref 来消除这些无谓的复制,就得到了 v3 的实现。这是 Mul_v3_ref:publicstaticvoidMul_v3_ref(refMatrix4x4result,refMatrix4x4m1,refMatrix4x4m2){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;}这个版本的耗时为 86.703 (ms),缩短到了官方版的 1/4 左右。可见“复制 192 字节”所花的时间至少是“64 次乘法 + 48 次加法”的三倍 ("至少"二字是考虑到函数调用的开销),所以减少无谓的复制还是蛮重要的,呵呵。优化版本 4 - 利用 3D 变换矩阵的特点 (耗时 20.97%)我们知道,在 3D 矩阵的变换中,最后一行是 (0, 0, 0, 1) 不会变化,如下图:所以可以省掉这一部分冗余运算,得到 Mul_v4_for_3d_trans:publicstaticvoidMul_v4_for_3d_trans(refMatrix4x4result,refMatrix4x4m1,refMatrix4x4m2){// (前略,同上) ...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;}这个版本的耗时为 71.062 (ms),约是官方版的 1/5 。这里拿矩阵相乘做优化实际上是举个栗子,旨在说明在实践中避免不必要的拷贝的重要性。至于何时使用 struct,何时使用 class,何时需要使用 ref 加持的 struct,相信大家能够根据具体的情况去更好地判断。这四个版本的代码可以在这里看到。他们相关的测试代码可以在这里看到。矩阵优化 (II) - 变换矩阵的缓存这里我们还是拿矩阵来举例子吧。下面是一个典型的变换到摄像机空间的操作 (为简化讨论仍使用官方版的乘法):Matrix4x4ret=Camera.main.worldToCameraMatrix*_inputMat;这里的运算本身并无不妥,但通常我们做这种变换的时候,是对一系列输入矩阵做变换,如下:publicstaticvoidApplyTransform(Matrix4x4[]outputMatrices,Matrix4x4[]inputMatrices){for(inti=0;i(这个据说还要调到 native code 里面去)Vector3[] Mesh.verticesCamera[] Camera.allCameras美术资源相关的运行时控制UnloadUnusedAssets() 和 UnloadAsset()Resources.UnloadUnusedAssets() 的特点:会扫描所有的未引用资源发现时就会触发回收操作是一个异步操作在加载一个关卡后自动调用。Resources.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 (流速控制)新手引导防卡死正如我在“一个有趣的交互 bug ——兼谈游戏的引导系统”一文中提到的新手引导问题那样,卡新手是一类常见问题。对于这一类问题,除了把可能有冲突的系统及时修复以外,我们还应当采取一些防御性的设计,当玩家陷入卡死状态时,能借助这些机制跳出当前的卡死状态。由于现在的新手引导普遍傻瓜化,如果玩家停留在某个步骤超过 5 秒钟,我们就可以假设该玩家遇到状况了。这时我们可以检测玩家是否在连续 tap 屏幕,如果连续 tap 三次以上,可以弹出信息提示:“请长按屏幕 x 秒钟退出当前的引导” 如果玩家按提示操作,就 break 出当前的引导,视情况跳过或重新开始。错误处理和异常捕获时机这一节只需讲一句话:关键逻辑路径上不要裸调。关键逻辑路径上不要裸调。关键逻辑路径上不要裸调。(是谁说重要的话要讲三遍来着?)不要依赖 Unity 对未捕获异常的宽容性。在游戏的关键逻辑路径上,如果裸调一个可能抛出异常的函数,就会冒着部分关键业务逻辑被跳过的风险。这种风险不仅会造成可能的内部状态错误和运行不稳定以外,更有可能被破解者或熟练用户利用,达成各种你非常非常不希望见到的目的。宕机信息的采集,处理,统计和反馈这里必须宣传一下,我司(西山居)质量中心部门近期出品了一个服务Crasheye,可以帮助开发人员采集,分类和梳理各种宕机记录,给出项目稳定情况的分类趋势统计,并借助符号文件把堆栈转换为程序员可读的样式。第一次看到这个工具就我伙呆了(这个词过时得好快)。点这里可以看到一个使用此产品来捕获宕机信息和统计的演示,简直华丽得不能直视。好了废话不多说了,我要跑去质量中心那边,请他们收下我的膝盖了。[2015-06-28] 补:本文中的代码都包含在u3d_practice这个库中,如有错误请不吝指出,更新和扩展都会出现在那里。[完]Gu Lu[2015-06-28]另:本文遵循Creative Commons BY-NC-ND 4.0许可协议。