Overdraw
Unity Overdraw(超绘)是指在渲染过程中绘制了超过一次相同像素的现象。不透明物体穿透看到透明物体或当多个UI元素重叠时,每个像素都需要被多次绘制。这种绘制超出了渲染所需的最小像素数,因此被称为Overdraw。
都有哪些元素会导致Overdraw
UI元素:当UI元素叠加在一起时,它们可能会导致overdraw。
Shader:某些shader可能需要绘制多次,导致overdraw。
模型:当模型的面数很高时,会导致过多的像素被绘制出来。
粒子系统:如果粒子的数量过多或者设置不当,会导致渲染时过多的像素被绘制出来。
地形系统:如果地形细节设置过高,也会导致过多的像素被绘制出来。
镜头后处理:如果在镜头后处理中使用了过多的效果(例如bloom、全屏模糊等),也会导致overdraw。
其他高耗性能的特效:例如实时阴影、全局光照等特效,也会导致过多的像素被绘制出来。
可以制定哪些规范控制Overdraw
批处理数量:规定每个批次的三角形数量的上限,以控制批处理的数量和渲染次数。
合并网格:规定是否需要合并多个网格为一个大网格,以减少批处理数量和Overdraw。
合并材质:规定是否需要将多个物体使用相同材质的合并为一个批次,以减少Overdraw。
剔除不可见面:规定是否需要使用背面剔除(Backface Culling)技术来剔除不可见的面,以减少Overdraw。
控制透明度:规定透明度的使用和控制,以避免过度的Overdraw。
合理使用特效:规定特效的使用和控制,以避免对Overdraw的负面影响。
优化Shader:规定Shader的使用和优化,以避免过多的Overdraw。
UI元素overdraw的优化
UI元素的overdraw指的是在屏幕上绘制UI元素时,同一区域内多次绘制造成的性能浪费。
优化UI元素overdraw的关键是尽可能减少绘制次数,合并绘制操作以及避免不必要的绘制。
- 合并UI元素:将多个UI元素合并成一个较大的UI元素,可以减少绘制次数,从而减少overdraw。可以使用Unity的Canvas Group组件来将多个UI元素合并到同一个Canvas上。
- 使用遮罩:当UI元素在屏幕上显示时,可以使用遮罩来隐藏不必要的部分,减少绘制次数。
- 减少透明度:如果UI元素具有透明度,可以尝试减少透明度或使用不透明的材质来减少overdraw。
- 使用UI组件的优化选项:Unity的UI组件中提供了一些优化选项,如使用静态batching、动态batching、合并材质等,可以减少overdraw。
- 减少UI元素数量:尽可能减少UI元素的数量,只使用必要的UI元素。
- 调整UI元素的层级:将UI元素的层级设置得尽可能低,可以减少overdraw。
- 使用UI画布裁剪:UI画布裁剪可以避免不必要的绘制,可以减少overdraw。
- 使用TextMeshPro:减少老文本的outline、shadow等其他效果的重绘。
Shader overdraw的优化
Shader的overdraw指的是在屏幕上绘制过程中,同一区域内多次执行相同的着色器代码造成的性能浪费。
优化Shader overdraw的关键是尽可能减少着色器执行次数,减少绘制次数,合并绘制操作以及避免不必要的绘制。
- 合并Mesh:将多个Mesh合并成一个大的Mesh,可以减少绘制次数和Shader overdraw。
- 减少透明度:如果物体具有透明度,可以尝试减少透明度或使用不透明的材质来减少overdraw。
- 使用级联遮挡剔除(Occlusion Culling):使用级联遮挡剔除可以在绘制之前剔除不可见的物体,减少overdraw。
- 调整渲染顺序:将最先绘制的物体放在最前面,可以减少overdraw。
- 使用Substance材质:Substance材质可以动态生成纹理,可以减少overdraw。
- 使用GPU instancing:GPU instancing可以复制和绘制一个Mesh的多个实例,可以减少绘制次数和Shader overdraw。
- 调整渲染距离:在远距离处,可以使用简单的材质代替复杂的材质,减少Shader overdraw。
模型overdraw的优化
模型的overdraw指的是在屏幕上绘制过程中,同一区域内多次绘制相同的模型造成的性能浪费。
优化模型overdraw的关键是尽可能减少绘制次数,合并绘制操作以及避免不必要的绘制。
- 合并Mesh:将多个Mesh合并成一个大的Mesh,可以减少绘制次数和overdraw。
- 使用Level of Detail(LOD):使用LOD可以在远距离处使用简单的模型代替复杂的模型,减少overdraw。
- 使用级联遮挡剔除(Occlusion Culling):使用级联遮挡剔除可以在绘制之前剔除不可见的物体,减少overdraw。
- 调整渲染顺序:将最先绘制的模型放在最前面,可以减少overdraw。
- 调整渲染距离:在远距离处,可以使用简单的模型代替复杂的模型,减少overdraw。
- 优化模型:优化模型的几何形状,可以减少overdraw。
- 减少模型数量:尽可能减少模型的数量,只使用必要的模型。
粒子系统overdraw的优化
粒子系统的overdraw指的是在屏幕上绘制过程中,同一区域内多次绘制相同的粒子造成的性能浪费。
优化粒子系统overdraw的关键是尽可能减少绘制次数,合并绘制操作以及避免不必要的绘制。
- 调整透明度:减少粒子的透明度或使用不透明的材质,可以减少overdraw。
- 使用GPU Instancing:使用GPU Instancing可以复制和绘制多个实例,可以减少绘制次数和overdraw。
- 使用Level of Detail(LOD):使用LOD可以在远距离处使用简单的粒子效果代替复杂的粒子效果,减少overdraw。
- 调整渲染顺序:将最先绘制的粒子效果放在最前面,可以减少overdraw。
- 减少粒子数量:减少粒子的数量,只使用必要的粒子。
- 调整渲染距离:在远距离处,可以使用简单的粒子效果代替复杂的粒子效果,减少overdraw。
地形系统overdraw的优化
地形系统的overdraw指的是在屏幕上绘制过程中,同一区域内多次绘制相同的地形块造成的性能浪费。
优化地形系统overdraw的关键是尽可能减少绘制次数,合并绘制操作以及避免不必要的绘制。
- 合并地形块:将多个地形块合并成一个大的地形块,可以减少绘制次数和overdraw。
- 使用Level of Detail(LOD):使用LOD可以在远距离处使用简单的地形代替复杂的地形,减少overdraw。
- 使用级联遮挡剔除(Occlusion Culling):使用级联遮挡剔除可以在绘制之前剔除不可见的地形块,减少overdraw。
- 使用纹理合并:将多个纹理合并成一个大的纹理,可以减少绘制次数和overdraw。
减少地形块数量:尽可能减少地形块的数量,只使用必要的地形块。
镜头后处理overdraw的优化
镜头后处理的overdraw指的是在屏幕上绘制过程中,同一区域内多次绘制相同的像素造成的性能浪费。
优化镜头后处理的overdraw的关键是尽可能减少绘制次数,合并绘制操作以及避免不必要的绘制。
- 减少后处理效果数量:减少后处理效果的数量,只使用必要的后处理效果
调试工具
- srp:Edtior场景的菜单里面选择OverDraw显示
- urp:window->Analysis->Rendering Debugger
- Frequently Use菜单勾选Overdraw
- unity2017 overdraw数量可视化工具:https://github.com/Nordeus/Unite2017/tree/master/OverdrawMonitor
urp相比srp的调试可靠性要高很多,srp不进行z-testing处理,很难看出真实的overdraw。
ui
滚动视图
当我们在做类似排行榜那些需要滚动的菜单的时候,因为滚动视图里面的内容在滑动导致界面变脏了,导致所在的Canvas需要进行重绘,滚动过于频繁很容易就导致掉帧,对此建议在滚动视图加一个独立的Canvas,这样滚动的时候就只刷新该滚动视图的内容,不需要全部重新重绘,但是这样会因为所属的Canvas变动了,合批会被打断,增加draw call。
如果滚动列表里面条目足够的多的时候,建议使用循环列表来实现条目的显示。
文本
UGUI默认的文本,在渲染字体的时候,很容易出现面片有多余的部分不需要渲染,导致OverDraw问题,而且在使用描边、阴影等特殊效果,会重复渲染一个面片来实现效果,更容易OverDraw,而去增加面数增加渲染开销。
TextMeshPro(TMP)使用Signed Distance Field(有向距离场,SDF)作为其首选文本渲染管线,使其可以在任意尺寸和分辨率下清晰的渲染文本。使用一系列自定义的着色器来提升SDF文本渲染的能力后,TMP可以简单的通过修改材质属性来动态地改变视觉效果,例如,放大、外边框、软阴影等,而不需要重新绘制一个面片来实现效果,并且可以通过创建材质预设来保存这些效果,在以后重新调TMP基于SDF可以显著提升抗锯齿效果,并且实现一些特效也十分简单。
sign distance function(符号距离函数),简称SDF,又可以称为定向距离函数(oriented distance function),在空间中的一个有限区域上确定一个点到区域边界的距离并同时对距离的符号进行定义:点在区域边界内部为正,外部为负,位于边界上时为0。也就是说SDF记录着当前像素点距离某一个区域的最小距离(这个区域我们可以理解为文字,一就是说可以假设像素值为0的点在区域内,像素值为255的点在区域外)。由于像素距离已经计算好了,那么在绘制文本面片的时候,面片大小不是一样的,无效的透明区域非常的少,这样可以有效的减少OverDraw。
shader
- 如果有多个参数用于同一个地方,可以用vector表示完参数,那就用vector来替换多个fixed、half、float的使用,以减少cpu传值次数。
- 能用u3d自带函数实现的功能不要自己写,因为U3D帮我们处理了平台差异。
- 非URP的时候使用grabpass的时候,尽量带上名称以复用,不指定名称使用grabpass,每次都会重新抓屏一次。
- 能在定点着色器阶段做的计算,不要放到片段着色器阶段。
- 尽量减少分支和循环。
- 对于一些靠摄像机远的物体,可以多写一个Subshader将片段着色器的计算丢到顶点着色器(Shader LOD)
- 对于使用比较频繁的贴图,启用Mipmap,这样远处的物体可以减少渲染开销。
后处理
后处理在Unity中可能会导致性能开销的原因有几个:
- 像素着色器运算量大:后处理通常需要在每个屏幕像素上进行复杂的像素着色器计算。这些计算可能包括模糊、光照、色彩校正等效果,这些操作需要在每个像素上执行,并且可能需要多次迭代。这样的复杂计算会增加GPU的工作量,导致性能降低。
- 多次渲染:后处理通常需要使用额外的渲染纹理来存储中间结果。这意味着在渲染过程中需要多次进行纹理读取和写入操作,这对于GPU来说是昂贵的。多次渲染会增加内存带宽和纹理传输的开销,导致性能下降。
- 屏幕空间操作:大多数后处理效果都是在屏幕空间进行操作的,这意味着需要处理整个屏幕的像素。屏幕空间操作会增加计算量,并且需要额外的内存和带宽来存储和处理屏幕空间的数据。
- 过度使用图像特效:在一个场景中同时应用多个复杂的后处理效果可能会导致性能问题。每个效果都需要额外的计算和渲染资源,多个效果叠加在一起可能会导致性能开销成倍增加。
为了减少后处理的性能开销,可以考虑以下优化方法:
- 减少后处理效果的数量和复杂度:只保留必要的后处理效果,并确保它们的计算量尽可能小。
使用低分辨率渲染目标:如果后处理效果不需要高分辨率的输出,可以使用较低分辨率的渲染目标来减少计算量和内存带宽。 - 合并多个后处理效果:如果可能,将多个后处理效果合并为一个效果,以减少渲染和计算的开销。
- 像素着色器优化:在像素着色器中进行的计算量较大,因此需要着重优化。你可以尽量减少复杂的计算和采样操作,使用更简单的算法来实现目标效果。此外,使用预计算的纹理或数据来减少计算量也是一种有效的优化方法。
- 分辨率缩放(Resolution Scaling):将渲染目标的分辨率降低,然后将结果缩放回原始分辨率。这种技术可以减少后处理的计算量,同时在视觉上保持较高的质量。通过调整缩放因子,你可以权衡性能和质量之间的平衡。
- 近似算法(Approximation):某些后处理效果可以使用近似算法来减少计算量。例如,高斯模糊可以使用较少的迭代次数或较小的采样半径来近似实现,以减少计算量。
- 降低采样率(Downsampling):在某些情况下,可以降低渲染目标的采样率,然后使用更低分辨率的纹理进行后处理。这样可以减少内存带宽和计算量,同时在视觉上保持相对较好的效果。
- 优化纹理读写:在后处理过程中,对纹理的读取和写入操作可能会成为性能瓶颈。你可以考虑使用更小的纹理尺寸、压缩格式或减少纹理的数量,以减少内存带宽和纹理传输的开销。
- 屏幕空间剔除(Screen-Space Culling):在屏幕空间中进行剔除操作可以减少不可见像素的处理。例如,在辉光效果中,只对亮度较高的像素进行处理,而忽略较低亮度的像素。
- 前向渲染(Forward Rendering):在某些情况下,使用前向渲染而不是延迟渲染可以提高性能。前向渲染在屏幕空间操作方面较为高效,因为它避免了延迟渲染中的多次渲染通道。
场景
- 场景过大的,使用四叉树把场景导出,加载的时候默认加载一个没有物件的场景,进入场景后再动态加载物件。
动态合批
unity内置动态合批
- 条件
- 材质相同,但如果是材质实例,也无法合批
- 支持不同网格的合批
- 超过32k的index时,会开始一次新的动态合批
- 单个网格最多300个顶多,900个定带你属性。(顶点属性指的是在片段着色器使用到的position等)
- 如果shader中用到了网格的position、normal和uv,则最多300顶多。
- 如果shader中用到了网格的position、normal、uv0、uv1和tangent,则最多180顶多。
- 原理
- 在进行场景绘制之前将所有的共享同一材质的顶点信息变换到世界空间中
- 然后通过一次Draw call绘制多个模型,达到合批的目的
- 并且计算的模型顶点数了不宜太多,否次CPU串行计算耗费的时间太长会造成场景渲染卡顿
- 缺点
- 模型顶点变化的操作是由CPU完成的,所以会带来一些CPU的性能消耗
- 打断
- 位置不相邻且中间夹杂着不同材质的其他物体,这样会导致渲染顺序不一样
- GameObject之间如果有不一致的缩放不能进行合批
- 用于lightmap的物体与没有lightmap的物体
- 使用Multi-pass Shader的物体会禁用合批
- Unity的Forward Rendering Path中如果一个GameObject接受多个光照会为每一个per-piexel light 产生多余的模型提交和绘制,从而附加了多个Pass导致无法合批
静态合批
unity内置静态合批
- 条件
- 场景物体需要标识为 static,使用相同的材质会合批在一起。
- 静态合批,每次最大顶多上限2^16,超过了会生成新的网格。
- 改变Renderer.material将会造成一份材质的拷贝,因此会打断批处理,应该使用Renderer.sharedMaerial来保证材质的共享状态。
- 使用了光照贴图和没使用光照贴图的不会合批在一起。
- 光照烘培时
- Unity会自动地提取这些共享材质的静态模型的Vertex buffer合Index buffer
- 将这些模型的顶点数据变化到世界空间下
- 存储在新构建的大Vertex buffer和Index buffer中。
- 记录每一个子模型的Index buffer数据在构建的大Index buffer中的起始和结束为止。
- 渲染时
- 一次性提交整个合并模型的顶点数据
- 引擎的场景管理系统判断各个子模型的可见性
- 设置一次渲染状态
- 调用多次Draw call分别绘制每一个子模型
- 误区
- 实际上看到draw call未减少,在编辑器内由于计算方法区别,draw call数量会显示减少
- 意义
- 合批的顶点数据等不需要分多批次提交
- 合批后的所有模型的顶点都已经放到了世界空间下,不需要再次执行顶点变换操作
- 材质不需要切换,SubMesh共享材质。
- 缺点
- 包体变大,由于绘制次数没有减少,模型都合成一个,那么会将合并后的模型渲染n次,并且占用内存会增大。
插件-MeshBacker
- 可以在运行前将网格就合并
- 如果场景非常大的时候,消耗内存就会比较大,所有的网格合并到了一个整体,无法进行遮挡剔除的优化。
- 可以进行贴图合并,有效的减少渲染次数。
- 在运行的时候,新加了模型,可以用代码进行合批。
LOD
- 会增加内存开销。
GPU实例化
- 每次合批数量上限是500个对象。
- 在大批量同样物件需要渲染的时候使用,但是需要使用相同材质,相同mesh。
- 不可以用于骨骼网格,常量缓冲区不可超过64k。
动画优化
- 蒙皮骨骼动画改顶点动画,空间换时间
- Animator LOD,远处动画降频
- Animation Instancing,
物理优化
- 减少MeshCollider的使用,可以添加多个BoxCollider等基础的碰撞器来处理
贴图优化
纹理资源可以说是几乎所有游戏项目中占据最大内存开销的资源。一个6万面片的场景,网格资源最大才不过10MB,但一个2048x2048的纹理,可能直接就达到16MB。因此,项目中纹理资源的使用是否得当会极大地影响项目的内存占用。
纹理尺寸
一般来说,纹理尺寸越大,则内存占用越大。所以,尽可能降低纹理尺寸,如果512x512的纹理对于显示效果已经够用,那么就不要使用1024x1024的纹理,因为后者的内存占用是前者的四倍。
纹理格式
纹理格式是研发团队最需要关注的纹理属性。因为它不仅影响着纹理的内存占用,同时还决定了纹理的加载效率。一般来说,我们建议开发团队尽可能根据硬件的种类选择硬件支持的纹理格式,比如Android平台的ETC、iOS平台的PVRTC、Windows PC上的DXT等等。
DXT压缩
DXT是PC端常用的压缩算法,质量较低,细节上会有丢失
压缩算法介绍
DXT压缩将图片拆分成4x4的小块,每一块取极值的2种颜色,剩下的颜色取插值、共有00,01,10,11四种颜色值。
压缩后单个颜色是16位,是RGB565、
不支持透明通道,原图RGB24,16个格子,需要24x16=384位
压缩后,两个16位颜色+16个插值,162+216=64位,压缩比例1/6
DXT3和DXT5压缩
在DXT1的基础上,支持透明图片,额外使用64位数据来保存透明通道值,压缩比例1/3。
DXT3压缩:每一个像素点使用4位保存alpha值,透明值较为粗糙
DXT5压缩:透明通道也单独取插值,有2个8位极值,每一个像素点使用3位插值,82+316=64位
ETC压缩
ETC是Android平台通用的压缩格式,质量较低
ETC:不支持透明通道,图片宽高必须是2的整数次幂
ETC2:是ETC的扩展,支持透明通道,且图片宽高只要是4的倍数即可
压缩算法介绍
也是将图片分成4x4的小块进行压缩,其中每一块会分成两半,用1位表示是横着还是竖着拆分。
取2种颜色,从2半中各取一个,分为individual模式还是 differential模式,用1位表示取色模式。
individual模式:取两个RGB444的颜色,适用于两边颜色差异大的情况
differential模式:取RGB555+RGB333的颜色,其中,第二个块的颜色是偏移值,适用于两边颜色差异不大的情况,精度更高。
压缩时会生成一个全局的映射表,两个子块各需要3位来确定使用哪一行的数据。
对于每一个像素点,使用2位数据表示使用这一行的哪一个modifier,去除一个偏移值。例如表中(0,0)格子的-8,偏移值就是(-8,-8,-8),在子块颜色的基础上加上偏移值得到当前像素的颜色。
压缩前:16x24=384位
压缩后:1+1+24+3x2+2x16=64位,压缩比例1/6
ETC1 不支持透明通道问题
在Android平台上,对于使用OpenGL ES 2.0的设备,其纹理格式仅能支持ETC1格式,该格式有个较为严重的问题,即不支持Alpha透明通道,使得透明贴图无法直接通过ETC1格式来进行储存。对此,我们建议研发团队将透明贴图尽可能分拆成两张,即一张RGB24位纹理记录原始纹理的颜色部分和一张Alpha8纹理记录原始纹理的透明通道部分。然后,将这两张贴图分别转化为ETC1格式的纹理,并通过特定的Shader来进行渲染,从而来达到支持透明贴图的效果。该种方法不仅可以极大程度上逼近RGBA透明贴图的渲染效果,同时还可以降低纹理的内存占用,是非常推荐的使用方式。
目前已经有越来越多的设备支持了OpenGL ES 3.0,这样Android平台上你可以进一步使用ETC2甚至ASTC,这些纹理格式均为支持透明通道且压缩比更为理想的纹理格式。如果你的游戏适合人群为中高端设备用户,那么不妨直接使用这两种格式来作为纹理的主要存储格式。
ASTC压缩
ASTC是Android和IOS平台下的一种高质量压缩方式,支持Android5.0和iPhone6以上机型
压缩算法介绍
也是分块压缩,一块128位,块的大小很灵活,有4x4,6x6,8x8,12x12等多种大小。支持LDR、HDR、2D和3D纹理。每个块也有端点对endpoints,端点对不一定是RGBA的,也可以只用其中部分通道,比如RG通道,这样可以对法线贴图进行更好的压缩。
BISE算法:例如有5个数字,值为0,1,2,正常存储需要2x5=10位。但是实际上只有3^5=243<256种情况,可以使用8位表示这些情况,即使用8位就可以表示5个值为0,1,2的整数。
BlockMode:平面数、权重范围、权重网格的大小
Part:分区数量
ConfigData、MoreConfigData:每个端点对的端点模式
对于块内颜色分布较为复杂的情况,分析块内颜色的分布,并做分区,分别存储其对应的endpoints;对于块内的像素取值时先定位分区,再计算在其在分区内的颜色。
对块内每个像素存储一个插值weight,但是weight的数量可以比像素少。对于规格较大的块,例如12x12,只能存储4x6的权重网格。解码时,权重网格会被双线性放大到块的大小。
块大小选择
块越大,压缩质量越差,但是图片越小
法线贴图:尽量选择4x4,避免丢失过多数据
细节处的贴图:选择4x4或者6x6,否则会丢失细节
一般的贴图:选择6x6或8x8
无关紧要,但是尺寸特别大的图:可以考虑8x8,10x10,12x12,不然打包出来太大
色阶问题
由于ETC、PVRTC等格式均为有损压缩,因此,当纹理色差范围跨度较大时,均不可避免地造成不同程度的“阶梯”状的色阶问题。因此,很多研发团队使用RGBA32/ARGB32格式来实现更好的效果。但是,这种做法将造成很大的内存占用。比如,同样一张1024x1024的纹理,如果不开启Mipmap,并且为PVRTC格式,则其内存占用为512KB,而如果转换为RGBA32位,则很可能占用达到4MB。所以,研发团队在使用RGBA32或ARGB32格式的纹理时,一定要慎重考虑,更为明智的选择是尽量减少纹理的色差范围,使其尽可能使用硬件支持的压缩格式进行储存。
Mipmap功能
Mipmap旨在有效降低渲染带宽的压力,提升游戏的渲染效率。但是,开启Mipmap会将纹理内存提升1.33倍。对于具有较大纵深感的3D游戏来说,3D场景模型和角色我们一般是建议开启Mipmap功能的,但是在我们经常会发现部分UI纹理也开启了Mipmap功能,这其实就没有必要的,绝大多数UI均是渲染在屏幕最上层,开启Mipmap并不会提升渲染效率,反倒会增加无谓的内存占用。因此,建议详细检测开启Mipmap功能的资源是否为UI资源。
Read & Write
一般情况下,纹理资源的“Read & Write”功能在Unity引擎中是默认关闭的。但是,我们仍然在项目深度优化时发现了不少项目的纹理资源会开启该选项。对此,我们需要密切关注纹理资源中该选项的使用,因为开启该选项将会使纹理内存增大一倍。
各个平台打包选择
正常不需要进行改动,Unity会默认根据情况进行一个比较友好的选择。
格式 | 内存占用 | 透明 | 二次方大小 | 建议使用场合 |
---|---|---|---|---|
RGBA32 | 1 | 有 | 无需 | 清晰度要求极高 |
RGBA16+Dithering | 1/2 | 有 | 无需 | UI、头像、卡牌、不会进行拉伸放大 |
RGBA16 | 1/2 | 有 | 无需 | UI、头像、卡牌、不带渐变、颜色不丰富、需要拉伸放大 |
RGB16+Dithering | 1/2 | 无 | 无需 | UI、头像、卡牌、不透明、不会进行拉伸放大 |
RGB16 | 1/2 | 无 | 无需 | UI、头像、卡牌、不透明、不渐变、不会进行拉伸放大 |
RGB(ETC1)+Alpha(ECT1) | 1/4 | 有 | 需要二次方,长宽可不一样 | 尽可能默认使用,在质量不满足时再考虑使用上边格式 |
RGB(ETC1) | 1/8 | 无 | 需要二次方,长宽可不一样 | 尽可能默认使用,在质量不满足时再考虑使用上边格式 |
PVRTC4 | 1/8 | 无 | 需要二次方正方形,长宽一样 | 尽可能默认使用,在质量不满足时再考虑使用上边格式 |
unity纹理信息 https://docs.unity3d.com/cn/2020.3/Manual/class-TextureImporterOverride.html
网格
网格资源在较为复杂的游戏中,往往占据较高的内存。
Normal、Color和Tangent
在项目中,Mesh资源的数据中经常会含有大量的Color数据、Normal数据和Tangent数据。这些数据的存在将大幅度增加Mesh资源的文件体积和内存占用。其中,Color数据和Normal数据主要为3DMax、Maya等建模软件导出时设置所生成,而Tangent一般为导入引擎时生成。
更为麻烦的是,如果项目对Mesh进行Draw Call Batching操作的话,那么将很有可能进一步增大总体内存的占用。比如,100个Mesh进行拼合,其中99个Mesh均没有Color、Tangent等属性,剩下一个则包含有Color、Normal和Tangent属性,那么Mesh拼合后,CombinedMesh中将为每个Mesh来添加上此三个顶点属性,进而造成很大的内存开销。正因如此,在开发时需要关注mesh冗余数据的资源。
引擎模块自身占用
引擎自身中存在内存开销的部分纷繁复杂,可以说是由巨量的“微小”内存所累积起来的,比如GameObject及其各种Component(最大量的Component应该算是Transform了)、ParticleSystem、MonoScript以及各种各样的模块Manager(SceneManager、CanvasManager、PersistentManager等)…
一般情况下,上面所指出的引擎各组成部分的内存开销均比较小,真正占据较大内存开销的是这两处:WebStream 和 SerializedFile。其绝大部分的内存分配则是由AssetBundle加载资源所致。简单言之,当您使用new WWW或CreateFromMemory来加载AssetBundle时,Unity引擎会加载原始数据到内存中并对其进行解压,而WebStream的大小则是AssetBundle原始文件大小 + 解压后的数据大小 + DecompressionBuffer(0.5MB)。同时,由于Unity 5.3版本之前的AssetBundle文件为LZMA压缩,其压缩比类似于Zip(20%-25%),所以对于一个1MB的原始AssetBundle文件,其加载后WebStream的大小则可能是5~6MB,因此,当项目中存在通过new WWW加载多个AssetBundle文件,且AssetBundle又无法及时释放时,WebStream的内存可能会很大,这是需要时刻关注的。
对于SerializedFile,则是当你使用LoadFromCacheOrDownload、CreateFromFile或new WWW本地AssetBundle文件时产生的序列化文件。
对于WebStream和SerializedFile,你需要关注以下两点:
- 是否存在AssetBundle没有被清理干净的情况。开发团队可以通过Unity Profiler直接查看其使用具体的使用情况,并确定Take Sample时AssetBundle的存在是否合理;
- 对于占用WebStream较大的AssetBundle文件(如UI Atlas相关的AssetBundle文件等),建议使用LoadFromCacheOrDownLoad或CreateFromFile来进行替换,即将解压后的AssetBundle数据存储于本地Cache中进行使用。这种做法非常适合于内存特别吃紧的项目,即通过本地的磁盘空间来换取内存空间。
托管堆内存占用
对于目前绝大多数基于Unity引擎开发的项目而言,其托管堆内存是由Mono分配和管理的。“托管” 的本意是Mono可以自动地改变堆的大小来适应你所需要的内存,并且适时地调用垃圾回收(Garbage Collection)操作来释放已经不需要的内存,从而降低开发人员在代码内存管理方面的门槛。
但是这并不意味着研发团队可以在代码中肆无忌惮地开辟托管堆内存,因为目前Unity所使用的Mono版本存在一个很严重的问题,即:Mono的堆内存一旦分配,就不会返还给系统。这意味着Mono的堆内存是只升不降的。举个例子,项目运行时,在场景A中开辟了60MB的托管堆内存,而到下一场景B时,只需要使用20MB的托管堆内存,那么Mono中将会存在40MB空闲的堆内存,且不会返还给系统。这是我们非常不愿意看到的现象,因为对于游戏(特别是移动游戏)来说,内存的占用可谓是寸土寸金的,让Mono毫无必要地锁住大量的内存,是一件非常浪费的事情。
大多时候不必要的堆内存分配主要来自于以下几个方面:
- 高频率地 New Class/Container/Array等。研发团队切记不要在Update、FixUpdate或较高调用频率的函数中开辟堆内存,这会对你的项目内存和性能均造成非常大的伤害。做个简单的计算,假设你的项目中某一函数每一帧只分配100B的堆内存,帧率是1秒30帧,那么1秒钟游戏的堆内存分配则是3KB,1分钟的堆内存分配就是180KB,10分钟后就已经分配了1.8MB。如果你有10个这样的函数,那么10分钟后,堆内存的分配就是18MB,这期间,它可能会造成Mono的堆内存峰值升高,同时又可能引起了多次GC的调用。
- Log输出。建议对自身Log的输出进行严格的控制,仅保留关键Log,以避免不必要的堆内存分配。
关于代码堆内存分配的注意点还有很多,比如String连接、部分引擎API(GetComponent)的使用等等。
内存泄露
内存泄露是开发人员在项目研发过程中最常见也最不愿遇到的问题。就目前来看,大家对于判断项目是否存在内存泄露仍然存在一些误区:
- 误区一
我的项目进出场景前后内存回落不一致,比如进入场景后,内存增加40MB,出来后下降30MB,仍有10MB内存没有返回给系统,即说明内存存在泄露情况。 - 误区二
我的项目在进出场景前后,Unity Profiler中内存回落正常,但Android的PSS数值并没有完全回落(出场景后的PSS值高于进场景前的PSS值),即说明内存存在泄露情况。
检查资源的使用情况,特别是纹理、网格等资源的使用
在我们进行过的项目深度优化过程中,资源泄漏是内存泄露的主要表现形式,其具体原因是用户对加载后的资源进行了储存(比如放到Container中),但在场景切换时并没有将其Remove或Clear,从而无论是引擎本身还是手动调用Resources.UnloadUnusedAssets等相关API均无法对其进行卸载,进而造成了资源泄露。对于这种情况的排查相当困难,这是因为项目中的资源量过于巨大,泄露资源往往很难定位。
一般来说,同种场景或同一场景的资源使用应该是较为固定的,比如游戏项目中的主城场景或主界面场景。通过比较不同时刻同一场景的资源信息,可以快速帮你找到其资源使用的差异情况。这样,你只需判断这些“差异”资源的存在是否合理,即可快速判定是否存在资源泄露,已经具体的泄露资源。
除一些常驻资源外,不同类型的场景,其资源使用是完全不同的。比如,游戏中主城和战斗副本的资源,除少部分常驻内存的资源外,二者使用的绝大部分资源应该是不一致的。所以,通过比较两种不同类型的场景,你可以直接查看比较结果中的“共同资源”,并判断其是否确实为预先设定好的常驻资源。如果不是,则它很可能是“泄露”资源,需要你进一步查看项目的资源管理是否存在漏洞。
通过Profiler来检测WebStream或SerializedFile的使用情况
AssetBundle的管理不当也会造成一定的内存泄露,即上一场景中使用的AssetBundle在场景切换时没有被卸载掉,而被带入到了下一场场景中。对于这种情况,建议直接通过Profiler Memory中的Take Sample来对其进行检测,通过直接查看WebStream或SerializedFile中的AssetBundle名称,即可判断是否存在“泄露”情况。
通过Android PSS/iOS Instrument反馈的App线程内存来查看
Unity Profiler中内存回落正常,但Android的PSS数值并没有完全回落”是有可能的,这是因为Unity Profiler反馈的是引擎的真实分配的物理内存,而PSS中记录的则包括系统的部分缓存。一般情况下,Android或iOS并不会及时将所有App卸载数据进行清理,为了保证下次使用时的流畅性,OS会将部分数据放入到缓存,待自身内存不足时,OS Kernel会启动类似LowMemoryKiller的机制来查询缓存甚至杀死一些进程来释放内存。因此,并不能通过一两次的PSS内存没有完全回落来说明内存泄露问题。
推荐的测试方式是在两个场景之间来回不停切换,比如主城和战斗副本间。理论上来说,多次切换同样的场景,如果Profiler中显示的Unity内存回落正常,那么其PSS/Instrument的内存数值波动范围也是趋于稳定的,但如果出现了PSS/Instrument内存持续增长的情况,则需要大家注意了。这可能有两种可能:
- Unity引擎自身的内存泄露问题。这种概率很小,之前仅在少数版本中出现过。
- 第三方插件在使用时出现了内存泄露。这种概率较大,因为Profiler仅能对Unity自身的内存进行监控,而无法检测到第三方库的内存分配情况。因此,在出现上述内存问题时,建议先对自身使用的第三方库进行排查。
无效的Mono堆内存开销
目前,Unity所使用的Mono版本中存在一个较大的问题,即内存一旦分配,则不会再返回给系统。这就衍生出另外一个问题—— 无效的Mono堆内存。它是Mono所分配的堆内存,但却没有被真正利用上,因此称之为“无效”。
那么,我们应该如何避免或减少过多“无效堆内存”的分配呢?推荐做法如下:
- 避免一次性堆内存的过大分配。Mono的堆内存也是“按需”逐步进行分配的。但如果一次性开辟过大堆内存,比如New一个较大Container、加载一个过大配置文件等,则势必会造成Mono的堆内存直接冲高,所以对堆内存的分配需要时刻注意;
- 避免不必要的堆内存开销。
资源冗余
在大量项目中,95%以上的项目均存在不同程度的资源冗余情况。所谓“资源冗余”,是指在某一时刻内存中存在两份甚至多份同样的资源。
AssetBundle打包机制出现问题
同一份资源被打入到多份AssetBundle文件中。举个例子,同一张纹理被不同的NPC所使用,同时每个NPC被制作成独立的AssetBundle文件,那么在没有针对纹理进行依赖打包的前提下,就会出现该张纹理出现在不同的NPC AssetBundle文件中。当这些AssetBundle先后被加载到内存后,内存中即会出现纹理资源冗余的情况。对此,对相关AssetBundle的制作流程一定要进行检查。
资源的实例化所致
在Unity引擎中,当我们修改了一些特定GameObject的资源属性时,引擎会为该GameObject自动实例化一份资源供其使用,比如Material、Mesh等。以Material为例,我们在研发时经常会有这样的做法:在角色被攻击时,改变其Material中的属性来得到特定的受击效果。这种做法则会导致引擎为特定的GameObject重新实例化一个Material,后缀会加上(instance)字样。其本身没有特别大的问题,但是当有改变Material属性需求的GameObject越来越多时(比如ARPG、MMORPG、MOBA等游戏类型),其内存中的冗余数量则会大量增长。如下图所示,随着游戏的进行,实例化的Material资源会增加到333个。虽然Material的内存占用不大,但是过多的冗余资源却为Resources.UnloadUnusedAssets API的调用效率增加了相当大的压力。
一般情况下,资源属性的改变情况都是固定的,并非随机出现。比如,假设GameObject受到攻击时,其Material属性改变随攻击类型的不同而有三种不同的参数设置。那么,对于这种需求,我们建议你直接制作三种不同的Material,在Runtime情况下通过代码直接替换对应GameObject的Material,而非改变其Material的属性。这样,你会发现,成百上千的instance Material在内存中消失了,取而代之的,则是这三个不同的Material资源。
代码
- 减少foreach使用,会有gc,使用最原始的for或while。
- 减少游戏搜索接口的时候,例如FindObjectsOfType等,会有gc,可以将结果缓存起来,间隔刷新而不是每帧去获取。
- 在切换场景的时候手动触发GC回收,可以有效减轻游戏关键时刻体验时被动触发的GC回收消耗。
- 使用对象池,对资源进行回收循环利用,长时间不使用在回收。
- 携程里面使用
yield return null
,少使用yield return 0
会有装箱操作。 - 类型判断的时候使用类型的CompareTo函数,少用
==
比较运算符。 - 频繁去临时创建List的地方,重写一个struct结构的List,而不使用默认的List,减少GC。