移动平台 Unity3D 应用性能优化(上)

作者:陈星百,腾讯移动客户端开发 工程师

商业转载请联系腾讯 WeTest 获得授权,非商业转载请注明出处。

原文链接:http://wetest.qq.com/lab/view/315.html

WeTest 导读

做了大概半年多 VR 应用了,VR 由于双眼 double 渲染的原因,对性能的优化要求比较高,在项目的进展过程中,总结了一些关于移动平台上 Unity3D 的性能优化经验,供分享。

一、移动平台硬件架构

移动平台无论是 Android 还是 IOS 用的都是统一内存架构,GPU 和 CPU 共享一个物理内存,通常我们有“显存”和“内存”两种叫法,可以认为是这块物理内存的所有者不同,当这段映射到 cpu,就是通常意义上的内存;当映射到 gpu,就是通常意义上的显存。并且同一段物理内存同一时刻只会映射到一个 device。

即使是在同一物理内存上 ,之前的 openGL ES 规范中 CPU 和 GPU 之间的内存是不能共享的,vertex 和 texture 的 buffer 是需要拷贝的。后面出来的 vulkan 与 IOS 的 metal 可以共享内存。

了解了移动平台的硬件架构,就知道了 1) CPU 2) 带宽 3) GPU 4) 内存 都有可能成为移动平台 3D 应用性能瓶颈。

二、移动平台 3D 应用的画面渲染过程

1、CPU 通过调用绘制命令(称为一次 Draw Call)来告诉 GPU 开始进行一个渲染过程的。一个 Draw Call 命令会指向本次绘制需要渲染的信息,这些信息包括:顶点数据、纹理数据、shader 参数(光照模型、法线方向、光照方向等)等,简单地说就 画什么,用什么画,怎么画。

2、GPU 接收到 Draw Call 命令之后就会开始进行一次单元渲染,关于 GPU 的单元渲染的过程是这样的(简单示意图):

1)从显存中取出拷贝的顶点数据和光照模型。

2)通过顶点处理器(Vertex Processor)对顶点数据进行一系列的变换和光照处理,包括裁剪处理。tips: 简单的想想,游戏中的各个物体的坐标都是参照游戏中的世界坐标系的,而实际显示的画面是玩家视角或者摄像机视角,这中间就有许多坐标系的转换。这些活就需要顶点处理器来做,最终我们得到了我们所需要视角的画面。

3)到这一步,画面还只是一些多边形,而实际显示在屏幕上的是一个个像素,这里就需要(光栅处理器) Rasterizer 进行光栅化(Rasterization),从而将画面变成一个像素图,把所有的顶点对应到一个一个的像素位置。

4)对这些像素进行上色,通过片元处理器(Fragment Processor)中的像素着色器(Pixel Shader)按照 shader 光照模型,根据纹理对应位置颜色,计算元颜色,再经过深度计算、透明测试计算出每个像素的最终颜色。

5)把结果输出到图像缓存中,全部完成后拿去显示。

三、Unity3D 应用性能优化之 CPU

CPU 的优化非常重要,CPU 的表现直接决定了 VR 应用的帧率,应用的耗电量,发热量。我们来看看相比于普通的 app,VR 应用的 CPU 都承担了什么责任:a、业务逻辑 b、网络通信 c、I/O 操作 d、drawcall e、physic 逻辑 f、GC 内存回收 g、垂直同步等待。

业务逻辑、网络通信、I/O 操作

这一块的优化和普通的 app 差不多。

关于业务逻辑:有些不同的是 Unity 脚本中有一类 update 方法(Update、FixedUpdate、OnGUI 等),这一类方法是在每帧刷新的时候调用的,是比较影响每帧耗时的,为了严格控制这一部分的执行时间,需要注意的以下几点: a、尽量不要再 Update 函数中做复杂计算,如有需要,可以隔 N 帧计算一次,对于纯数学计算,可以开辟新线程来计算(Unity 为什么一般避免使用多线程, 实际上大多数游戏引擎也都是单线程的, 因为大多数游戏引擎是主循环结构, 逻辑更新和画面更新的时间点要求有确定性, 如果在逻辑更新和画面更新中引入多线程, 就需要做同步而这加大了游戏的开发难度。UnityEngine 绝大多数类是不支持子线程的,所以一般只有纯数学计算才会用到子线程去计算。)

b、关闭所有在 update 类中执行 log 的打印操作(Unity 中一次 log 打印有时长达 7ms,Profiler 数据)。

c、不在 update 类方法调用 Getcomponent、SendMessage、FindWithTag 这几个耗时较长的方法。

d、不在 update 类方法中使用临时变量。

关于网络通信、I/O 操作:这些普通 app 的优化和注意点没有什么很大区别,有一点是,Unity 工程中使用了资源动态加载,有些资源是保存在服务器端的,在有必要的时候才会通过网络 load 下去加载。这个资源动态加载需要注意一个问题:由于网络通信过程,CPU 总是处于等待的状态,一般资源下载是多线程同时操作,为了尽快上屏显示资源(在这个工程中是一些图片和英雄的 3D 模型),但是资源有可能是在同一个帧周期中下载完毕的,如果直接加载的话,可能会出现 Camera 瞬时渲染过多三角形面,造成渲染时间(Camera.Render() 函数执行时间)过长,,卡顿的现象。所以这里要注意,网络下载可以多线程多任务同时下载,但是在 Unity 主线程,要避免出现同时加载大型模型和大纹理的情况,最好使用队列的方式,保证一帧只渲染一个 3D 模型。

关于 GC

为什么要把 GC 放在 CPU 这一部分?虽然 GC 是用来处理内存回收的,但是却增加了 CPU 的开销(GC 一次开销可长可短,有时长达 100ms)。因此对于 GC 的优化目标就是尽量少的触发 GC。

首先我们要知道所谓的 GC 是 Mono 运行时的机制,而非 Unity3D 游戏引擎的机制,所以 GC 也主要是针对 Mono 的对象来说的,而它管理的也是 Mono 的托管堆。 明白了这一点,你也就明白了 GC 不是用来处理引擎的 Assets(贴图,音效,模型等等)的内存释放的,因为 U3D 引擎也有自己的内存堆而不是和 Mono 一起使用所谓的托管堆。其次我们还要清楚什么东西会被分配到托管堆上?对,就是引用类型。引用类型包括:用户自定义的类,接口,委托,数组,字符串,Object.而值类型包括:几种基本数据类型(如:int,float,bool 等),结构体,枚举,空类型。所以 GC 的优化也就是代码的优化。

那么 GC 什么时候会触发呢?两种情况: a、当我们的堆的内存不足时,会自动调用 GC 来回收内存。 b、手动的调用 GC,用 System.GC.Collect(),一般情况下,不建议手动去手动进行内存回收,因为容易出现问题。

检查整个工程代码,关于减少 GC 这一方面的优化经验总结大概如下: 1、字符串连接的处理。因为将两个字符串连接的过程,其实是生成一个新的字符串的过程。而之前的旧的字符串自然而然就成为了垃圾。而作为引用类型的字符串,其空间是在堆上分配的,被弃置的旧的字符串的空间会被 GC 当做垃圾回收,可以使用 StringBuilder 来解决(注意:C#没有 StringBuffer,Java 里才有!!String 在进行运算时(如赋值、拼接等)会产生一个新的实例,而 StringBuilder 则不会。所以在大量字符串拼接或频繁对某一字符串进行操作时最好使用 StringBuilder,不要使用 String)。

2、尽量不要使用 foreach,而是使用 for。foreach 其实会涉及到迭代器的使用,而据传说每一次循环所产生的迭代器会带来 24 Bytes 的垃圾。那么循环 10 次就是 240Bytes。

3、不要直接访问 gameobject 的 tag 属性。比如 if (go.tag ==“human”) 最好换成 if (go.CompareTag (“human”))。因为访问物体的 tag 属性(每次 Object.name 也会分配 39B 的堆内存.)会在堆上额外的分配空间。如果在循环中这么处理,留下的垃圾就可想而知了。

4、不要实例化(Instantiate)和(Destroy)对象,事先建好对象池,以实现空间的重复利用。

5、在某些可能的情况下,可以使用结构(struct)来代替类(class)。这是因为,结构变量主要存放在栈区而非堆区。因为栈的分配较快,并且不调用垃圾回收操作,所以当结构变量比较小时可以提升程序的运行性能。但是当结构体较大时,虽然它仍可避免分配/回收的开销,而它由于"传值"操作也会导致单独的开销,实际上它可能比等效对象类的效率还要低。所以要注意选择。

6、场景切换时,可以主动进行垃圾回收(调用 System.GC.Collect()),从而及时去除游戏中已经不必要地内存占用。

Draw Call 的优化

前面说过了,DrawCall 是 CPU 调用底层图形接口的操作。比如有上千个物体,每一个的渲染都需要去调用一次底层接口,而每一次的调用 CPU 都需要做很多工作,那么 CPU 必然不堪重负。但是对于 GPU 来说,图形处理的工作量是一样的。

我们先来看看 Draw Call 对 CPU 的消耗大概是一个什么级别的量: NVIDIA 在 GDC 曾提出,25K batchs/sec 会吃满 1GHz 的 CPU,100%的使用率。有一个公式可以和清楚得计算出在给定的 CPU 资源 与 帧率的情况下,最多能有多少个 DrawCall。 DrawCall_Num = 25K CPU_Frame CPU_Percentage / Framerate。 DrawCall_Num : DrawCall 数量 CPU_Frame : CPU 工作频率(GHz 单位) CPU_Percentage:CPU 分配在 DrawCall 这件事情上的时间率(百分比)

Framerate:希望的游戏帧率

比如说我们使用一个高通 820,工作频率在 2GHz 上,分配 10%的 CPU 时间给 DrawCall 上,并且我们 VR 要求 60 帧,那么一帧最多能有 83 个 DrawCall(由于双 camera 的存在,单眼 DrawCall 只能保证在 41 个以内)。其实,google 官方的建议是单眼 DrawCall 不多于 50 个。

所以对 DrawCall 的优化,主要就是为了尽量解放 CPU 在调用图形接口上的开销。所以针对 drawcall 我们主要的思路就是每个物体尽量减少渲染次数,多个物体最好一起渲染。那么 DrawCall 次数的优化有哪些方案呢?

DC Batching(DC 批处理) batch 即批处理,DrawCall batching 即 DC 的批处理,即把多次 DrawCall 合并成一次 DrawCall 的方案。

Dynamic Batching 动态批处理 Unity 引擎对于使用相同材质的物体会自动进行批处理,相同材质意味着 shader 完全一样,这一部分主要是要注意那些破坏这一特性的人为因素,比如说: 1、批处理动态物体需要在每个顶点上进行一定的开销,所以动态批处理仅支持小于 900 顶点的网格物体,如果你的着色器使用顶点位置,法线和 UV 值三种属性,那么你只能批处理 300 顶点以下的物体(如果在这基础上还使用了 UV2,则只能批处理 180 顶点以下的物体);请注意:属性数量的限制可能会在将来进行改变。

2、使用不同的缩放比例的物体,unity 将无法对这些物体进行批处理。比如(1,1,1)和(1,2,2)就不会动态批处理,但是(1,1,1)和(2,2,2)会动态批处理。

3、拥有 lightmap 的物体含有额外(隐藏)的材质属性,比如:lightmap 的偏移和缩放系数等。所以,拥有 lightmap 的物体将不会进行批处理(除非他们指向 lightmap 的同一部分)。接受实时阴影的物体也不会批处理。

4、多通道的 shader 会中断批处理操作(为了达到特殊的渲染目的,可能某个物体要多遍渲染.这是就要多个通道)。

5、在脚本中动态地指定了物体的材质,也不会进行批处理。

Static Batching 静态批处理 动态批处理虽然是自动的,但是限制非常多,不小心就会打破批处理,所以 unity 在专业版中还提供了静态批处理,静态批处理要求是想批处理的物体一定是 static 的,静态的,不会改变位置和旋转角度以及缩放的,且必须材质一致。其原理是把物体的网格进行合并,变成一个静态的更大的网格物体,再使用一个统一的材质进行渲染。

知道了它的原理,它的某些坑就比较清晰了: 1、在一个平行光、环境光下,没有问题,但是如果你使用了多个平行光,点光源,聚光灯这种复杂的光源去照射物体,那么静态批处理就会被打断。(项目中就遇到过,因为两边有两排英雄模型,所以场景中使用了两个不同平行光,场景中勾选的 static 物体并没有被合并 drawcall,经过一番折磨才找到原因)。

2、如果静态批处理前有一些物体共享了相同的网格,那么每一个物体都会有一个该网格的复制品(本来 unity 只会保留一份,但是静态批处理会生成新的一个大网格,所以会保留所有物体的网格,最后合并),即一个网格会变成多个网格被发送给 GPU。这样会造成内存的使用变大,需要注意这个问题,但是一般场景中使用相同网格的物体会比较少。

3、对于那些 shader 相同,纹理不同导致的不同材质无法进行批处理的物体(比如项目中的场景环境,基座,地面,其实都使用了 unity 自带的 standard shader)可以通过纹理合并的方法来使得它们可以被静态批处理。这就引发了下面的事情: BUS 总线带宽 CPU 完成一次 DrawCall,除了需要发一个 DrawCall 的命令之外,还需要把内存中顶点数据、纹理贴图、shader 参数通过 bus 总线拷贝到内存分配给 GPU 的显存之中,注意这是拷贝,不是指针传递,速度不快。如果一次 drawcall 传递的数据过大,带宽成为了瓶颈,那就会大大影响效率(其它的 DrawCall 无法出发,GPU 又处于闲置)。这种情况最有可能出现在为了减少 DrawCall,疯狂的合并纹理上。在项目中,UI 的 DrawCall 调用占了很大一部分,也会最难优化的,为了减少 drawcall ,我们把 UI 模块的静态部分(一些 UI 的底板,背景等不会发生变化的)全部合并成了一个纹理,最后导致了 DrawCall 下降了,但是帧率却也下降了,内存使用也增加了,原因就是这个。在项目中,不会同时出现的元素不要打包到一起,保证单张合并纹理不大于 10241024 一般就不会有问题了(王者荣耀最大纹理限制在了 256256)。

DrawCall 的优化大概就是这些,优化的目标其实是往一个目标上靠,cpu 的 DrawCall 命令刚刚好能被 GPU 消化,不要让 CPU 等待(带宽限制),也不要让 GPU 闲置。如果即使做到了这个,应用帧率还是上不去,那么就只能去削减场景,做有损优化了。

Physics

Unity 内置 NVIDIA PhysX 物理引擎,来模拟物理世界的一些效果,比如说重力、阻力、弹性、碰撞这些,其中使用了一些内置的组件来实现这些模拟,用的比较多的如:刚体(Rigidbody) 各种碰撞器(Collider) 恒力 (Constant Force) 物理材质(Physic Material)铰链关节(Hinge Joint)弹簧关节(Spring Joint)。

unity 除了提供了一些重要的组件之外,在 unity 脚本中的生命周期中提供了一个专门为物理计算的刷新方法: FixedUpdate()。FixedUpdate 跟 Update 的区别在于,这两个函数处于不同的“帧循环”中,FixedUpdate 处于 Physics 循环中,而 Update 不是。所以这两个函数的使用也有了不同。Update 的执行受场景 GameObject 的渲染的影响,三角形的数量越多,渲染所需要的时间也就越长。FixedUpate 的执行则不受这些影响。所以,Update 每个渲染帧之间的间隔是不相等的,而 Fixedupdate 在每个渲染帧之间的时间间隔是相等的。由于关系到物理模拟,所以一般涉及到物理组件,都需要放在 Fixedupdate 中进行计算。那么关于 physics,一般的优化手段都有哪些呢?下面是一些经验及总结:

1、将物理模拟时间步间隔设置到合适的大小。 Fixed Timestep 是和物理计算有关的,所以若计算的频率太高,自然会影响到 CPU 的开销。同时,若计算频率达不到软件设计时的要求,有会影响到功能的实现,所以如何抉择需要具体分析,选择一个合适的值,一般大于 16ms,小于 30ms。可以通过 Edit->Project Settings->Time 来改变这个值。

2、谨慎使用网格碰撞器(Mesh Collider),过于消耗性能,一般使用更简单的碰撞器,或者使用基本几何碰撞器合并的组合碰撞器。在这个项目中,把所有的网格碰撞体都抛弃了,都换成了 box collider。

3、真实的物理(刚体)很消耗,不要轻易使用,尽量使用自己的代码(数学计算)模仿假的物理。

4、最小化碰撞检测请求(例如 ray casts 和 sphere checks),尽量从每次检查中获得更多信息。 项目中涉及到物体的组件很少,关于 physic 的优化肯定还有很多可以说的,需要再去学习了。

VSync

简单地说,这是 CPU 优化的最直接的一个方法。

科普:VSync 垂直同步又称场同步(Vertical Hold),垂直同步信号决定了 CRT 从屏幕顶部画到底部,再返回原始位置的时间。从 CRT 显示器的显示原理来看,单个像素组成了水平扫描线,水平扫描线在垂直方向的堆积形成了完整的画面。显示器的刷新率受显卡 DAC 控制,显卡 DAC 完成一帧的扫描后就会产生一个垂直同步信号(决定于屏幕的刷新率)。我们平时所说的打开垂直同步指的是将该信号送入显卡 3D 图形处理部分,从而让显卡在生成 3D 图形时受垂直同步信号的制约(注意是制约)。

如果我们选择等待垂直同步信号(也就是我们平时所说的垂直同步打开),那么在游戏中或许强劲的显卡迅速的绘制完一屏的图像,但是没有垂直同步信号的到达,显卡无法绘制下一屏,只有等垂直同步的信号到达,才可以绘制。这样 FPS 自然要受到操作系统刷新率运行值的制约。而如果我们选择不等待垂直同步信号(也就是我们平时所说的关闭垂直同步),那么游戏中作完一屏画面,显卡和显示器无需等待垂直同步信号就可以开始下一屏图像的绘制,自然可以完全发挥显卡的实力。但是不要忘记,正是因为垂直同步的存在,才能使得游戏进程和显示器刷新率同步,使得画面更加平滑和稳定。

取消了垂直同步信号,固然可以换来更快的帧率,但是在图像的连续性上势必打折扣。

针对手游的性能优化,腾讯 WeTest 平台的 Cube 工具提供了基本所有相关指标的检测,为手游进行最高效和准确的测试服务,不断改善玩家的体验。

目前功能还在免费开放中。,欢迎点击链接: http://wetest.qq.com/product/cube 使用。

原创声明,本文系作者授权云+社区-专栏发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏机器之心

业界 | 华盛顿大学联合AWS开源NNVM:面向AI框架的新型端到端编译器

2697
来自专栏落影的专栏

程序员进阶之算法练习(十四)

前言 坚持做算法练习对开发的好处是抽象能力变强,拿到一个需求能很快对其进行抽象,然后再用学过的设计模式相关知识进行整理,最后用代码实现。 最大的好处在于:对...

3317
来自专栏Jackson0714

Web性能探索之旅-1.无线网络基础

1122
来自专栏小樱的经验随笔

ECJTUACM16 Winter vacation training #5 题解&源码

A-------------------------------------------------------------------------------...

33810
来自专栏文武兼修ing——机器学习与IC设计

P2P接口串行FIR设计

配置接口使用寄存器组实现,掉电丢失,因此每次使用之前需要进行配置FIR参数,配置接口时序如下所示:

744
来自专栏AI黑科技工具箱

0.伏笔:图像读取方式以及效率对比

入职了之后挺忙,已经好久没有做一些科普性的更新了。近期已做了些学术要求不高的工作,所以刚好有时间写写总结。

30811

Ray:AI的分布式系统

随着机器学习的算法和技术的进步,越来越多的机器学习应用程序需要多台机器,并且必须利用并行性。但是,在集群上进行机器学习的基础设施仍然是特设的。尽管针对特定用例(...

2276
来自专栏SDNLAB

ONOS:负载均衡路由算法及应用开发(一)

一、应用介绍 当新流量发起时,本应用将为其选择一条路由路径,这条路径具有全局负载均衡意义上的最小权值(Weight/Cost)。 本应用即将开源在笔者的Gith...

3307
来自专栏嵌入式程序猿

mscan VS flexcan

在嵌入式程序猿的公众号里,曾多次介绍过NXP的flexcan以及基于flexcan的一些其他协议和开发,最近在用NXP的另外一款片子,使用的是mscan,这两种...

3429
来自专栏吉浦迅科技

DAY33:阅读Share Memory和Constant Memory

863

扫码关注云+社区