不知不觉,以从事unity游戏开发将近十年了。一路前行,终会站在巨人的肩膀上。愿你我都安好。
直奔主题吧。知其然,更知其所以然。
从unity运行到看到游行画面的整个工作流来说,分为两个计算处理阶段。
CPU 计算处理阶段(应用阶段)
-
概述
- CPU 计算处理的阶段,借鉴 Shader 渲染管线流程,把它称为应用阶段。
- 应用阶段,首先从游戏一帧也就是一个 Update 说明。主要是 CPU 处理准备模型网格,模型材质信息阶段。然后将这些数据通过 draw call 发送给 GPU,完成一次 CPU 处理。
-
准备模型的过程
-
模型在场景的位置的计算
- 在这里,模型要通过物理碰撞,包括触发器和刚体组件的碰撞检测,有的还有物理材质,寻路网格的参与。
-
模型动画的计算
- 通过关键帧计算插值的补间骨骼位置,然后通过模型顶点在骨骼的权重进行顶点变化,这个过程也是复杂且性能要求高的。
-
遮挡剔除
- 这里包括相机视锥模型的剔除和遮挡剔除。
- 同时,为了减少优化模型顶点的合批的计算量,又出了个 LOD 技术,根据相机的远近切换模型精度,以减少计算量。
- 最终完成一帧内,摄像机内可见物体的所有模型网格。
-
-
优化处理
-
网格和 draw call
- 但是这些网格还不是最终的网格。对于 GPU 来说,一个网格和一个材质球就是一个 draw call,这个后面会说明。
- 所以要想 GPU 更快的计算,CPU 就要减少 draw call 的数量。那么使用同一材质球的各个模型怎么一个 draw call,即使用一个材质球完成,而不是一个个的模型使用相同的着色器去渲染呢?
-
动态合批和静态合批
- 这样动态合批和静态合批应运而生,这些合批的最终结果是将使用相同材质的模型合成一个大网格,这样将多个相同材质的网格合并成一个大网格使用同一个 CPU 计算准备好的材质球的所有数据信息一起推送到 GPU 完成一次 draw call。
- 至于为什么相同材质合并一个网格使用同一个着色器处理成一个 draw call,将在后面 GPU 计算处理阶段介绍。
-
局限性
- 但是这些优化还是有很多局限性。静态网格说白了就是在编辑器里游戏运行前将静态不变的场景物体合并成一个个多个批次的大网格,因而不适合运动形变物体对象。
- 动态合批正好适合动态物体,但是物体模型又有 300 顶点甚至更少顶点的限制。。。怎么办呢?GPU Instancing 也应运而生,能通过 GPU 实例化多个位置变换的物体,绕过 CPU 合批的局限性,但是不支持 SkinnedMeshRenderer 蒙皮骨骼。我是真的服了。。。Unity 感觉在一直补坑的道路上越走越远。
- 接着后面又出了个 GPU Skinning 技术,说是技术,其实就是在补坑。我是不明白不统一处理这些问题,简直反人类。
-
GPU 计算处理阶段(渲染阶段)
-
概述
- GPU 计算处理阶段,把它称为渲染阶段。
- 在经过 CPU 的应用阶段后,GPU 会处理 CPU 在一帧内计算处理的所有 draw call。一个 draw call 就是一个 GPU 处理一个网格的数据的一个流水线。这里的一个网格是 CPU 合批后的大网格数据。
-
处理 draw call
-
Shader 加载
- 要知道 GPU 处理逻辑就要知道整个处理流程。在 GPU 接到一个 draw call 指令后,会将材质球相关联的 shader 加载进 GPU。设置shader的状态(深度缓冲区,颜色缓冲区),还有就是数据(模型网格数据,纹理数据,变量初始化数据)
- 正常情况下 shader 会包含一个顶点函数(shader 中的定义:
#pragma vertex vert
)一个片元函数(shader 中的定义:#pragma fragment frag
),有的还有曲面着色器和几何着色器也对应不同的函数,就是在渲染管线里的顶点着色器和片元着色器。 - 即一个 shader 通常是包含两个着色器的,顶点函数对应顶点着色器,片元函数对应片元着色器。
-
纹理处理
- 将着色器加载完成后,GPU 会给着色器分配对应的纹理单元,用于存储纹理对象。
- 一个纹理对象可以是一个图片纹理(在 shader 中的定义:
_Texture("Texture", 2D)
),也可以是一组纹理数组(在 shader 中的定义:_TextureArray("Texture Array", 2DArray)
)对象。 - 加载完成后会将通过材质球绑定的纹理加载进来。
-
-
流水线计算处理
-
顶点着色器
- 首先通过顶点着色器,即调用顶点函数,此函数主要必须有的功能是将模型从模型空间转换到平面的裁切空间。
-
曲面着色器和几何着色器
- 然后如果有曲面着色器和几何着色器的就执行曲面着色器和几何着色器。
-
三角裁切,三角形遍历,光栅处理
- 执行完后,进行三角裁切。
- 三角形遍历,将模型裁切空间三角形映射到屏幕像素
- 光栅处理:深度测试、透明测试。
-
片元着色器
- 最终进行片元着色器,即调用片元函数,最终完成一个模型的处理。
-
-
完成 draw call 处理
- 最终完成一个个的 draw call 的处理。
从上面知识点反推。
一.首先画面渲染要经过顶点着色器和片元着色器
1.从着色器编程角度来说
在满足效果需求的情况下尽量使用fixed类型变量,然后能在顶点计算的的一定要在顶点进行计算。少使用if和分支语句。在shader编程中大部分逻辑都是数值运算的。还有个知识点就是纹理数组可以处理多张纹理切换,这个要比单张纹理设置要高效,因为每次设置纹理有可能处罚纹理切换(从内存重新绑定新纹理然后加载到gpu内存)
2.从顶点着色器角度。
减少顶点函数的调用,进一步说就是减少网格顶点数据。那么如何减少顶点数据呢。
减少顶点模型数据,首先从cpu出发,
1.Lod技术。根据摄像机远近,进行模型精度切换,进而减少模型顶点。
2.遮挡剔除技术。剔除摄像机视角看不到的物体模型。
3.相机视锥剔除。这个不在考虑范围内。因为unity默认优化掉了
3. 从 draw call 的角度来看:
draw call 优化的主要目的是优化 GPU 的性能。之所以单独列出 draw call,是因为每个 draw call 都会导致重新绑定着色器、重新设置渲染状态和重新绑定数据,即使多个 draw call 使用的是同一个材质也是如此。具体来说:
- 重新绑定着色器:每次 draw call 需要将当前使用的着色器程序绑定到 GPU,以便进行渲染。
- 重新设置渲染状态:包括设置深度测试、混合模式、剔除模式等渲染状态,确保渲染行为符合预期。
- 重新绑定数据:将顶点缓冲区、索引缓冲区等数据重新绑定到 GPU,以供渲染使用。
虽然在最新的图形 API(如 Vulkan 和 OpenGL)中,这些操作已经得到了优化,但这些优化依赖于硬件设备,导致技术不能通用。
-
减少 draw call:
减少 draw call 意味着减少材质的切换次数,并尽量将相同的网格数据一次性传递到 GPU,用一个 draw call 来渲染。这样可以减少着色器的重新加载、纹理单元的重新分配、渲染状态的重新设置和缓冲区的重新绑定,从而提升渲染效率。
衍生出的两种技术:
-
动态批处理(Dynamic Batching):
这种技术合并多个具有相同材质的动态物体的渲染调用。动态批处理特别适用于顶点数量少的对象(通常限制为 300 顶点以内)。它在粒子特效和 UGUI(Unity UI)中应用广泛。例如,在 UGUI 中,每张图片通常是一个长方形网格,整个 Canvas 的元素会合并成一个网格来减少 draw call 数量。
-
静态批处理(Static Batching):
静态批处理合并场景中静态物体的渲染调用。所有使用相同材质的静态物体会合并成一个大的网格,这样可以减少 draw call 的数量。根据材质的种类,一个场景可能会生成多个合并的大网格,每个网格都只需一个 draw call 来渲染。
在减少 draw call 的过程中,还衍生出了其他技术:
-
GPU 实例化技术(GPU Instancing):
GPU 实例化允许将多个相同的网格数据一次性传递给 GPU,由 GPU 进行变换计算。这种技术减少了 CPU 的工作量,特别是对于大量相同物体的场景,如草地或树木等。GPU 实例化不支持 SkinnedMeshRenderer(蒙皮网格渲染器),因此衍生出了 GPUSkinning 技术。
-
GPUSkinning 技术:
GPUSkinning 将顶点数据和骨骼数据传递给 GPU,让 GPU 在顶点着色器中通过权重计算顶点的变换坐标。这种技术显著减轻了 CPU 的负担,特别是在处理复杂的动画时,如角色动画。
-
计算着色器(Compute Shader):
计算着色器是一种用于执行并行计算任务的 GPU 程序。它不仅可以用于渲染计算,还可以进行一般计算任务,如骨骼变换和粒子系统模拟。使用计算着色器可以进一步减轻 CPU 的负担,并提高计算效率,因为它可以将大量的计算任务并行处理在 GPU 上。
4.从cpu角度
1. 物理碰撞优化
-
减少触发器和刚体的使用:
- 触发器:触发器用于检测物体之间的重叠而不产生物理响应。减少触发器的使用,尤其是在大量物体的场景中,可以减轻CPU的负担。仅在必要时使用触发器,并确保它们的用途明确。
- 刚体(Rigidbody):动态物体需要Rigidbody来进行物理计算。对于静态物体或不需要物理响应的物体,使用Collider而不是Rigidbody,以减少物理计算开销。
-
减少物理计算:
- 优化Rigidbody使用:对于不需要频繁更新的物体,将其设置为
isKinematic
,这样它们将不参与物理计算。对于不需要实时更新的物体,可以调整Collision Detection
模式为Discrete
而不是Continuous
。
- 优化Rigidbody使用:对于不需要频繁更新的物体,将其设置为
-
优化碰撞检测:
- 简化碰撞体:尽量使用简单的碰撞体(如BoxCollider、SphereCollider、CapsuleCollider)代替复杂的MeshCollider。复杂的碰撞体会增加物理计算的负担。
- 减少碰撞体数量:合并小物体的碰撞体到一个较大的碰撞体中,避免大量小的碰撞体影响性能。
-
分层碰撞检测:
- Layer Collision Matrix:使用Unity的Layer Collision Matrix来控制哪些层之间进行碰撞检测。对于不需要碰撞检测的层(例如UI层、背景层),可以取消碰撞设置,减少计算开销。
2. 减少模型顶点
-
LOD技术:
- 使用不同的细节级别:对于远离摄像机的物体使用低细节的LOD模型,减少渲染和计算负担。确保在场景中设置合适的LOD级别,并根据实际需求调整LOD切换的距离。
-
遮挡剔除技术:
- 利用Unity的遮挡剔除(Occlusion Culling):确保只渲染在摄像机视野内的物体,减少不必要的渲染和计算。通过
Occlusion Culling
设置可以提高性能。
- 利用Unity的遮挡剔除(Occlusion Culling):确保只渲染在摄像机视野内的物体,减少不必要的渲染和计算。通过
-
模型减面:
- 简化模型几何:减少模型的顶点数量和多边形数目,优化模型的复杂性。使用3D建模工具中的减面功能来减少模型的面数。
3. 静态合批
-
静态合批也是一个有效减少cpu计算的方法:
- 通过静态合批,将合批运算在编辑器里预先将模型合并计算好。然后将合批的大网格数据和相关信息存储起来。运行时直接使用。
4. 动画优化
-
减少关键帧:
- 优化动画数据:减少动画中的关键帧数量,通过合理设置关键帧间隔来减轻动画计算的负担。对于重复的动画动作,可以使用动画曲线来减少关键帧数量。
-
优化Animator Controller:
- 简化Animator状态机:减少Animator Controller中的状态和过渡,避免复杂的动画状态机影响性能。将频繁使用的动画进行简化处理。
5. 布料物理模拟
- 减少布料物理的使用:
- 优化布料模拟:布料模拟会消耗大量计算资源。在不需要动态布料模拟的情况下,考虑使用静态布料贴图或简化布料模拟的设置来减轻CPU负担。
6. 减少代码对象的实例化
- 对象缓存池(Object Pooling):
- 实现对象池:对于需要频繁创建和销毁的对象(如子弹、敌人),使用对象池技术来复用对象,避免频繁的内存分配和垃圾回收。这可以显著减少性能开销。
7. 导航网格优化
- 降低精度:
- 调整NavMesh精度:根据场景的复杂度和需求,降低NavMesh的精度。减少NavMesh的细节可以降低寻径计算的复杂性,从而提高性能。
- 使用NavMesh区域:将NavMesh划分为多个区域,减少单一区域的计算量,特别是在大型场景中。这有助于减少更新和计算开销。
总结
通过从多个角度进行优化,可以显著提高Unity游戏的性能。以下是一个综合优化的策略:
- 物理计算:减少触发器和刚体使用、优化碰撞体的数量和复杂性、利用Layer Collision Matrix进行分层碰撞检测。
- 模型和渲染:使用LOD技术、遮挡剔除、简化模型顶点。
- 动画:减少关键帧、简化Animator状态机。
- 布料模拟:减少布料物理模拟的使用。
- 对象管理:实现对象缓存池来减少对象实例化。
- 导航网格:调整NavMesh的精度和分区来优化寻径计算。
内存优化
内存优化的核心在于合理设置加载资源、去重资源以及优化代码。以下是针对各个方面的详细说明:
图片优化
-
使用合适的通道和压缩格式:根据需要选择图片的通道(如RGB、RGBA、单通道),并尽量使用压缩格式(如ETC2、ASTC、DXT等),这些格式能显著减少内存使用。
-
关闭不必要的读写选项:在Unity中,如果不需要在运行时对图片进行读写操作,应关闭Read/Write Enabled选项,防止纹理在内存中被重复存储。
-
根据需求关闭MipMap:对于UI元素或仅在特定尺寸显示的纹理,关闭MipMap生成可以节省内存。
-
调整图片大小:根据设备性能和实际需求合理调整纹理大小,避免使用过大的纹理导致内存浪费。
-
使用图集(Texture Atlas):将多个小纹理合并成一个大纹理,可以减少纹理切换(Texture Swaps),节省内存并提高性能。
模型网格优化
-
关闭不必要的读写选项:与图片优化类似,除非需要在运行时修改模型网格,否则应关闭Read/Write Enabled选项以节省内存。
-
优化模型质量:在模型导入阶段减少多边形数量,提高模型的优化程度,这些工作应在美术设计阶段完成。
-
控制骨骼数量:减少模型的骨骼数量,尤其是在性能要求高的设备上,这可以显著降低内存使用。
-
分离模型与材质:关闭模型材质的自动导入,将模型与材质分开处理。使用共享材质并通过打包资源实现按需加载,减少内存占用。
-
分离模型与动作:关闭动作的自动导入,利用Unity的状态机机制进行动作复用。在打包时将动作资源单独分离,按需加载,而不是一次性加载所有动作数据。
-
减少动画关键帧:通过减少关键帧数量来优化动画数据,降低内存使用。
音频优化
-
使用压缩音频格式:选择压缩的音频格式(如OGG、MP3)代替未压缩的格式(如WAV),可以显著降低内存占用。Unity支持多种音频压缩格式,应选择适合目标平台的格式。
-
合理设置音频加载类型:
- Decompress on Load:适用于小型音效,加载后解压缩存储在内存中,占用较多内存但无播放延迟。
- Compressed in Memory:适用于中型音效,压缩存储在内存中,减少内存占用但可能会有解压缩开销。
- Streaming:适用于大型背景音乐,直接从磁盘读取,内存占用最小但会有播放延迟。
-
优化音频剪辑长度:将长音频剪辑分割成较短片段,便于在不需要时轻松卸载或切换,减少内存占用。
资源去重
资源包依赖去重
资源去重的关键在于合理的资源打包逻辑。通常,游戏开发过程中使用资源包(如Unity的AssetBundle或Addressables)加载模式来管理资源。因此,资源去重的工作应该在资源打包阶段完成,而不是在加载时做判断。
网上说是在加载时做判断,这纯正时扯蛋。说这话的根本不明白打包加载原理。这里不赘述原理,会单独写文章。因为讲清楚篇幅会很大。要做到打包不重复资源的极致还是很麻烦的。加载也是。即使最新的Addressables原生插件也是要自己做依赖分析,控制那些资源要打包,这也是扯蛋。当你通读Addressables文档后会发现确实是这样。
如何进行资源去重
-
资源依赖分析:在打包之前,对所有资源进行依赖分析,找出哪些资源被多个资源依赖。
-
共享资源单独打包:根据依赖分析的结果,将所有被两个或多个其他资源依赖的共享资源单独打成一个包。这可以确保这些共享资源只被加载一次,完全去掉重复资源,避免内存浪费。即一个资源被两个或两个以上的其他资源依赖,那么这个资源一定单独打成一个包,就会完全去掉重复资源
-
资源本身去重:对于项目中在不同路径下存在的相同资源,可以通过获取资源的哈希值进行比对,找出并删除重复的资源文件,确保每个资源只在一个位置存在。
-
需要注意的是,即使使用Unity的Addressables插件,去重工作依然需要手动进行依赖分析和打包控制。Addressables主要简化了资源加载过程,但并不能自动解决资源的去重问题。需要开发者在打包时,通过详细的资源依赖分析,确定哪些资源需要单独打包,以避免资源重复。
烘焙场景灯光优化
- 优化光照贴图的大小和分辨率:在进行烘焙场景灯光时,应控制光照贴图的分辨率在合理范围内,避免不必要的高分辨率,从而节省内存。
代码优化
-
减少对象的频繁创建与销毁:通过使用对象池(Object Pooling)技术来重复利用对象,减少垃圾回收(GC)的频繁调用,优化内存使用。
-
内存管理:定期检查代码中的内存使用情况,清理不再使用的对象引用,防止内存泄漏。
通过合理设置加载资源、去重资源以及优化代码,可以有效地进行内存优化,减少游戏运行时的内存消耗,提高游戏性能。
UGUI 优化的核心原理
UI优化的核心是优化动态批处理(Dynamic Batching)。在Unity的UI系统(UGUI)中,优化UI性能的关键在于减少Draw Call的数量。Draw Call的数量通常取决于Canvas内的动态批处理效率,因此,优化Canvas的批处理是提升UI性能的关键。
理想的Canvas状态
在理想状态下,Canvas内的所有UI元素可以形成一个单独的动态批处理,从而只产生一个Draw Call。要实现这种理想状态,需要满足以下几个条件:
-
简单的网格结构:UI元素通常使用简单的矩形网格(Quad Mesh),这样有助于批处理的合并。
-
同一图集(Texture Atlas):通常,一个界面上的UI元素会被打包到同一个图集中,以减少纹理切换,从而最大化动态批处理的效率。
-
同一材质(Material):所有UI元素使用同一种材质,可以进一步减少Draw Call的数量。
当以上条件得到满足,并且UI元素不发生变化(例如文本未更新、图片未移动),Canvas内的批处理就不需要重新计算,此时,一个Canvas只会有一个Draw Call。
动态批处理的挑战
然而,实际情况通常更复杂。一旦UI元素发生变化,例如文本内容改变或图像位置移动,Canvas就会触发重绘操作。这种情况会导致所有的动态批处理重新计算,从而增加Draw Call的数量。为了应对这个问题,提出了“动静分离”的策略。
动静分离
“动静分离”的策略是将动态变化的对象和静态不变的对象分开,使用不同的Canvas来管理:
- 动态Canvas:包含那些会经常更新或移动的UI元素。
- 静态Canvas:包含那些不会频繁变化的UI元素。
通过动静分离,可以减少Canvas的重绘次数,从而减少Draw Call。然而,这种方法在实际操作中存在一些问题:
-
UI层级错乱:动静分离会导致多个Canvas的创建,这些Canvas之间的层级关系容易错乱,导致UI元素显示顺序出错。
-
复杂的Canvas管理:当多个Canvas被使用时,管理这些Canvas之间的交互和渲染顺序会变得非常复杂,可能会引起UI元素穿插和显示异常等问题。
-
局限性:动静分离在复杂UI场景下的应用非常有限,不容易做到准确分离。尤其是在不同的UI页面加载时,原本正常的UI层级关系可能会发生变化,导致显示错误。
因此,动静分离虽然在理论上能够优化Canvas的批处理,但在实际操作中,其应用局限性较大,需要谨慎使用。
多个图集的批处理挑战
在实际应用中,UI界面通常涉及多个图集,例如,UI元素的图像和字体通常属于不同的图集,这为动态批处理带来了额外的挑战。
图集和字体图集的穿插
假设有两个图集,一个是UI图集A,另一个是字体图集。以下是几个典型场景:
-
UI图集和字体图集的顺序排列:
- 当所有引用字体图集的元素(例如Text组件)都在引用UI图集A元素的上面或下面时,会形成两个独立的动态批处理,即两个Draw Call。
-
UI图集和字体图集的交叉排列:
-
如果字体图集中的元素(Text组件)与UI图集A中的元素在Hierarchy面板的UI节点顺序中交叉排列,可能会导致两种情况:
-
不打断批处理:如果图集A的UI元素没有像素遮挡或与字体图集的元素像素重叠,动态批处理不会被打断,Draw Call数量保持不变。
-
打断批处理:如果图集A中的UI元素在字体图集元素的上下之间,并且形成像素遮挡或重叠,批处理会被打断。这会形成三个Draw Call:
- 底层的字体图集元素形成一个Draw Call。
- 中间的UI图集A的元素形成一个Draw Call。
- 上层的字体图集元素形成另一个Draw Call。
-
-
复杂的图集场景
在更复杂的场景中,同一个UI图集可能会生成多张纹理(多个子图集)。可以将一张图集生成的多张纹理,理解为图集的多张子图集。在这种情况下,优化的原理仍然相同:尽量减少不同图集之间的穿插和遮挡,减少Canvas的重绘次数,最大化动态批处理的效率。
要优化这些复杂场景,可以采用以下方法:
-
减少图集间的穿插:避免不同图集之间的UI元素在Hierarchy中的交叉排列,尽量将同一图集的元素放在一起,以减少Draw Call的数量。
-
优化图集布局:合理安排图集的内容,尽量将相关性高的元素放入同一个图集中,减少不同图集的切换。
-
控制Canvas的更新频率:将静态和动态元素分开到不同的Canvas中,减少不必要的Canvas重绘。
通过这些策略,可以有效提升UGUI的渲染效率,减少Draw Call的数量,从而提升整体UI性能。