第四章 UI
- 从图集工具来看,NGUI有更多的自主选择权,而UGUI更多的自动化的便利,
- 从组件支持度来看NGUI更适合快速原型,而UGUI更多的是需要自己编写适合自己的组件。
- 从UI底层可定制度上看,两者都是可定制的,但NGUI更加方便。
- 从输入事件处理上看,NGUI混合了物理系统,而UGUI有一套自己独立的事件系统。
- 从层级显示上看,NGUI概念有点混淆,而UGUI层级概念清晰干净。
- 从字体制作上看,NGUI麻烦了点,而UGUI更加方便。
- 从社区完善上看,NGUI更加商业化,而UGUI有官方支持后台强大。
- 从性能上比较看,NGUI臃肿了点但尚可,而UGUI更加良好。
综合来看,NGUI和UGUI都各自有各自的特点,没有绝对好,也没有绝对的差,都各自有各自的特点,和擅长的领域。针对不同的人群和项目可以有不同的选择。
- Unity3D 4.x的项目通常会选择NGUI,因为Unity3D 4.x对UGUI支持的并不好。
- 新项目都会选择使用最新版本的Unity3D,所以选择UGUI的会多一点。我们需要与时俱进跟随潮流,所以如果你没有尝试过UGUI,熟悉UGUI应该是迟早的事。铁了心要做一个决定是用NGUI还是用UGUI,我的建议是,新项目启用UGUI,老项目继续使用NGUI。
- 不过UGUI也有源码开放,只是不能随意定制,只是在此基础上扩展和重载。
-
UGUI的初级和高级使用详解
- UGUI是在3D网格下建立起来的UI系统,它的每个可显示的元素都是以3D模型网格的形式来构建起来的。当UI被实例化时,UGUI首先要做的事就是构建网格。
- UGUI做了优化,它将一部分相同类型的图片都集合起来合成一个张图,然后将拥有相同图片相同shader的材质球合并成一个材质球,并且把分散开的模型网格也一起合并了,这样就生成了几个大网格和几个材质球,以及少许整张的图集。
- UGUI也并不是所有的网格和材质球都合并成一个,只有把相同层级的元素,以及相同层级上的拥有相同的材质球参数的才合并在一起。合并成一个网格了就是一个静止的模型了,如果我们移动了任何元素,或者销毁了任何元素,或者改变了任何元素的材质球参数,UGUI则会销毁这个网格,重新构建一个新的。
- 合并和拆分的操作会消耗很多CPU,UI系统要做的就是尽一切可能节省些CPU消耗,把尽量多的剩余CPU让给项目逻辑。UGUI在制作完成成品后性能优劣差距很多时候都会出现在这里,合并的最多的元素,拆分次数最少的UI,才能达到优秀的性能开销.
-
核心组件Canvas
- Canvas,我们暂且叫它画布。Canvas就相当于画画时铺在上边的画板,我们把各类元素放在画布上后,Canvas要做的事情就是合并这些元素。
- 合并的规则为,同一个Canvas里,相同层级的,相同材质球的元素进行合并,从而减少Drawcall。不过相同层级的概念并不是gameobject 上的节点层级,而是覆盖层级。Canvas说如果两个元素重叠,则可以认为它们是上下层关系,把所有重叠的层级数计算完毕后,第0层的所有元素统一合并,第1层的元素也统一合并,以此类推。
- Canvas上的参数 Render Mode 渲染模式比较重要,这里详细介绍下,你可以选择不以Camera为基准的Overlay模式,也可以选择Camera为基准的Screen Camera模式,也可以选择3D世界为基准的World Space模式。三者适合于三种不同的的使用场景各有不同。
- Overlay模式并不与空间上排序有任何关系,空间上的前后位置不再对元素起作用,它常用在纯UI的区域内,这种模式下Camera排序有别与其他模式,Sort order参数在排序时被着重使用到,Sort order参数的值越大,越靠前渲染。在这个模式下没有Camera的渲染机制因此很难加入普通的3D模型物体来增加效果。
- Screen Camera模式,相对比较通用一点,它依赖于Camera的平面透视,渲染时的布局依赖于它绑定的Camera。想让更多的非UGUI元素加入到UI中,Screen Camera模式更加具有优势。这种模式是实际项目中制作UI最常用的模式,不过UGUI底层有对排序做些规则,如对元素的z轴不为0的元素,会单独提取出来渲染,不参与合并。
- World Space模式,主要用于当UI物体放在3D世界中时用的,比如,一个大的场景中,需要将一张标志图放在一个石块头上,这时就需要World Space模式。它与 Screen Camera 的区别是,它常在世界空间中与普通3D物体一同展示,依赖于截锥体透视(Perspective)Camera。它的原理挺简单的,与普通物体一样当UI物体在这个Camera视野中时,就相当于渲染了一个普通的3D面片,只不过除了普通的渲染Canvas还对这些场景里的UI进行合并处理。
-
Canvas Scaler
- 这是个缩放比例组件,用来指定画布中元素的比例大小。
- 有简单指定比例大小的Constant Pixel Size模式,也有Scale With Screen Size以屏幕为基准的自动适配比例大小,或者Constant Physical Size以物理大小为基准的适配规则。
- 在实际手游项目里,设备的屏幕分辨率变化比较大,通常使用以屏幕为基准的自动适配比例大小的Scale With Screen Size选项。
-
Graphic Raycaster
- 输入系统的图形碰撞测试组件,它并不会检测Canvas以外的内容,检测的都是画布下的元素。当图元素上存在有效的碰撞体时,Graphic Raycaster 组件会统一使用射线碰撞测试来检测碰撞的元素。
- 我们也可以设置完全忽略输入的方式来彻底取消点击响应,也可以指定阻止对某些layers进行相应。
-
EventTrigger
- 输入事件触发器,与此脚本绑定的UI物体,都可以接受到输入事件。
- 比如(鼠标,手指)按下,弹起,点击,开始拖动,拖动中,结束拖动,鼠标滚动事件等。
- 它主要是起到点击响应作用,配合前面的 Graphic Raycaster 响应给输入事件系统。
-
Image,RawImage
- 这两个是UI里的主要部件,它们可以对图片进行展示,包括图片,图集。
- 两者的区别是Image仅能展示图集中的图元但可以参与合并,而RawImage能展示单张图片但无法合并。通常我们会将小块的图片,打成图集来展示,这样更节省性能也更节省内存,这也是UGUI自动集成的功能,每个图片资源都有一个tag 标记,标记决定了哪些元素会合并到同一张图集内,如果没有tag标记,则默认不会合并图集它自己就是自己的图集。
- 不使用图集而使用RawImage展示单张图片的时,通常都是由于图片尺寸太大导致合并图集效率太低,或者相同类型的图片数量太多,导致合并图集后的图集太大,而实际在画面上需要展示的这种类型的图片又很少,图集方式反而浪费大量内存空间,则使用RawImage逐一展示即可。
-
Mask,RectMask2D
- 遮挡组件,可以将其子节点下矩形区域外的内容剔除,是滚动窗口中最常用的组件。
- 这两种方式的主要是在剔除的方法上有所区别,在实现效果上都是一样的,其中Mask 使用顶点重构的方式剔除矩形区域外的部分,而 RectMask2D 则采用 Shader 的剔除方式,每个元素都有自己的材质球实例和实例参数。
- Mask 和 RectMask2D 它俩具体的剔除算法和源代码分析我们将在后面的UGUI源码剖析章节讲解。
-
其他组件
- 其他大部分逻辑组件都是可以重写的,比如按钮组件Button,切换组件Toggle,滚动条组件ScrollBar,滑动组件Slider,下拉框组件DropDown,视图组件ScrollView,如果不想使用它们,觉得它们的功能不够用,我们是可以用Image,Mask等几个核心组件组合后重写的。
- 在实际工作中,很多项目都会自定义属于自己的组件,为什么要自定义呢?很多时候项目里的需求更多样化,有自己的组件可以在特殊需求和特殊逻辑时,能够好不费劲的更改自定义的组件。所以大部分项目中,都会重写一些组件来用来给自己项目使用,也有一些人总结了这些组件的经验,写了些比较好用的组件开源在Github上。
-
UGUI分成了三块,输入事件,动画,核心渲染。
-
动画:用了tween补间动画的形式,对颜色,位置,大小做了渐进的操作。
tween的原理是在启动一个协程,在协程里对元素的属性渐进式的修改,除了修改属性数值,tween还有多种曲线可以选择,比如内番曲线,外翻曲线等,一个数值从起点到终点的过程可以由曲线来控制。
-
UGUI 把输入事件模块有四部分,事件数据模块,输入事件捕获模块,射线碰撞检测模块,事件逻辑处理及回调模块。
-
事件数据模块
事件数据模块部分对整个事件系统的作用来说,它主要定义并且存储了事件发生时的位置、和事件对应的物体,事件的位移大小,触发事件的输入类型,以及事件的设备信息等。事件数据模块在逻辑上没有做过多的内容,而主要为了获取数据,提供数据服务。
事件数据模块,主要作用为在各种事件发生时,为事件逻辑做好数据工作。
-
输入事件捕获模块
输入事件捕获模块由四个类组成,BaseInputModule,PointerInputModule,StandaloneInputModule,TouchInputModule。
StandaloneInputModule 的主函数 ProcessMouseEvent,它从鼠标键盘输入事件上扩展了输入的逻辑,处理了鼠标的按下,移动,滚轮,拖拽的操作事件。其中比较重要的函数为 ProcessMousePress、ProcessMove、ProcessDrag 这三个函数,我们来重点看下他们处理的内容。
ProcessMousePress 不仅仅处理的是按下的操作,也同时处理鼠标抬起的操作,以及处理了拖拽启动和拖拽抬起与结束的事件。在调用处理相关句柄的前后,事件数据都会被保存在 pointerEvent 中,然后被传递给业务层中设置的输入事件句柄。
ProcessDrag 拖拽句柄处理函数与ProcessMousePress类似对拖拽事件逻辑做了判断,包括拖拽开始事件处理,判断结束拖拽事件,以及拖拽句柄的调用。
ProcessMove 则相对简单点,每帧都会直接调用处理句柄。
除了鼠标事件外,我们再来看看触屏事件的处理方式,即 TouchInputModule 的核心函数。 ProcessMove 和 ProcessDrag 与前面鼠标事件处理时一样的,只是按下的时间处理不同,而且它对每个触点都做了相同的操作处理。其实 ProcessTouchPress 和鼠标按下处理函数 ProcessMousePress 非常相似,可以说基本上一模一样,只是传入时的数据类型不同而已,由于篇幅有限这里不再重复展示长串代码。
-
射线碰撞检测模块源码
射线碰撞检测模块主要工作是从摄像机的屏幕位置上,做射线碰撞检测并获取碰撞结果,把结果返回给事件处理逻辑类,交由事件处理模块处理事件。
射线碰撞检测模块主要为3个类,分别作用于 2D射线碰撞检测,3D射线碰撞检测,GraphicRaycaster图形射线碰撞测试。
-
2D、3D射线碰撞测试相对比较简单,用射线的形式做碰撞测试,区别在2D碰撞结果里预留了2D的层级次序以便在后面的碰撞结果排序时,以这个层级次序为依据做排序,而3D的碰撞检测结果则是以距离大小为依据排序的。
-
GraphicRaycaster 为UGUI元素点位检测的类,它被放在了 Core 渲染块里。它主要针对 ScreenSpaceOverlay 模式下输入点位做碰撞检测,因为这个模式下的检测并不依赖于射线碰撞,而是遍历所有可点击的UGUI元素来检测比较,从而判断是该响应哪个UI元素。因此 GraphicRaycaster 是比较特殊的。GraphicRaycaster 对每个可以点击的元素(raycastTarget是否为true,并且 depth 不为-1,为可点击元素)进行计算,判断点位是否落在该元素上。再通过 depth 变量排序,判断最先该落在哪个元素上,从而确定哪个元素响应输入事件。所有检测碰撞的结果数据结构为 RaycastResult 类,它承载了所有碰撞检测结果的依据,包括了距离,世界点位,屏幕点位,2D层级次序,碰撞物体等,为后面事件处理提供了数据上的依据。
-
-
事件逻辑处理模块
事件主逻辑处理模块,主要的逻辑都集中在 EventSystem 类中,其余的类都是对它起辅助作用的。
EventInterfaces,EventTrigger,EventTriggerType 定义了事件回调函数,ExecuteEvents 编写了所有执行事件的回调接口。
- EventSystem 主逻辑里只有300行代码基本上都在处理由射线碰撞检测后引起的各类事件。判断事件是否成立,成立则发起事件回调,不成立则继续轮询检查,等待事件的发生。
- EventSystem 是事件处理模块中唯一继承 MonoBehavior 并且有在 Update 帧循环中做轮询的。也就是说,所有UI事件的发生都是通过 EventSystem 轮询监测到的并且实施的。EventSystem 通过调用输入事件检测模块,检测碰撞模块,来形成自己主逻辑部分。因此可以说 EventSystem 是主逻辑类,是整个事件模块的入口。
-
-
Culling 裁剪模块
Culling 里是对模型裁剪的工具类,大都用在了 Mask 遮罩上,只有 Mask 才有裁剪的需求。
-
Layout 布局模块
Layout 主要功能都是布局方面,包括横向布局,纵向布局,方格布局等等。总共12个文件,有9个带有 Layout 字样,它们都是处理布局的。
除了处理布局内容以外,其余3个文件,CanvasScaler,AspectRatioFitter,ContentSizeFitter 则是调整自适应功能。
从 ContentSizeFitter,AspectRatioFitter 都带有 Fitter 字样可以了解到,它们的功能都是处理自适应。其中 ContentSizeFitter 处理的是内容自适应的, 而 AspectRatioFitter 处理的是朝向自适应的,包括以长度为基准的,以宽度为基准的,以父节点为基准的,以外层父节点为基准的自适应,四种类型的自适应方式。另外 CanvasScaler 做的功能非常重要,它操作的是 Canvas 整个画布针对不同屏幕进行的自适应调整。
不同 ScreenMathMode 模式下 CanvasScaler 对屏幕的适应算法,包括优先匹配长或宽的,最小化固定拉伸的,以及最大化固定拉伸三种数学计算方式。其中代码中在优先匹配长或宽算法中,介绍了使用Log和Pow来计算缩放比例可以表现的更好。
-
MaterialModifiers, SpecializedCollections, Utility
材质球修改器,特殊收集器,实用工具,这三块相对代码量少却很重要,他们是其他模块所依赖的工具。
-
VertexModifiers
顶点修改器为效果制作提供了更多基础方法和规则。主要用于修改图形网格,尤其是在UI元素网格生成完毕后对其进行二次修改。
其中 BaseMeshEffect 是抽象基类,提供所有在修改UI元素网格时所需的变量和接口。
IMeshModifier 是关键接口,在下面的渲染核心类 Graphic 中会获取所有拥有这个接口的组件,然后依次遍历并调用 ModifyMesh 接口来触发改变图像网格的效果。
当前在源码中拥有的二次效果包括,Outline(包边框),Shadow(阴影),PositionAsUV1(位置UV) 都继承了 BaseMeshEffect 基类,并实现了关键接口 ModifyMesh。其中 Outline 继承自 Shadow, 他们的共同的关键代码,我们可以重点看一下:
此函数作用是,在原有的Mesh顶点基础上,加入新的顶点,这些新的顶点复制了原来的顶点数据,修改颜色并向外扩充,使得原图形外渲染出外描边或者阴影。
-
我们常用的组件 Image,RawImage,Mask,RectMask2D,Text,InputField 中,Image,RawImage,Text 都是继承了 MaskableGraphic ,而 MaskableGraphic 又继承自 Graphic 类,这里 Graphic 类相对比较重要,是基础类也存些核心算法。
-
Graphic 的运作机制。
- SetAllDirty 设置并通知元素需要重新布局、重新构建网格、以及重新构建材质球。 它通知 LayoutRebuilder 布局管理类进行重新布局,在 LayoutRebuilder.MarkLayoutForRebuild 中它调用 CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild 加入重构队伍,最终重构布局
- 网格构建函数,Graphic 构建 Mesh 的部分,先调用OnPopulateMesh创建自己的Mesh网格,然后调用所有需要修改 Mesh 的修改者(IMeshModifier)也就是网格后处理组件(描边等效果组件)进行修改,最后放入 CanvasRenderer 。其中 CanvasRenderer 是每个绘制元素都必须有的组件,它是画布与渲染的连接组件,通过 CanvasRenderer 我们才能把网格绘制到 Canvas 画布上去。
-
Mask 遮罩部分
-
Mask 组件调用了模板材质球构建了一个自己的材质球,因此它使用了实时渲染中的模板方法来裁切不需要显示的部分,所有在 Mask 组件后面的物体都会进行裁切。我们可以说 Mask 是在 GPU 中做的裁切,使用的方法是着色器中的模板方法。
-
RectMask2D 会先计算并设置裁切的范围,再对所有子节点调用裁切操作。其中:
获取了所有有关联的 RectMask2D 遮罩范围,
然后计算了需要裁切的部分,实际上是计算了不需要裁切的部分,其他部分都进行裁切。
最后对所有需要裁切的UI元素,进行裁切操作。
-
宏观的角度看UI框架。
只有从宏观的角度看问题,才能看的更明白。我们项目中拥有众多UI界面,我们要统一管理所有UI,这样才能使得每个UI界面都能得到有效的调配。不仅如此,如果每个UI界面都是可扩充的那就太棒了。UI有一个很关键的系统是输入事件系统,UI内的每个按钮都需要有一个处理输入的句柄。所以我们需要写一个统计的管理类,以及每个UI都要有统一的基类,并且每个UI按钮元素都对应一个处理输入的句柄。另外对于UI来说,有通用UI,也有非通用UI,有常用UI和非常用UI之分。接下来我们把细节规划一下。
-
管理类
-
整个UI是由N个界面构成的。这些UI界面有基本的功能,生成,展示,销毁,查找。如果说,我们分别对N个UI界面的这些功能进行编程,就会有大量的工作产生,而且维护起来的工作量也是巨大的。
-
用一个单例实例来管理所有的UI界面,让他们能有统一的接口进行以上的活动,创建UI管理类是最好的选择,我们可以命名它为 UIManager,这个名字符合它代表的功能。
-
UIManager 具体里面要做些什么呢。它需要创建UI,需要查找现有的某个UI,以及需要销毁UI,以及一些UI的统一接口调用和调配工作。UIManager 承担了所有UI的管理工作,因此UI在生成出来后的实例都将存储在这里。不仅如此,一些UI常用变量也存储在里面,比如屏幕的适配标准大小,比如UI的Camera实例等等。
-
-
基类
-
项目中有很多界面,这N个界面他们有自己的共性,比如最基本的,他们都需要进行初始化,他们都需要有展示接口,他们都可以关闭,共性产生统一特征的接口,Init,Open和Close。继承基类又使得管理起来比较方便,在上面提到的 UIManager 里存储的UI实例时,可以统一使用基类的方式存储。我们可以把基类的名字称为 UIScreenBase,每个UI界面都继承自它,Screen一词很形象贴切的描述了屏幕上显示的界面。
-
我们将所有UI都定义为基类的子类,对有需要做特殊处理的UI界面,可以重写Init,Open和Close。为了能更方便的知道UI的状态,我们也可以定义一个UI状态,比如OpenState为打开状态,CloseState为关闭状态,HidenState为隐藏状态,PreopenState为预加载状态,以状态的形式来判断UI现在的情况。
-
-
输入事件响应机制
Unity3D的UGUI输入事件响应机制建立通常有2种,一种是继承型,一种是注册型。
-
继承型是指事件先响应到基类,再由基类反应给父类,由父类做处理,这样UI既可以得到对输入事件的响应,也可以自行修改自己需要的逻辑。比如我们写了个处理事件的基类组件UIEventBase是父类能接受各种输入事件响应,UIEventButton是继承UIEventBase的子类,当输入事件传入时UIEventButton能做出响应,因为它继承了父类。
-
绑定型是指在对输入事件响应之前,我们对UI元素绑定一个事件响应的组件。比如编写一个绑定型事件类 UIEvent,当某个UI元素需要输入事件回调时,对这个物体加绑一个 UIEvent,并且对 UIEvent 里需要的相关响应事件进行赋值或注册操作函数。当输入事件响应时,由 UIEvent 来区分输入的是什么类型的事件,再分别调用响应到具体函数。
继承型和绑定型都有一个共同的特点,都需要与UI元素关联,区别是继承型融入在了各种组件内,而绑定型以独立的组件形式体现。
继承型UI事件输入响应机制需要关联到组件内,UGUI和NGUI都已经有了自己的基础的组件,所以很难在这上面使用,而在另一些比较特殊的GUI系统内可以很好的适应。比如我曾经做过一个项目,我们构建的一套新的UI系统的完全独立于UGUI和NGUI的GUI系统之外,我们将输入事件处理注入到这个系统的各个组件内,达到了输入事件处理与组件融合的效果。
绑定型的方式更适合在已经建立了GUI系统的基础上,对输入事件进行封装处理。通常在UGUI和NGUI上都会使用绑定型对输入事件处理进行封装。
例如,在UI初始化中,对需要输入事件响应的,绑定一个事件处理类,比如命名为 UIEvent,然后对事件句柄进行赋值,例如,ui_event.onclick = OnClickLogin,OnClickLogin就是响应登录按钮的事件句柄。
-
-
自定义组件
除了NGUI和UGUI本身的组件外,我们自己的自定义组件是必不可少的,特别是游戏项目,无论大小,都需要有自己的自定义组件,自定义组件不仅能让程序员在写逻辑时快速上手,满足项目的设计需求,而且也能起到对UI优化的作用,尤其在元素多的组件内。
下面介绍项目中最常改造的组件:
① UI动画组件
② 按钮播放音效组件。
③ UI跟随3D物体组件。
④ 无限滚动页面组件。
⑤ 其他组件。
编写自定义的UI组件的目标就是,增加更多通用的组件,减少重复劳动,让程序员在编写UI界面时更加快捷高效,同时也提升了UI的运行效率。拥有属于自己的一套自定义套件,对项目来说也是非常有价值和高效的一件事。
-
动指的是元素移动,或放大缩小频率比较高的UI,静就是静止不动的,或者说动的比较少的UI。
UGUI 和 NGUI一样,都是用模型构建UI画面的,在构建后都做了合并Mesh的有优化操作,不合并会导致无数drawcall进而导致GPU队列阻塞或消耗加大,游戏性能降低。合并操作是有极大益处的,但问题在于UI元素一动就需要重新合并,将那些原本不需要重新构建的内容也一并重构了,导致原来合并Mesh的好事变坏事。因此要将行动的UI元素和静态不动的UI元素分离开来,让合并的范围缩小,只合并那些会动的UI元素,因为他们重绘的频率比较高,而那些基本不动的UI元素就不让它们参与重新合并Mesh的操作了。
-
UGUI 和 NGUI都有自己的重绘合并节点,我们可以称它们为画板,UGUI是Canvas,NGUI是UIPanel。以画板为节点进行拆分。把会动的UI元素放入专门为它们准备的合并节点上,而将静止不动的UI留在原来的合并节点上。这样一来,当会动的UI元素来回移动缩放的时候,不再会重构静态部分的UI了。在实际项目中静态的UI元素占UI的数量比较多,而动态的UI元素只是小部分。动静分离后,减少了不少的CPU在重绘和合并时的消耗。
-
原因:UI过大,实例化,初始化时,消耗的CPU过大。我们需要想办法拆分这些,过大的UI界面。
-
拆分方法:把隐藏的UI界面拆分出来,成为独立运作的界面,只在需要它们时才调用并实例化。其次,如果界面内容还是很多,我们可以把2次显示的内容拆出来。什么是2次内容?打个比方,一个界面打开时会显示一些内容(例如动画),完毕后或者点击后才能看到另外的内容。这之后出现的内容视为2次显示内容,可以考虑拆分出来成为独立的界面,需要时再加载。
注意权衡加载速度与内存,过大的UI固然加载缓慢内存消耗大,但拆分成小个体时,如果小个体频繁加载和销毁,也同样会消耗过多CPU。如果加载和销毁过于频繁,我们可以使用后面介绍的优化方法,把它们存起来不销毁。
-
原因:我们在UI实例化时,需要将Prefab实例化到场景中,这期间还会有Mesh的合并,组件的初始化,渲染初始化,图片的加载,界面逻辑的初始化等程序调用,消耗掉了很多CPU。这导致了在我们打开某个界面时,出现卡顿的现象,就是CPU消耗过重的表现。
上面讲的拆分UI是一个方面,不过只能在一些冗余比较大的界面上做优化,而一些容易比较小,难以拆分的UI界面,就很难再用拆分的方法优化效果。甚至有的UI界面即使拆分后,任然会消耗很多CPU。因此我们使用UI预加载,在游戏开始前加载一些UI界面,让实例化的消耗在游戏前平均分摊在等待的时间线上。
-
如何进行UI预加载
第一步,最直接的方法,在游戏开始前加载UI资源但不实例化,只是把资源加载到内存。这样当点击按钮后,弹出UI界面时就少了一点加载资源的时间,把CPU消耗重心放在了实例化和初始化上。
第二步,在第一种方法的基础上,打开界面时CPU还是消耗太严重,那么就将UI实例化和初始化也提前到游戏开始前。只是实例化和初始化后,对UI界面进行了隐藏,当需要他出现时,再显示出来,而不再重新实例化,当关闭时,也同样只是隐藏而不是销毁。这样一来在打开和关闭时,只消耗了少量CPU在展示和隐藏上。
现在项目大都使用 AssetBundle 来做资源,但也有部分使用 Unity3D 的本地打包机制,这些prefab在Unity3D中有Preload的功能,在平台设置里这个功能,可以把需要预加载的Prefab加入到列表中去。它会将这些Prefab在进入APP或者说打开应用展示LOGO界面时进行预加载。在APP初始化时,预加载了指定的Prefab,CPU消耗在启动页面上,对于使用Resources.Load接口的加载整体效果不错。
最后,所有的预加载,都会出现另一个问题,CPU集中消耗带来的卡顿。预加载并没有削减CPU,CPU消耗的总量并没有发生变化。总体需要加载的图片数是不变的,实例化的元素数不变,以及初始化程序需要消耗的时间也不变,所有消耗总量是不变的。我们只是把它们这些消耗分离了或者说提前了,拆分到了各个时间碎片里去,让人感觉不到一瞬间有很大的CPU消耗。所以如果我们将这些预加载,集中在了某个位置,比如全部集中在游戏开始前,或者进度条的某个位置,也同样会有强烈的卡顿感,因为CPU在这个点进行了集中的消耗。
-
为什么要对UI图集进行Alpha分离?
我们对UI图集的压缩是减少APP包体大小的一部分,这也是减少内存使用量的一个比较有效方法,内存减少的同时对CPU也会降低些消耗。UI图集的压缩好处很多,但同样也会引起些问题,当我们对图集进行压缩后,在屏幕上显示的效果却不尽如人意,模糊,锯齿,线条等劣质的画面出现。这是因为我们在使用压缩模式ECT或PVRTC时将透明通道也一并压缩进去了,导致了渲染的扭曲,因此我们需要把透明通道alpha分离出来单独压缩。这样就既可以压缩图集,达到缩小内存目的,图像显示又不会太失真。
-
如何分离UI图集的Alpha呢?
UGUI由于是内部集成的,Alpha分离在Unity3D的UGUI中已经帮你完成了
-
为什么要拆分UI字体?
项目中字体其实占了很大的空间,如果有几个不同的字体一起展示在屏幕上,会消耗较大的内存。字体很多时候不可避免,但需要规范和整理,并且也需要优化。我们需要更快的性能效率,拆分字体会让加载字体的速度更快,让场景加载的速度更快。
-
如何拆分UI字体?
我们的解决方案是把字体中的常用字拆出来,另外生成一个字体文件,让字体文件变小,内存变少,最终使得加载变快。
比如在登陆场景中,我们只需要几个数字和字母,所以我们大可以从字体中提取数字和26个字母成立一个新的字体在场景中应用,这样就省去了大的字体的加载。
Scroll View 使用在类似背包的界面中非常常见,会有巨量的元素存在在窗口中进行渲染,所以在生成和滑动时,会消耗大量的CPU来重构Mesh,进而导致游戏运行缓慢,出现卡顿现象。这是由于我们前面在UGUI源码剖析中介绍过的元素属性上的改变将导致网格的重构,如果不断移动则每帧都需要重构,导致大量的CPU浪费。
要优化这种情况,就必须对滚屏菜单组件进行改造,将原来策略中所有元素都必须一次性实例化的问题,改为只实例化需要显示的实例数量。然后在拖动滑动的期间,实时判断是否有有UI元素被移出画面,这样的元素可以重复利用,将他们填补到需要显示的位置的上去,再对该单位元素的属性重新设置,我们需要的元素信息,让它展现为在该位置需要显示的元素的样子。
- UGUI中当元素需要对颜色进行改变时,UGUI是通过改变顶点的颜色来实现颜色的变化的。改变当前元素的顶点颜色,然后需要将它们重新合并到整块的Mesh里去,因为不能直接从原来合并好的Mesh上找到当前的顶点位置,所以需要一次整体的合并重构Mesh。
- 元素改变了 alpha 则会更糟糕,由于改变 alpha 的效果无法通过改变顶点的颜色来实现,于是就需要拆分出一个另外的材质球来进行渲染,通过对材质球的参数改变来实现 alpha 的效果。这样做不但重构了Mesh,还多出来个材质球,就相当于多一个Drawcall,效率消耗相当大。
- 倘若在动画里,每一帧都对UGUI的颜色和Alpha进行改变,那么UGUI每一帧都会对Mesh进行重构一次,并且每帧都生成新的材质球来实现 alpha 的透明效果。这样做消耗了大量的CPU运算,通常使得UI界面在运行动画时效率特别低下,即使拆分动静分离也无济于事。
- 如何对此做优化呢?我们不希望在UI颜色改变时,导致Mesh重构,这样动画中消耗掉太多CPU,那么我们就自己建一个材质球,提前告诉UGUI:我们使用自己的特殊的材质球进行渲染。当颜色动画对颜色和 alpha 更改时,我们直接对我们自定义的材质球进行颜色和 alpha 的改变。这样UGUI就不需要重构Mesh了,因为把渲染的工作交给了新的材质球,而不是通过 UGUI 设置顶点颜色和新材质球来达到效果。
- 如何操作?
首先,我们需要把UGUI的Shader下载下来。
然后,建立一个自己的材质球,并且材质球里使用下载下来的UGUI的Shader。
再次,把这个材质球放入Image或RawImage的Material上去,与Image或RawImage绑定。
接着,写个类比如class ImageColor继承MonoBehaviour,里面有个public 的颜色变量,比如public Color mColor,类里面只干一件事,在update里一直判断是否需要更改颜色,如果颜色被更改,就把颜色赋值给Material。
最后,把动画文件中的颜色部分从更改Image或RawImage的颜色变为更改 ImageColor 的颜色变量。这样UGUI颜色动画在播放时,不会直接去改变 Image 或 RawImage 的颜色,改变的是我们创建的 ImageColor 的颜色。
通过 ImageColor 来改变材质球属性,最后达到不重构Mesh的效果。切换元素的贴图时也一样可以做到不重构的效果,由于贴图更换会导致重构,为了达到不重构的目的可以给一个自定义材质球的并且更换材质球中的贴图。
不过要注意下,因为启用了自定义的材质球,所以Drawcall就提高了,因为每个材质球都会单独增加一次Drawcall。并且当 alpha 不是1的时候,会与原有的UGUI产生的材质球的透贴形成不同的渲染排序,原因是当两张透贴放在一起渲染时,alpha混合会导致渲染排序混乱而前后不一致。所以使用时要小心谨慎,权衡利弊用在恰当的地方将发挥大的功效,用在不恰当的地方则事倍功半。
这个半透明物体的排序问题,归根结底是无法写入深度数据问题,是3D渲染中无法彻底解决的问题。我们会在后面的渲染管线与图形学章节中详细介绍。这里解决半透明排序问题,可以通过改变自定义的 Shader 中的渲染次序(RenderQueue)来解决。
- UI的展示与关闭动作最常见,需要查看界面时打开,结束了关闭。但打开和关闭会消耗一定的CPU,打开时需要实例化和初始化,关闭需要销毁GameObject。这些是CPU消耗在实际项目中的消耗量巨大。
- 对于关闭和打开的CPU消耗的优化这里有几个策略可寻,
1.前面提过利用碎片时间的预加载,会让展示速度更加快。
2.在关闭时隐藏节点,打开时再显示所有节点。
3.移出屏幕。移出屏幕并不会让CPU消耗全部消失,但会减少GPU在这个界面上的消耗。当需要显示时再移入屏幕,有时候移入后进行初始化回到原来的状态也是必要的。
4.打开关闭时,设置UI界面为其他的层级Layout,使得其排除在相机渲染之外,当需要展示时再设置回UI层级。
上述中 2、3、4方法相同点是,都是用内存换CPU,关闭界面时不减少内存,只减少了CPU的消耗。不同点是,方法2 在关闭期间CPU消耗比方法3的更少,在打开时CPU消耗比方法3 的却更多。因为在显示所有节点的同时,UI网格需要重构,而移出屏幕则不需要重构网格。
方法3 和方法4都使用了相同的原理。只是方法3 用坐标去做摄像机的渲染排除,而方法4 则用层级Layout去做摄像机的排除操作。方法3和4 在CPU消耗上会更少,不过也要注意它们在关闭的同时也需要关闭Update更新程序,以减少不必要的消耗。
- 对象池,即对象的池子。对象池里寄存着一些废弃的对象,当计算机程序需要该种对象时,可以向对象池申请,让我们对废弃的对象再利用。
- 对象池的规则是,当需要对象时向对象池申请对象,对象池从池子中拿出以前废弃的对象重新‘清洗’下(重置下)给出去,如果对象池也没有可用对象,则新建一个放入给出去,当对象用完后,把这些废弃的对象放入对象池以便再利用。
- 对象池的方法,本质是用内存换CPU的策略。我们在UI界面中,时常会需要不断跳出不同的物体。这时实例化和销毁UI物体是逻辑中消耗最大的,物体被不断新建出来,又不断被销毁。CPU大部分浪费在了实例化和销毁上,渲染只占了很小一部分比重。这时运用对象池就能解决大部分浪费的问题,将要销毁的实例对象,放入对象池并移出屏幕或隐藏,当需要他们时再放出来重新初始化。
- 对象池是个用内存换CPU的方法,它用内存付出代价来换取CPU的效率。不过使用的不恰当的话也会引起不少内存问题的,因此对象池最好是要用在重复利用率高的对象上。这里总结了几条对象池运用的经验:
- 当程序中有重复实例化并不断销毁的对象时需要使用对象池进行优化。重复实例化和销毁操作会消耗大量CPU,在此类对象上使用对象池的优化效果极佳,相反如果在很少或较少做重复和销毁操作的对象上使用对象池,则会浪费内存,得不偿失。
- 每个需要使用对象池的对象都需要继承对象池的基类对象,这样在初始化时可以针对不同对象做重载,区别对待不同类型的对象。让不同对象的初始化方法根据各自的情况分别处理。
- 销毁操作时使用对象池接口进行销毁。在销毁物体时要使用对象池提供的销毁接口,让对象池来决定是真销毁,还是只是隐藏对象。
- 场景结束时要及时销毁整个对象池,避免无意义的内存驻留。当场景结束后,在对象池内的物体,已经不再适合新的场景了,或者说面临的环境情况与旧场景不同时所以需要及时清理对象池,把内存空出来留给新场景使用。
-
为什么要关心UI贴图设置?
首先我们得知道,Unity3D会重置全部贴图格式。可以理解为,无论你是JPG,PNG,PSD等,只要放在Unity3D中,Unity3D会读取图片内容,然后重新生成一个自己格式的图,在引擎中使用的是自己生成的图和格式。因此在Unity3D中使用图片其实不必关心用什么格式的图,只要你做好内容就可以了,比如JPG是没有alhpa通道的,通常做透贴都是PNG,这些图形或颜色内容上的东西是我们需要关心的,其他的交给Unity3D就可以。
Unity3D中图片的设置也有很多讲究,因为关系到重新生成的图片的格式,最终将决定加载入引擎的是什么样格式的图片,所以我们不得不要研究下贴图的设置问题。
-
UI的选项的优化,我们可以通过写脚本的方式,把放入UI的贴图自动设置我们规定好的图片选项,辅助我们更改UI贴图设置,省去不少二次检查时间。例如以下这段,就是利用Unity3D的 Editor API 来自动设置UGUI的精灵图片。
要完全省去检查时间是不可能的,在实际项目中我们也不得不从头检查一遍所有贴图的设置情况,来确认是否是我们所期望的设置,不过工作量比以前少了很多,可靠性也增强了许多。
-
什么是内存泄露?
内存泄漏,简单来说就是由程序向系统申请内存,使用完毕后并没有将内存还给系统导致内存驻留或者浪费的过程。系统本身的内存是有限的,如果内存泄漏一直被调用,就会耗尽系统内存,最终导致奔溃。就像你一直向银行借钱不还一样,银行虽然一直容忍你的不道德行为但银行也是有底线的,最终会切断你的资金来源,一下子收回全部资金,到那时候你就崩溃了。计算机系统也是一样,他不会无限制的让程序申请到内存,当申请内存影响到系统运行时就会停止。
-
为什么会内存泄露?
游戏项目内存泄漏简单分两种,一种是程序上的内存泄漏,另一种是资源上的内存泄漏。虽然资源上的内存泄漏也跟程序有关,但跟程序上的自身内存块的内存泄漏相比,它主要是因为资源在使用后或不再使用时没有卸载的原因导致的。
程序上的内存泄漏主要是因为Mono的垃圾回收机制并没有识别“垃圾”的原因造成的。为什么会没有识别呢,根源还是在编程时的疏忽,在编程时一些不好的习惯,错误的想法,不清晰的逻辑,导致申请的内存或指向内存的引用,没有有效的释放,导致垃圾回收机制没能识别出释放此块内存的理由。 而资源上的内存泄漏,主要是因为人为的申请资源使用完毕后并没有释放,导致资源内存长期驻留在内存里。
程序上的内存泄漏,需要借助些工具,也可以从框架的角度建立有效的指针计数器来排查,可以说是属于预防型为主排查为辅。而资源上的内存泄漏,就完全是属于人为的过错或疏忽,关键是容易排查。资源内存泄漏,主要排查的内容就是,资源在不需要使用时,却仍然驻留在内存里的情况。
-
什么是垃圾回收机制?
Unity3D是使用基于Mono的C#作为脚本语言,它是基于Garbage Collection(简称GC)机制的内存托管语言。那么既然是内存托管,为什么还会存在内存泄漏呢?GC本身并不是万能的,GC能做的是通过一定的算法找到“垃圾”,并且自动将“垃圾”占用的内存回收,并且每次运行垃圾回收需会消耗一定量的CPU。
找“垃圾”的算法有两种,一种是用引用计数的方式,另一种是跟踪收集的方式。
引用计数,简单的说,就是当被分配的内存块地址赋值给引用时,增加计数1,相反当引用清除内存块地址时,减少计数1。当引用计数变为0时,表明没有人再需要此内存块了,所以可以把内存块归还给系统,此时这个内存块就是垃圾回收机制要找的“垃圾”。
另一个是跟踪收集,简单的说就是遍历一遍引用内存块地址的根变量,以及与之相关联的变量,对内存资源没有引用的内存块进行标记,标记为“垃圾”,在回收时还给系统。
-
为什么有了这么智能的垃圾回收机制,还会有内存泄漏呢?
首先引用计数的方式它很难解决对象之间相互循环引用的问题,导致引用计数时无法被释放。现代计算机语言中已经很少使用这种方式去做了,但在逻辑组件上或业务框架上有很多,因为这样做简单方便,比如C++智能指针就是这种方式。
比如 A类中有B类的实例变量,而B类中有A类的实例变量,现在有A,B两个实例,这时A的引用计数为2,B的引用计数也为2,当B变量被置NULL后,B的引用计数只减少了1,因为在A中还有一个,因此,只有当A的实例变量也被销毁时,B实例的引用计数才真正变为0。也就是说B类变量是否销毁的命运同时取决于A和B。
其次跟踪收集并不是万能的,很多时候会有环状的引用链存在,以及包括在编码时错误操作的泄漏,这些编码的泄漏问题在实际编码过程中是非常隐蔽且难以查找的,不少的泄露问题需要人工去检查引用变量是否释放引用,工作量比较巨大且繁琐,特别是程序侧的内存泄漏尤其难找。
比如常常会有 A类中有B,B类中有C,C类中有D,D类中有A。这种环装的链路,导致跟踪收集比较困难,当C类实体设置为NULL后,B中依然有C,B设置为NULL后,A中依然有B,进而导致B中依然有C。这种就像‘命运共同体’似的环状引用链,导致跟踪收集的垃圾回收机制在被调用时的效果并不明显。
因此垃圾回收并不是万能的,即使有垃圾回收也一样会存在内存泄露问题。如果想避免内存泄露,至少在建立框架或架构时就应该对此有足够的考虑,对基础组件应该更加严谨,在这基础之上再对编程规范进行严格的把控,即使是这样在排查时也要保持足够的耐心和细心。
资源侧的内存泄漏对游戏项目来说量级上比较大,大到几百MB甚至几个G,不过万幸的是相对程序侧来说资源侧的内存泄漏查找相对比较容易。下面介绍一些关于Unity3D内存运作,泄漏排查,预防泄漏的经验,来帮助大家在实际项目中针对内存泄漏理解,排查,和预防。
-
Unity3D内存是如何运作的?
Unity3D中C#起初使用Mono做为虚拟机(VM,和Java一样都是虚拟机托管)运行在各大平台上,也就是说C#代码只要一份就够了(准确的来说应该是IL即中间语言是同一份的),各大平台的Mono需要各自制作一份来应对各系统的执行接口,简单说也就是说Unity3D通过Mono来跨平台解析并运行C#代码,在Android系统上App的lib目录下存在的libmono.so文件,就是Mono在Android系统上的实现。
C#代码通过Mono这个虚拟机解析和执行,当需要的内存自然由Mono来进行分配管理。只是Mono的堆内存大小在运行时是只会增加而不会减少的。可以将Mono内存堆理解为一个内存池,每次C#向Mono内存的申请堆内存,都会在池内进行分配,释放的时候也是归还给池里去,而不是释放归还给操作系统。假如某次分配,发现池里的内存不够了,则会对池进行扩建,即向操作系统申请更多的内存扩大池以满足该次以及后面更多的内存分配。需要注意的是,每次对池的扩建都是一次较大的内存分配,每次扩建都会将池扩大6-10MB左右。
分配在Mono堆内存上的都是程序上需要的内存块,例如静态实例以及这些实例中的变量和数组、类定义数据、虚函数表,函数和临时变量更多得则使用栈来存取。Unity3D的资源则不同,当它被读取进来时是通过Unity3D的C++层,分配在Native堆内存上的那部分内存,与Mono堆内存是分开来管理的。
Mono通过垃圾回收机制(Garbage Collect,简称GC)对内存进行回收。前面我们说了当Mono需要分配内存时,会先查看空闲内存是否足够,如果足够的话则直接在空闲内存中分配,否则Mono会扩容,在扩容之前Mono会进行一次垃圾回收(GC)以释放更多的空闲内存,如果GC之后仍然没有足够的空闲内存,这时Mono才会向操作系统申请内存扩充堆内存。
除了空闲内存不足时Mono会自动调用GC外,我们也可以在代码中主动调用GC.Collect()来手动进行GC。不过问题是GC本身是个比较消耗CPU计算量的过程,不仅如此,由于GC会暂停那些需要Mono内存分配的线程(C#代码创建的线程和主线程),因此无论是否在主线程中调用GC都会导致游戏一定程度的卡顿,需要谨慎调用。
由于各种原因Unity3D后来不再完全依靠Mono了,而另寻了一个解决方案那就是IL2CPP,Unity3D将C#翻译成IL中间语言后再翻译成C++以解决所有问题。那么翻译成C++语言内存就不托管了吗?不是的。内存依然托管,只是这次由C++编写VM来接管内存,不过这个VM只是内存托管而已,并不解析和执行任何代码,它只是个管理器。
IL2CPP与Mono的区别在什么地方呢?区别在于Mono只将C#翻译为IL中间语言,并把中间语言交给VM去解析和执行,VM的工作既要解析又要执行,这样的话Mono要针对不同平台执行IL程序就需要为每个平台定制一个单独的VM。IL2CPP则是把C#代码翻译为IL中间语言后又再继续翻译为C++代码,对于不同平台来说每次翻译的C++代码必须针对当前平台的API做出些变化,也就是说IL2CPP在不同平台下需要对不同平台的接口进行改造。与Mono针对不同平台拥有不同的VM相比,IL2CPP只是在翻译时改造了不同平台的接口代码,显而易见IL2CPP对程序员来说维护的工作量减少了很多。不仅仅只是程序员维护的工作量少了,在IL2CPP翻译完成后的编译时,使用的是平台本身都各自拥有的C++编译器,用各自平台的C++编译器进行编译后就可以直接执行编译内容无需再通过VM,因此IL2CPP相对Mono的效率会更高一些。
-
资源内存泄漏
-
资源内存泄漏就是Native内存泄漏,与程序内存泄漏不一样,资源内存泄漏都是因为加载后没有释放造成的,也有在逻辑中拷贝了一份资源但没有在使用完释放的情况。基本上都是疏忽大意造成的,除非完全不知道需要卸载。
这里介绍两种寻找资源内存泄漏的技巧:
1) 通过资源名来识别。 即在美术资源(如贴图、材质)命名的时候,就将其所属的游戏状态放在文件名中,如某贴图叫做bg.png,在房间中使用,则修改为Room_bg.png。 这样在Profile工具里一坨内存资源里面,混入了一个Room大头的资源,可以很容易地识别出来,也方便利用程序来识别。 这个方法但也不是万能的,因为在项目制作过程当中,一张图需要被用到各个场景中去,很可能也不只一两个,有时甚至四五个场景中都会用,只用前缀来代替使用场景的指定,很多时候也会造成另一种误区。 甚至由于项目的复杂度扩展到一定程度,包括人员更替,在检查资源泄漏时,用前缀来判断使用场景点不太靠谱,因为你根本就不知道这张图在哪使用了。所以说技巧只能辅助你,并不是说一定能有效。 2) 我们可以通过Unity提供的接口Resources.FindObjectsOfTypeAll()进行资源的Dump. 可以根据需求Dump贴图、材质、模型或其他资源类型,只需要将Type作为参数传入即可。 Dump成功之后我们将这些信息结果保存成一份文本文件,这样可以用对比工具对多次Dump之后的结果进行比较,找到新增的资源,那么这些资源就是潜在的泄漏对象,需要重点追查。
在平时项目中,我们找到这些泄漏的资源的方法,最直观的方法,就是在每次游戏状态切换的时候,做一次内存采样,并且将内存中的资源一一点开查看,判断它是否是当前游戏状态真正需要的。这种方法最大的问题,就是耗时耗力,资源数量太多眼睛容易看花看漏。
现在市面上比较有名的Unity3D项目优化工具UWA的GOT,它会逐帧记录资源内存和Mono堆内存的使用情况,并且可以在快照之间进行相互比较,得出新增或减少的资源名称。有了内存快照之间的对比就可以大大加快了我们查找内存泄漏的问题。
另外在Github上有一个在Editor下可以对内存快照进行比较的工具。内存快照进行比较的工具
- UI贴图质量区分对待。
针对高低端不同的机型,分别使用不同的两套UI贴图,其中一套是针对高端机型的,无任何压缩,无缩小,
另一套是针对低端机型的,对UI贴图进行了压缩,且缩小了UI贴图的大小。
我们会在下面详细的讲如何在游戏中用NGUI和UGUI无缝的切换高低质量的UI画面。
- 特效使用情况区分对待。
针对高低端不同的机型,分别使用不同的特效,或者非关键部位不使用特效。
特效在项目中使用的最常见也最频繁,高质量的特效能直接导致低端机型的卡顿,因此在低端机型中更换低质量的特效,或者甚至在非关键部位不使用特效。
- 阴影使用情况区分对待。
针对高低端不同的机型,分别使用不同质量的阴影,或者不使用阴影。
场景中的模型物体越多,面数就越多,实时阴影的计算量与渲染量就越多。
在低端机型上保持顺畅是第一,如果能不使用阴影那是最好的,直接省去了阴影的计算。
但假如一定要使用,那我们也有办法减低阴影计算和渲染的消耗。
下面是针对高低端机型对阴影处理的几个方法:
方法1,用Unity3D提供的阴影渲染设置接口,QualitySettings.shadowResolution 设置渲染质量,QualitySettings.shadows 设置有无和模式,以及 QualitySettings.shadowProjection 投射质量,QualitySettings.shadowDistance 阴影显示距离,QualitySettings.shadowCascades 接受灯光的数量等。
在高端机型上使用高质量阴影渲染设置,而在中端机型上使用中端阴影渲染设置,在低端机型上使用低端阴影渲染设置,甚至关闭阴影渲染设置。
方法2,关闭传统的阴影渲染设置,使用简单的底部圆形黑色阴影面片代替。
这样就省去了阴影计算的CPU消耗,而且还能看见阴影在底部。
用一些简单的阴影面片替换了阴影计算的CPU消耗,用内存换了CPU。
方法3,关闭部分模型的阴影计算。
使用简单的圆形面片代替阴影在真实感上效果还是差了点,如果我们既需要实时的阴影效果,又不想消耗过多的CPU,怎么办?
我们可以对部分模型使用简单的阴影面片代替,对另一部分比较重要的模型使用实时阴影渲染。
这就需要我们将场景中的所有可被计算阴影的模型集中起来管理。
我们将场景中的Render全部收集起来,把需要实时阴影计算的模型打开Render.receiveShadows选项,对另一些不需要实时阴影计算的模型关闭Render.receiveShadows选项,并选择使用简单阴影模型代替。
- 抗噪声,LOD,像素光数量使用情况区分对待。
针对高低端不同的机型,分别使用不同质量的抗锯齿效果和不同级别的LOD效果,或者直接不使用抗锯齿和LOD效果。
Unity3D渲染设置专门有针对抗噪声,LOD,像素光数量限制的选项,我们可以在程序里调用 QualitySettings.antiAliasing,QualitySettings.lodBias,QualitySettings.maximumLODLevel,QualitySettings.pixelLightCount等。
可以使用这些接口在高中低端机上分别进行设置,让不同性能的机子拥有不同画质的设置。
- 整体贴图渲染质量区分对待。
针对高低端不同的机型,分别使用不同的贴图渲染质量。
Unity3D有对贴图渲染质量的设置,QualitySettings.masterTextureLimit,这个API默认是0,就是不对贴图渲染进行限制,假如设置为1,就是渲染1/2大小的画质,相当于压缩了所有要渲染的贴图的大小至原先的1/2大小,假如设置2,就是1/4画质,以此类推。
对所有贴图进行渲染限制,是个大杀器,能直接让CPU和GPU消耗降下来,但画质也遭到了毁灭性的打击,我们需要它在低端机型上发挥作用,但也要谨慎使用。
区分高低端
1. Apple,毕竟IOS的型号是有限的,我们可以把其中一些型号的机子归类为高端,其中一些型号的机子归类为中端,另一些型号的机子归类为低端。Unity3D中的API有可以用的接口,例如 UnityEngine.iOS.Device.generation == UnityEngine.iOS.DeviceGeneration.iPhone6,等以此类推就能实现区分Apple机子高低端的情况。
2. Android 等其他机型,由于机子型号太多,我们可以用内存大小,系统版本号,屏幕分辨率大小,平均帧率等因素判断是高端机还是低端机。
比如,3G内存或以上为高端机,1G或以下肯定是低端,其他为中端。
又比如,Android 7.0以上为高端,Android 5.0以下都是低端,其他为中端。
如上描述通过一些简单的规则将高低端机型区分开来。
3. 我们也可以通过平均帧率来判定高低端机型。
在游戏中加入统计平均帧率的程序,将机型型号和平均帧率发送给服务器,由服务器记录各机型型号的平均帧率,再统计出来一份报表给客户端,客户端根据这份统计报表决定,哪些机型型号是高端设备,哪些是低端设备。
最后再通过高低端设备画质不同的方法,来优化高低端设备的游戏流畅度。
实时切换高清和标清的UI
1. 一套UI图复制成两套图。
一套高清一套标清,高清(HD) Prefab指向高清图,(标清SD)Prefab指向低清图。
这里NGUI和UGUI的方法不同。
NGUI需要制作两个Atlas prefab,再通过修改内核将Atlas实时更换掉,也可以复制并制作另一个SD UI Prefab只改变Atlas部分的指向标清画质的Atlas。
UGUI稍微复杂一点,但原理差不多,虽然不能实时改变图集来切换高清画质和标清画质,但也可以通过制作一个SD Prefab来达到高清和标清切换的目的。
步骤是,首先复制所有图片到SD文件夹,并加上前缀sd,这样好辨认,然后复制一个相同UI Prefab命名为SD UI Prefab,再把复制过来的SD UI Prefab里的图都换成SD里的图。这样高清和标清UI Prefab都有相同的逻辑,只是指向的图不同而已。
2. 程序在选择SD还是HD时,只要需要关注Prefab名的不同。
Prefab名字在高清和标清之间只是前缀不一样,高清的前缀HD或者没有前缀,而标清的文件名前缀统一设置为SD,所以加载时很容易用前缀名区分开来。
3. 最后,NGUI和UGUI制作SD Prefab的流程可以通过写一个脚本程序一键搞定,就不用再麻烦手动一个个复制一个个修改了。
在开发过程中,我们只要维护好HD UI Prefab就可以了,在打包前用脚本一键构建SD UI Prefab能节省不少时间,提高不少效率。
这样一来,在制作过程中,我们不用再关心SD的事情,完全可以只把注意力和精力集中在做好高清的UI上,关于标清SD的问题,脚本程序已经帮我们全部搞定。
最后总结:两套图一高一低,需要维护两套,使用脚本程序工具根据HD的Prefab生成SD的Prefab。
- 充分利用图集空间。
在我们大小图拼接在一起制作成图集时,尽量不要让图集空出太多碎片空间。
碎片空间怎么来的呢,基本上由大图与大图拼接而来,因为大图需要大块的拼接空间,所以会有几张大图拼接在一起形成图集的情况,导致很多浪费的空白空间在图集内。
我们要把大图拆分开来拼接,或者把大图分离出去不放入图集内,而使用单独的图片做渲染。
在拼接时,大图穿插小图,让空间更充分的利用。
- 图集大小控制。
假设我们图集的大小不加以控制,就会形成例如 2048x2048 甚至 4096x4096 的图。
这会导致什么问题呢,在游戏加载UI时异常的卡顿,而且由于卡顿的时间过长内存消耗过快,导致糟糕的用户体验甚至崩溃。
我们需要规范图集大小,例如我们通常规定图集大小标准在1024x1024,这样不仅在制作时要考虑让大小图充分利用空白空间,也让UI在加载时,只加载需要的图集,让加载速度更快。
- 图片的拼接归类。
在没有图片拼接归类的情况下,通常会在加载UI时加载了一些不必要的图集,导致加速速度过慢,内存消耗过大的问题。
比如背包界面的一部分图片,放在了大厅图集里,导致,在加载大厅UI时,也把背包界面的图集一并加载了进来,导致加速速度缓慢,内存飙升。
我们要人为的规范他们,把图集分类,例如,通常我们分为,常用图集(里面包含了一些各个界面都会用到的常用图片),功能类图集(比如大厅界面图集,背包界面图集,任务界面图集等),链接类图集(链接两种界面的图集,比如只在大厅界面与背包界面都会用的,特别需要拆分出来单独成为一张图集)
我们优化图集拼接的最终目的是,减少图集大小,减少图集数量,减少一次性加载图集数量,让游戏运行的更稳,更快。
- 什么是GC?为什么要优化GC?
GC(garbage collection)就是垃圾回收机制。前面在内存泄漏章节中对垃圾回收机制做过详细的介绍,这里再简单介绍下。
在游戏运行的时候,数据主要存储在内存中,当游戏的数据在不需要的时候,存储当前数据的内存就可以被回收以再次使用。
内存垃圾是指当前废弃数据所占用的内存,垃圾回收(GC)是指将废弃的内存重新回收再次使用的过程。
在进行垃圾回收(GC)时,会检查内存上的每个存储变量,然后对每个变量检查其引用是否处于激活状态,如果变量的引用不处于激活状态,则会被标记为可 回收,被标记的变量会在接下去的程序中被移除,其所占的内存也会被回收到堆内存中。
所以GC操作是一个相当耗时的操作,堆内存上变量或者引用越多则其检查的操作会更多,耗时也更长。
引用变量的多少和层数,只是GC耗时的其中一个因素。其他关键的耗时因素,还有内存分配和申请系统内存的耗时,回收内存的耗时以及垃圾回收(GC)接口 被调用的次数,都是非常关键的GC耗时因素。
- 内存分配和申请系统内存是如何影响耗时的
1)Unity3D内部有两个内存管理池:堆内存和堆栈内存。堆栈内存(stack)主要用来存储较小的和短暂的数据,堆内存(heap)主要用来存储较大的和存储时间较长的数据。
2)Unity3D中的变量只会在堆栈或者堆内存上进行内存分配,变量要么存储在堆栈内存上,要么处于堆内存上。
3)只要变量处于激活状态,则其占用的内存会被标记为使用状态,则该部分的内存处于被分配的状态。
4)一旦变量不再激活,则其所占用的内存不再需要,该部分内存可以被回收到内存池中被再次使用,但是并不是释放,这样的操作就是内存回收。处于堆栈上的内存回收及其快速,处于堆上的内存并不是及时回收的,此时其对应的内存依然会被标记为使用状态。
5) 垃圾回收主要是指堆上的内存分配和回收,Unity3D中会定时对堆内存进行GC操作。
6) Unity3D中堆内存只会增加,不会减少,也就是当堆内存不足时只会向系统申请更多内存,而不会空闲时还给系统,除非应用结束重新开始。
7) Unity3D在堆内存不足时会向系统申请新的内存以扩充堆内存,这种向系统申请新内存的方式是比较耗时的
8)堆内存中的申请与回收导致大量的碎片内存,而当大块的内存需要使用时,这些碎片内存却无法用于大块内存需求,此时就会引起一个耗时的操作,就是向系统申请更多的内存,申请的次数越多越频繁,GC的耗时也就越多
9) 不断频繁的调用垃圾回收(GC)接口,每次调用都会重新检查所有内存变量是否被激活,并且标记需要回收的内存块并且在后面回收,这样就在逻辑中产生了很多不必要的检查,和并不集中的销毁导致的内存命中率下降,最终导致浪费了宝贵的CPU资源
10) GC带来的问题是堆内存的碎片化,堆内存碎片会造成两个结果,一个是游戏占用的内存会越来越大,一个是GC会更加频繁地被触发
- 如何主动减少GC的消耗?
主要有三个操作会触发垃圾回收:
1) 在堆内存上进行内存分配操作而内存不够的时候都会触发垃圾回收来利用闲置的内存;
2) GC会自动的触发,不同平台运行频率不一样;
3) GC可以被强制执行。
特别是在堆内存上进行内存分配时内存单元不足够的时候,GC会被频繁触发,这就意味着频繁在堆内存上进行内存分配和回收会触发频繁的GC操作。
在Unity3D中,值类型变量都在堆栈上进行内存分配,而引用类型和其他类型的变量都在堆内存上分配。
所以值类型的分配和释放,在其生命周期后会被立即收回,例如函数中的临时变量Int,其对应函数调用完后会立即回收。
而引用类型的分配和释放,是在其生命周期后或被清除后,在GC的时候才回收,例如函数中的临时变量List,在函数调用结束后并不会立刻被回收,而是要等到下次GC时才被回收至堆内存。
大体上来说,我们可以通过三种方法来降低GC的影响:
1)减少GC的运行次数;
2)减少单次GC的运行时间;
3)将GC的运行时间延迟,避免在关键时候触发,比如可以在场景加载的时候调用GC。
我们可以采用三种策略:
1)对游戏进行重构,减少堆内存的分配和引用的分配。更少的变量和引用会减少GC操作中的检测个数从而提高GC的运行效率。
2)降低堆内存分配和回收的频率,尤其是在关键时刻。也就是说更少的事件触发GC操作,同时也降低堆内存的碎片化。
3)我们可以试着测量GC和堆内存扩展的时间,使其按照可预测的顺序执行。当然这样操作的难度极大,但是这会大大降低GC的影响。
- 缓存变量,达到重复利用的目的,减少不必要的内存垃圾。
比如,Update或OnTriggerEnter中使用 MeshRenderer meshrender = gameObject.GetComponent()来处理模型渲染类,就会造成不必要的内存垃圾。
我们可以在Start或Awake中先缓存起来,然后在Update或OnTriggerEnter中使用。这样我们已经缓存的变量内存一直存在在那,而不会被反复的销毁和分配。
优化前
void OnTriggerEnter(Collider other)
{
MeshRenderer meshRenderer = gameObject.GetComponent<MeshRenderer>();
ExampleFunction(meshRenderer);
}
优化后
private MeshRenderer mMeshRenderer;
void Start()
{
mMeshRenderer = gameObject.GetComponent<MeshRenderer>();
}
void OnTriggerEnter(Collider other)
{
ExampleFunction(mMeshRenderer);
}
- 减少逻辑调用。
堆内存分配,最坏的情况就是在其反复调用的函数中进行堆内存分配,例如Update()和LateUpdate()函数这种每帧都调用的函数,这会造成大量的内存垃圾。
我们要想方设法减少逻辑调用,可以利用时间因素,对比是否改变的情况等将Update和LateUpdate中的逻辑调用减少到最低程度。
例如,用时间因素,决定是否调用逻辑的案例
优化前,每一帧都在调用逻辑。
void Update()
{
ExampleFunction(transform.position.x);
}
优化后,调用逻辑的间隔延迟到1秒。
private float timeSinceLastCalled = 0;
private float delay = 1f;
void Update()
{
timSinceLastCalled += Time.deltaTime;
if(timeSinceLastCalled > delay)
{
ExampleFunction();
timeSinceLastCalled = 0f;
}
}
例如,用对比情况,决定是否调用逻辑的案例
优化前,每帧都在调用逻辑。
void Update()
{
ExampleFunction(transform.position.x);
}
优化后,只在坐标X改变的情况下才调用逻辑。
private float previousTransformPositionX;
void Update()
{
if(transform.position.x != previousTransformPositionX)
{
ExampleFunction (transform.position.x);
previousTransformPositionX = transform.position.x;
}
}
通过这样细小的改变,我们可以使得代码运行的更快同时减少内存垃圾的产生。案例中,我们只使用了几个变量的代价,却节省了很多CPU消耗。
- 清除链表,而不是不停的生成新的链表。
在堆内存上进行链表的分配的时候,如果该链表需要多次反复的分配,我们可以采用链表的clear函数来清空链表从而替代反复多次的创建分配链表。
优化前,每帧都会分配一个链表的内存进行调用。
void Update()
{
List myList = new List();
ExampleFunction(myList);
}
优化后,不再重复分配链表内存,而是将他清理后再使用。
private List myList = new List();
void Update()
{
myList.Clear();
ExampleFunction(myList);
}
- 对象池。
用对象池技术保留废弃的内存变量,在当重复利用时不再需要重新分配内存而是利用对象池内的旧有的对象。
因为即便我们在代码中尽可能地减少堆内存的分配行为,但是如果游戏有大量的对象需要产生和销毁,依然会造成频繁的GC。
对象池技术可以通过重复使用对象来降低堆内存的分配和回收频率。
对象池在游戏中广泛的使用,特别是在游戏中需要频繁的创建和销毁相同的游戏对象的时候,例如枪的子弹这种会频繁生成和销毁的对象。
- 字符串。
在C#中,字符串是引用类型变量而不是值类型变量,即使看起来它是存储字符串的值的。
这就意味着字符串会造成一定的内存垃圾,由于代码中经常使用字符串,所以我们需要对其格外小心。
C#中的字符串是不可变更的,也就是说其内部的值在创建后是不可被变更的。
每次在对字符串进行操作的时候(例如运用字符串的“加”操作),C#会新建一个字符串用来存储新的字符串,使得旧的字符串被废弃,这样就会造成内存垃圾。
我们可以采用以下的一些方法来最小化字符串的影响:
1) 减少不必要的字符串的创建,如果一个字符串被多次利用,我们可以创建并缓存该字符串。
例如项目中常用到的,将文字字符串存储在数据表中,然后由程序去读取数据表,从而将所有常用的字符串存储在内存里。
这样的话,成员们在调用字符串时就可以直接调用我们存储的字符串了,而不需要去新建一个字符串来操作。
2)减少不必要的字符串操作。
例如如果在Text组件中,有一部分字符串需要经常改变,但是其他部分不会,则我们可以将其分为两个部分的组件,对于不变的部分就设置为类似常量字符串即可。
优化前,每帧都会重新创建一个字符串来设置时间文字。
public Text timerText;
private float timer;
void Update()
{
timer += Time.deltaTime;
timerText.text = "Time:"+ timer.ToString();
}
优化后,不再操作字符串,而是赋值给文字组件显示,从而减少了内存垃圾。
public Text timerHeaderText;
public Text timerValueText;
private float timer;
void Start()
{
timerHeaderText.text = "TIME:";
}
void Update()
{
timerValueText.text = timer.ToString();
}
3)如果我们需要实时的操作字符串,我们可以采用StringBuilderClass来代替,StringBuilder专为不需要进行内存分配而设计,从而减少字符串产生的内存垃圾。
不过此类方法还是要选择性使用,因为在实际项目中,如果大量的使用StringBuilder,又会产生很多new的操作,导致内存垃圾的产生,同时原本工作量小的字符串操作,又会变成工作量大的StringBuilder函数调用。
所以只能在小范围特定区域使用,比如,特别频繁的操作字符串的情况下,不断增加,改变字符串的地方。例如,游戏中有对字符串逐步显示的需求,像写文章一样一个个或者一片片的显示,而不是全部一下子显示的需求时,用StringBuilder就恰到好处。
4)移除游戏中的Debug.Log()函数的代码.
尽管该函数可能输出为空,对该函数的调用依然会执行,该函数会创建至少一个字符(空字符)的字符串。
如果游戏中有大量的该函数的调用,这会造成内存垃圾的增加。
- 协程。
调用 StartCoroutine()会产生少量的内存垃圾,因为Unity3D会生成实体来管理协程。任何在游戏关键时刻调用的协程都需要特别的注意,特别是包含延迟回调的协程。
yield在协程中不会产生堆内存分配,但是如果yield带有参数返回,则会造成不必要的内存垃圾,例如:
yield return 0;
由于需要返回0,引发了装箱操作,所以会产生内存垃圾。这种情况下,为了避免内存垃圾,我们可以这样返回:
yield return null;
另外一种对协程的错误使用是每次返回的时候都new同一个变量,例如:
while(!isComplete)
{
yield return new WaitForSeconds(1f);
}
我们可以采用缓存来避免这样的内存垃圾产生:
WaitForSeconds delay = new WaiForSeconds(1f);
while(!isComplete)
{
yield return delay;
}
- Foreach循环。
在Unity3D 5.5以前的版本中,在foreach的迭代中都会生成内存垃圾,主要来自于其后的装箱操作。
每次在foreach迭代的时候,都会在堆内存上生产一个System.Object用来实现迭代循环操作。
如果游戏工程不能升级到5.5以上,则可以用for或者while循环来解决这个问题。
- 函数引用。
函数的引用,无论是指向匿名函数还是显式函数,在Unity3D中都是引用类型变量,这都会在堆内存上进行分配。
特别是System.Action匿名函数在项目中使用的特别频繁,匿名函数调用完成后都会增加内存的使用和堆内存的分配。
具体函数的引用和终止都取决于操作平台和编译器设置,但是如果想减少GC最好减少函数的引用,特别是匿名函数。
- LINQ和常量表达式。
由于LINQ和常量表达式以装箱的方式实现,所以在使用的时候最好进行性能测试。
如果可以尽量使用其他方式代替LINQ,以家少LINQ对内存垃圾的增加。
- 主动调用GC操作。
如果我们知道堆内存在被分配后并没有被使用,我们希望可以主动地调用GC操作,或者在GC操作并不影响游戏体验的时候(例如场景切换,或读进度条的时候),我们可以主动的调用GC操作System.GC.Collect()。通过主动的调用,我们可以主动驱使GC操作来回收堆内存。让体验不好的时间段放在察觉不到的地方,或者不会被明显察觉的地方。