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