主要参考内容:《Unity性能优化》系列课程笔记- 文集 哔哩哔哩专栏
项目创建
一开始需要我们去下载项目工程和资源(贵的话去某宝上买一下),这个步骤我就跳过了,准备好后大概长这样。

截图可能看不出来,但是哪怕不运行都肉眼可见的很卡,看来优化的空间很大啊。

第一步,我们得学会看基本的status里的内容。
| 字段 | 含义 |
|---|---|
Level |
当前音频电平,单位 dB。越接近 0 越大声。 |
Clipping |
发生削波的比例。高了说明声音可能爆音失真。 |
DSP load |
音频 DSP 处理负载,占用越高越吃 CPU。 |
Stream load |
流式音频读取负载。高了可能说明正在边播边读的音频比较重。 |
CPU: main |
主线程一帧耗时。脚本、物理、UI、编辑器开销等都可能在这里。 |
CPU: render |
渲染线程一帧耗时。偏向提交绘制、准备渲染数据。 |
FPS |
帧率 |
Batches |
一帧里处理的批次数,也就是绘制调用的数量。 |
Saved by batching |
因为批处理而省掉的绘制调用数。越多通常越好。 |
Tris |
一帧处理的三角形数。 |
Verts |
一帧处理的顶点数。 |
Screen |
当前分辨率,以及这块屏幕缓冲占用的内存信息。 |
SetPass calls |
切换着色器 pass 的次数。通常越多越容易增加 CPU 开销。 |
Shadow casters |
当前会投射阴影的物体数量。 |
Visible skinned meshes |
当前可见的蒙皮网格数量。 |
Animation components playing |
正在播放的 Animation 组件数量。 |
Animator components playing |
正在播放的 Animator 数量。 |
当然,光看这些苍白的解释是没有意义的,从我们优化的角度来说,主要看的指标是:CPU ,无论是主线程的一帧耗时还是渲染线程的一帧耗时都是非常重要的数据,只要某一项接近或超过你的帧预算,就优先处理。比如 60 FPS 预算是 16.7 ms,30 FPS 是 33.3 ms;Batches以及SetPass Call ,这两个的数值偏高证明你的渲染策略有问题,它们高,往往说明材质切换、灯光/阴影、shader pass 太碎;Shadow casters 代表的是有投射阴影的物体数量,渲染中的阴影计算可以说是比较大的开销,当CPU Render较高时这个是可以考虑的方向;Tris / Verts 这两个参数分别代表三角面和顶点数,这两个参数其实往往很难看出端倪,但是也是可以考虑的内容,尤其在GPU压力很大或者低端机表现不佳时...
当然,我们说了这么多,有一个最基本的问题是:用什么标准来判断这些参数? 存在一个公认的标准吗?
非常遗憾,没有 Unity 官方强制的、一刀切的绝对公认标准,只有行业经验参考阈值,而且高度绑定目标硬件平台,这些数值只是参考,最终以真机 Profiler 的帧时间为金标准。参数阈值会跟着目标设备档次变化,PC、高端手机、中低端手机要求完全不一样,同时各个指标之间会互相影响,不能单看某一个数字合格就代表性能合格。
照着课程走的话,可以看到说烧录真机和你在Editor模式中的性能表现与渲染效果截然不同,当然这里我们可以提一嘴:编辑器是带调试附加功能的 PC 模拟环境;烧录真机是经过打包裁剪、平台资源转换、运行在移动硬件上的真实产物,构建流程、图形 API、硬件算力、Quality 配置全部不一样,所以渲染效果和性能差距很大。
- Statistics 面板只用来做相对对比,不要拿编辑器的 FPS、ms 当作真机性能依据,Tris、Batches 这类计数可以参考,但耗时必须看真机 Profiler。
- 编辑器正常、真机材质变粉,90% 是 Shader Stripping 变体被裁剪。
- 编辑器合批很好,真机 DrawCall 暴涨,优先排查静态合批打包结果、SRP-Batcher 材质条件。
- 验证效果和性能必须烧录真机,模拟器也不能完全替代真机。
- 编辑器使用和真机一致的 Quality 档位,可以缩小两者效果差距,但依旧不能完全消除差异。
当然,这里我们也提到了几个新的概念,我们来一个一个讲解。

比起status简单粗暴地展示参数,profiler作为unity自带的性能诊断工具,能更清晰而精准地发现问题。当我们烧录到真机后,可以在Editor这边选择对应的手机,就可以收集到真机的对应数据了。

比如这个profiler结果,可以看到基本CPU的开销集中在了渲染,代码以及GC,对于普通 3D 手游场景,渲染物体多、有实时光影阴影 ,CPU 开销 Rendering 占最高是非常常见的现象,属于符合预期。3D 项目 CPU 第一大开销经常就是 Rendering 模块,大量时间花在准备渲染指令、处理阴影投射物体。
最后还有一个可以参考的优化内容,就是我们最终打包的APK文件的大小,底包的大小能充分体现出资源优化的程度。
资源优化
我们首先考虑的,就是资源的优化。

这张是 Unity 官方标准完整资源生命周期流水线,一共五大阶段:Import(导入)→ Create(编辑创作)→ Build(构建打包)→ Distribute(分发)→ Load(设备加载运行),同时区分主 App 包和 Content Bundles(资源包 / AB 包)两条输出路线,完整描述美术资源从外部文件到玩家设备上跑起来全链路。
我们从导入开始学习如何优化:

我们导入资源的对象主要是外部的诸如网格,纹理,音频等,以及unity editor内部创建的诸如prefab,animation controller等(比如你导入别人的包或者项目),在导入这一步就可以去尝试优化。
这里介绍了一个新的工具:unity的upr,以及asset checker。


我们需要去下载asset checker,并在upr中新建一个自己的项目:

然后我们按照操作手册来操作asset checker的内容,输入对应的指令后可以后在资源检测可以看到:

我们接着这个部分来分析如何优化。
音频部分

检查了八十四个音频资源,有七十五个建议优化。

第一个建议说是这些环境音效可以开启forceMono,当然我们首先得知道forceMono是干嘛的。简单的说一般的音频文件默认打开双声道,但是如果其实左右声道的内容完全相同的话,我们就可以通过force to mono切换成单声道,之后再去调整音量即可达到与之前的效果完全相同的同时节省内存。

这里还额外展开说了两个内容,第一个是压缩格式:

Unity音频四种压缩格式里,PCM为无损无压缩格式,体积庞大极少使用,MP3因安卓端兼容隐患新项目尽量避开,Vorbis压缩率高音质好但播放会消耗CPU解码,适合BGM这类长音频搭配Streaming流式加载,ADPCM解码开销低,适合大量并发的短音效,搭配Decompress On Load加载模式以及Force To Mono强制单声道减少内存占用,移动端安卓平台BGM选Vorbis、短音效选ADPCM,PC端硬件性能更强可沿用这套方案,短音效也可酌情使用Vorbis。
还有就是采样率:

这里只说了采样赫兹48000Hz对于移动端来说太高了,并且给出了一个经验值22050Hz。
采样率属于音频重要优化项,采样率越高,音频细节越好,但文件体积、内存占用会同步上升,移动端没必要无脑使用48000Hz,手游音效常用22050Hz,BGM可以给到32000Hz,PC端硬件性能充足一般用44100Hz或48000Hz。Unity的Sample Rate Setting可以设置为Override手动降采样,短音效降到22050Hz人耳几乎听不出差别,却能显著减小包体与内存,注意不要把采样率压得过低,低于16000Hz会明显损失高频细节,脚步声这类音效就会发闷。

Preserve Sample Rate是保留音频原始采样率,不会做任何降采样,音质完整但包体、内存开销最大;Optimize Sample Rate让Unity自动根据平台做智能降采样,移动端会自动压低采样率,PC端保留较高采样率,不用手动填数值,属于自动化折中方案;Override Sample Rate为手动强制覆写,可以自己指定固定采样率,适合严格管控音频资源的手游项目,手动给到22050Hz这类目标值,能精准控制包体与内存,缺点是每个音频都要手动设置。移动端项目音效优先用Override手动设置22050Hz,BGM用32000Hz,想省事就选Optimize Sample Rate,PC或者追求无损音质的BGM使用Preserve Sample Rate。

第二个建议是,不同的音乐类型建议使用不同的加载类型。

说的就是这里了。Decompress On Load是资源加载时就一次性解压成原始PCM波形,内存占用高,但播放时不再消耗CPU解码,适合短音效;Compressed In Memory把压缩音频保留在内存,播放时实时解码,内存占用低但持续消耗CPU,适合不会同时大量并发的中等长度音频;Streaming不从内存完整加载音频,直接从磁盘边读边播放,内存开销最小,但会产生磁盘IO,专门用于BGM这类大体积长音频,手游项目短音效一般选Decompress On Load,BGM统一选Streaming,尽量少用Compressed In Memory避免战斗场景解码CPU过高。
模型部分
我们来看看模型的导入流程:

先在 DCC 工具做好导出配置,进入 Import Settings 面板,区分特有与常规选项完成基础设置,再判断是否为人形骨架,人形骨架要完成人形骨骼、Avatar 配置与创建遮罩,非人形则走通用骨架流程,之后依次配置材质、贴图,经过导入测试确认无误后完成模型导入。
当然我们首先得补充一下DCC工具是什么:DCC全称Digital Content Creation,即数字内容创作工具,就是做3D模型、动画、贴图的美术软件,常见的有Maya、3ds Max、Blender、ZBrush、Substance Painter,游戏里角色、场景、骨骼动画都靠它们制作,做完导出FBX等格式给到Unity引擎使用,对应流程图里最开头一步,引擎导入前要先在DCC里做好导出参数,避免模型、骨骼、动画导入后出现异常。

这就是一个导入的FBX文件的inspector界面,我们来看有哪些可以优化的部分。

首先scene部分,用来控制DCC导出的场景单位、轴转换、变形、相机灯光、层级结构,Scale Factor为整体缩放系数,Convert Units开启后会把外部文件厘米单位换算成Unity米单位,避免模型尺寸错乱,Bake Axis Conversion开启会把坐标轴旋转直接烘焙进模型顶点,不依赖根节点变换,Import BlendShapes控制是否导入面部表情等形变,Import Deform Percent用于形变权重导入,Import Visibility读取DCC里物体显隐状态,Import Cameras、Import Lights决定要不要把外部相机灯光一并导入引擎,Preserve Hierarchy保留原始完整父子层级,关闭会做层级合并,Sort Hierarchy By Name会把子物体按名称排序整理层级,项目开发一般打开单位转换、BlendShapes,关闭烘焙轴转换、相机灯光,按需选择层级保留选项,防止导入多余资源、层级混乱、模型大小比例出错。

这是FBX导入的Meshes网格选项,Mesh Compression是网格压缩,Off代表不压缩,调高档位会压缩顶点数据减小内存,但会损失顶点精度,角色模型一般选低档位,高精度模型关闭压缩;Read/Write开启后C#脚本可以读写修改Mesh顶点数据,会多占用一份内存,普通渲染模型建议关闭,只有需要代码改网格时才勾选;Optimize Mesh设为Everything会对顶点、索引做重排优化,提升GPU渲染效率,绝大多数模型都开启;Generate Colliders会自动根据网格生成MeshCollider,仅对静态场景模型按需勾选,角色、动态物体不要开启,避免性能开销。

这是FBX导入的Geometry几何设置,Keep Quads勾选会保留四边形面,Unity渲染最终还是转三角,一般关闭;Weld Vertices开启会合并位置重合的顶点,减少多余顶点,多数模型打开;Index Format索引格式Auto自动根据顶点数量选16位/32位索引;Legacy Blend Shape Normals是旧版形变法线兼容开关,新项目关闭;Normals选Import代表直接读取DCC导出的法线,也可让引擎重新计算;Normals Mode的Area And Angle Weighted是面积角度加权算法,计算法线效果更自然;Smoothness Source优先使用平滑组,Smoothing Angle=60度,夹角小于该角度就做平滑法线;Tangents选Calculate Mikkspace,是PBR法线贴图标准算法,法线贴图模型必须选这个;Swap UVs交换两套UV通道,UV颠倒的时候勾选;Generate Lightmap UVs自动生成光照贴图第二套UV,静态烘焙场景模型才开启;Strict Vertex Data Checks开启会严格校验顶点数据,排查异常模型时打开,正常项目关闭。
我们回到之前的检测报告,看看FBX部分的优化建议:

第一个建议是动画的资源使用最佳压缩方式。

但是实际上这个项目中所有引入的fbx根本没有动画资源。
所以我们去修改Rig里的动画类型为None即可。


第二个建议是说部分FBX资源的顶点太多。课程中告诉我们,一个fbx文件的顶点数限制在五百过于严苛,因为现在即使是低端的手机应对这种精度的模型都搓搓有余,所以此项建议可以忽略。不过这里稍微提醒一下就是,FBX 文件顶点和 Unity 导入后 Mesh 顶点,UV、法线拆分会让导入后顶点数高于 DCC 软件里看到的数值,遇到警告优先看实际导入后的 Mesh 顶点。

还有一个建议是这样的,课程中提到了project settings中的:

Vertex Compression (Mixed) 是打包时对顶点位置、法线、UV 等顶点数据做精度压缩,Mixed 代表自动对不同顶点通道选择合适压缩等级,能降低运行时 Mesh 内存占用,会损失一点点浮点精度,角色、道具常用 Mixed,高精度物件可关闭;Optimize Mesh Data 开启后打包时会剔除 Mesh 里没被材质使用的顶点通道,比如没用第二套 UV、没用切线就直接删掉对应数据,减小包体与内存,几乎没有副作用,发布版本建议勾选。
至于优化的建议,**Optimize GameObjects(优化游戏对象)**只针对带骨骼动画的模型,开启之后 Unity 会把骨骼层级在 Hierarchy 层级面板隐藏,不在场景里生成一大堆骨骼 GameObject,动画系统内部直接操作骨骼矩阵,减少大量 Transform 组件,降低 CPU 开销,这就是这条检测规则的原意,专门给带动画的角色 / 物件用。我们的项目里根本没有动画,所以直接选择为None即可。
纹理部分
纹理部分是一个很大的概念,我们首先学习在unity中有哪些常见的纹理类型:

Unity的各类纹理类型分别为:Default 是通用默认纹理;Normal map 用于法线贴图,将颜色通道转为法线数据实现模型凹凸效果;Editor GUI and Legacy GUI适配编辑器及旧版IMGUI界面;Sprite (2D and UI)专供2D精灵与UGUI使用;Cursor用作自定义鼠标光标;Cookie实现灯光剪影光斑效果;Lightmap 为烘焙光照贴图,编码随平台变化;Single Channel用于只有单个通道的贴图,多用于遮罩类数据。
不同的纹理大小占用的内存不同,所以在不同的平台使用不同大小的纹理也是优化的一部分。

选择纹理大小要结合平台硬件,可借助Bundle变体 、Mipmap 适配不同设备,按需选用流式、稀疏、虚拟纹理等加载方案,不要单纯靠放大贴图提升细节,改用DetailMap等手段,尽量让纹素匹配屏幕像素,避免欠采样模糊或过采样噪点,还可以利用SceneView的Mipmap视图辅助调整贴图尺寸。
当然这里我们稍微补充一下提到的几个概念:
Bundle变体(AssetBundle Variant):针对同一个资源制作多套不同品质贴图,比如一套高清、一套降分辨率的低配版,打包成不同变体AB包,运行时根据设备性能,给高配机加载高清贴图,低配机加载压缩小贴图,实现一套逻辑适配多套画质资源,不用修改业务代码。
Mipmap:贴图导入时自动生成一系列逐级缩小的贴图副本,物体离镜头远就用小的mip层级贴图,减少显存采样开销、抗锯齿;还能限制可用mip层级,低配设备强制只用低层级,变相降低纹理内存,缺点是会多占用约33%的显存存储整套mip链。
DetailMap(细节贴图):一张叠加的高频细节纹理,不靠放大主贴图来增加物体表面细碎纹理,把划痕、颗粒这类细微细节放在DetailMap上叠加渲染,主贴图可以保持较小分辨率,既保留画面细节,又不会让主贴图尺寸爆炸,节省内存。
最后的那个可以观看具体哪些像素欠采样,哪些物体过采样,课程的版本比较老,现在如果你是built-in内置管线的话还有mipmap渲染模式,但是如果你是URP的话就得去renderer debugger里面找到Overdraw之后运行游戏后执行,就能看到下面的情况。

然后提了一嘴哪些纹理不该使用sRGB颜色空间。

然后是纹理压缩的部分。

如果直接拿PNG或者TGA这种通用文件格式的话,访问和采样速度会比较慢,且占据内存高,所以一般对通用的这类格式文件做压缩处理。

然后是纹理图集的概念:

纹理图集就是把多张小贴图合并打包成一张大贴图,它能让使用该图集的静态网格支持静态合批以减少DrawCall,同时减少碎贴图,提升压缩效率降低内存开销,但它需要美术规划资源,模型要共用一套Shader或是额外制作通道图区分材质,制作修改的成本更高。Unity默认支持Sprite Atlas图集,专门针对 UGUI/2D 精灵,开箱即用不需要额外安装包,但要注意它只处理 Sprite 精灵图片,不能处理 3D 模型的材质纹理,3D 物体的纹理合并图集 Unity 没有内置工具,需要美术在外部 PS、Substance 或者第三方插件完成。Sprite Atlas 会把一堆零散的 Sprite 小图打包到一张大图里,减少碎贴图,降低 DC;缺点是图集尺寸必须是 2 的幂,会产生空白浪费,修改资源需要重新打包图集,3D 模型纹理不能用这套系统。
接着是纹理过滤,也就是当贴图放大、缩小时,GPU 算法决定怎么给像素采样贴图颜色,用来控制画面模糊 / 锯齿。

邻近点采样计算开销最低,贴图放大时会出现马赛克块状效果;双线性过滤对周边纹素插值让画面过渡平滑,但远处贴图容易模糊;三线性过滤在双线性的基础上,对相邻Mipmap层级也做插值混合,可以消除Mipmap切换时的跳变闪烁;各向异性过滤能够改善倾斜视角下的纹理清晰度,很适合地表这类纹理,不过GPU开销相对更高。
最后介绍了mipmap的概念:

Mipmap就是为一张纹理逐级生成分辨率递减的副本,相当于纹理层面的LOD,渲染时根据物体在屏幕上的大小自动选用合适层级采样,既提升GPU纹理采样性能,还能改善远距离过采样产生的噪点,不过整套副本会带来额外的内存开销。
接着还说了一个概念:Mipmap Streaming(纹理 Mipmap 流式加载),它不会把贴图全部 Mipmap 层级一次性加载进内存,会根据物体距离屏幕远近,只加载当前需要用到的 Mipmap 级别,远距离物体只加载低分辨率副本,以此降低纹理内存占用,相机靠近物体时再动态加载更高清的层级;代价是相机快速拉近物体时,有可能短暂出现贴图模糊的加载延迟。

我们首先在这里开启Texture Streaming。

然后随便找一个texture的advanced属性中的就可以看到mip streaming这个选项了。
说了这么多纹理的概念,现在让我们看看具体在项目中怎么优化吧。
依然是上图这张纹理的import settings。

2D是最通用的二维贴图,模型固有色、法线、UI、精灵都使用该类型;Cube为六面组成的立方体贴图,专门用于天空盒和环境反射;2D Array是将多张尺寸格式一致的2D贴图打包为GPU数组,需要Shader配合读取指定层,多用于地形、粒子等高级渲染场景;3D属于体积纹理,具备宽、高、深度三维维度,主要用于体积雾、体积云这类体渲染效果,内存开销很高,普通项目很少使用,日常开发优先选用2D,天空盒反射选Cube,后两种仅在特定渲染需求下才使用。

Alpha Source用来设置贴图Alpha通道的来源,None代表贴图不使用Alpha通道,会丢弃透明信息;Input Texture Alpha直接读取原图自带的Alpha通道,适合PNG这类自带透明的贴图;From Gray Scale会把贴图的灰度信息生成Alpha通道,用图片亮度充当透明数据,常用于把一张黑白图拆成颜色和透明度两张图合并使用,搭配Alpha Is Transparency勾选后,Alpha就会作为透明度生效,不带透明的固有色贴图直接选None,PNG透明贴图选Input Texture Alpha,需要灰度转透明时选用From Gray Scale。

Non‑Power of 2处理非2次幂尺寸贴图,ToNearest就近缩到最近2次幂尺寸,还有ToSmaller、ToLarger选项,移动端尽量使用2次幂贴图避免缩放损耗 ;Read/Write开启后CPU可以读写贴图像素,会额外占用内存,仅脚本需要操作像素时勾选,普通资源务必关闭;Virtual Texture Only专供虚拟纹理系统使用,普通贴图不用;Generate Mipmaps开启会生成多级渐缩小贴图,物体远离时自动使用更小层级,优化采样性能、减少闪烁,3D模型贴图建议勾选,UI贴图一般关闭;Ignore PNG Gamma用于修复部分PNG图片gamma读取异常,绝大多数场景不开启;Swizzle可以重排RGBA通道顺序,法线贴图、通道复用材质时才会调整,普通贴图保持默认RGBA即可。
补充一下上面提到的虚拟纹理系统是什么,虚拟纹理(Virtual Texture,VT)简单说就是Unity的贴图流式分页加载系统,类比CPU这边的虚拟内存,把超大贴图拆成很多小瓦片Tile,GPU只加载当前镜头能看到的瓦片,不需要把整张巨量贴图全部塞进显存,解决地形、开放世界里几K甚至几十K超大贴图显存爆炸的问题。传统贴图是导入后完整上传显存,不管看不看得见都占满显存,虚拟纹理会把贴图切成分辨率很小的瓦片池,运行时根据相机距离、可见区域动态请求需要的瓦片,看不见的瓦片就从显存卸载,支持非常大的贴图尺寸,不用顾虑贴图尺寸上限,分为Streaming Virtual Texture(流式虚拟纹理)和Sparse Virtual Texture(稀疏虚拟纹理),Unity里主要用流式VT。Virtual Texture Only这个勾选就是标记这张贴图只允许给虚拟纹理系统使用,普通材质不能直接采样,贴图不会走常规Mipmap上传流程,普通3D、UI贴图千万不要勾选,只有作为VT源贴图的时候才启用,它适合开放世界地形、超大场景地表,小型手游、普通道具贴图完全用不上,额外还有一套VT材质、渲染器配置,会增加工程复杂度,小项目一般不会碰这套系统。
介绍完上述内容后,我们回到之前的检测报告中来,看看纹理部分该如何改善:

300个纹理资产中250个建议优化。

第一个,不要针对未压缩的纹理执行Mipmap。

第二个,是针对三线性过滤进行了警告,因为三线性过滤的计算成本较大,一般三线性过滤都是为了切换mipmap不同层级时的视觉连贯性。

第三个,如果有纹理的alpha值为0或255就认为这个值不具有意义(完全透明或者完全不透明),可以舍弃,可以去关闭对应的alpha source。

第四个,这个AssetChecker警告代表检测出一张1024×1024的纯色法线贴图,整张图像素几乎没有变化,工具默认大于16×16就触发告警,对于这种大尺寸纯色纹理,优先建议不用贴图文件,改用Shader常量数值实现同等效果,以此节省显存与采样开销,只有业务确实必须保留贴图时才忽略警告。

第五个,这 12 张贴图 Wrap Mode 设为了 Repeat 重复模式,如果模型 UV 没有控制在 0‑1 区间,贴图边缘像素会循环拼接,容易出现难看的接缝黑边;可平铺的地砖、地面贴图适合用 Repeat,椅子、道具这类非平铺模型贴图应当改为 Clamp,避免 UV 溢出时露出贴图边缘异常线条,若是本身就需要平铺的贴图,这条警告可以直接忽略。
稍微补充一下这个环绕模式的概念,因为之前似乎没有介绍,**纹理环绕模式(Wrap Mode)**决定 UV 超出 0‑1 范围时 GPU 怎么采样贴图,Repeat 就是重复平铺,Clamp 则把超出部分固定用贴图边缘像素。


剩下的这两个的含义就不用多介绍了。
课程还额外介绍了一些针对纹理不好检测的但是也需要优化的情况。

比如这种纹理图集,里面大片的空白,且分类不合理。只要图集中一个纹理还在被使用,这个图集就得一直占用内存。

还有这种半透明纹理,虽然贴图本身只有 256×256,但渲染出来会铺满屏幕大片区域,半透明物体要走混合渲染,无法做深度测试剔除,屏幕被它覆盖的每一个像素 GPU 都必须执行片元着色器,产生大量 Overdraw(过度绘制),带来 GPU 片段压力;同时它使用 RGBA 格式存储 Alpha 通道,会带来额外内存与带宽开销,大面积半透明叠加时性能压力会进一步放大,优化思路包括缩小贴图尺寸、尽量用 Additive 相加混合替代 Alpha‑Blend 混合、裁剪贴图空白透明区域、能用 Shader 程序化光晕就尽量不用贴图粒子,手游中屏幕大面积半透明是很容易掉帧的性能热点。

大量的颜色渐变的纹理我们不用使用贴图,可以完全靠 Shader 在片元着色器里用数学公式实时计算渐变,不用加载纹理,节省贴图内存、包体与采样开销。

这是特效序列帧动画,现在每一帧都是独立贴图,大量零散贴图会造成大量 DrawCall、贴图内存浪费与资源碎片,优化核心就是把所有帧打包到一张**图集(SpriteSheet)**里,将多张小图合并为一张大贴图,通过 UV 偏移切换动画帧,大幅减少贴图数量与 DrawCall,同时图集开启合适的压缩格式降低内存。
动画部分
我们回到model中的rig的animation type。


一句话,不用legacy动画(除非为了兼容老动画),有人形动画就用hunmannoid,否则都用generic。
选中人形动画后:

Skin Weights 是骨骼蒙皮权重,Standard (4 Bones) 代表每个顶点最多受 4 根骨骼影响,Custom 可以自定义每顶点最大骨骼数;Strip Bones 会移除模型里不影响蒙皮渲染的多余骨骼,只保留蒙皮计算必需骨骼,减少骨骼数量;Optimize Game Objects 会把 Hierarchy 层级里的骨骼 GameObject 删掉,骨骼数据转移到 SkinnedMeshRenderer 内部,简化层级、降低 CPU 开销,运行时不再能直接访问骨骼 GameObject,适合纯动画渲染,如果你代码要操控骨骼 Transform,就不能开启 Optimize Game Objects。

然后是animation界面:

因为原来的素材包中并没有带动画的模型啊,我直接用课程中的界面来讲解参数。Import Constraints 控制是否导入 FBX 内的约束数据,Import Animation 开启后才会读取文件里的动画关键帧,Bake Animations 用于把 IK 类动画烘焙成普通关键帧,Resample Curves 会对原始动画曲线重采样统一采样点,Anim.Compression 选择 Keyframe Reduction 即关键帧精简压缩模式,Rotation Error、Position Error、Scale Error 分别代表旋转(单位度)、位移、缩放允许的最大误差阈值,数值越大删掉的关键帧越多、数据体积越小但动画变形偏差会变大,Animated Custom Properties 用来控制是否导入 FBX 里自定义属性的动画,整套参数主要用于在可接受视觉误差下删减冗余关键帧,降低动画片段内存占用。
Anim.Compression 一共有三种模式:Off、Keyframe Reduction、OptimalUnity...。Off 代表完全关闭压缩,完整保留 FBX 全部原始关键帧,没有精度损失,但文件体积、运行内存最大,只适合调试、对动作精度要求极高的特殊动画;Keyframe Reduction 是手动控制模式,按照 Rotation/Position/Scale Error 误差阈值删除冗余关键帧,误差越大删得越多,开发者自己把控压缩程度,Humanoid、Generic、Legacy 都支持,是项目最常用模式;Optimal 是 Unity 推荐自动模式,针对 Humanoid/Generic 人形通用动画,Unity 内部自动判断选用关键帧删减或者密集存储格式,在给定误差阈值下拿到尽可能小的内存占用,不用开发者手动权衡压缩策略。
我们选取一个动画片段看看其中包含哪些参数:


这是Unity动画Clip统计信息,Pos、Quaternion/Euler、Scale分别对应位置、旋转、缩放动画曲线,Humanoid人形模式下会多出Muscles肌肉曲线,Generic是材质颜色这类普通属性动画,PPtr专用于2D精灵动画;Constant代表被优化成固定常量不再参与运算的曲线,Dense是按离散采样点密集存储,Stream保存时间与切线做插值计算,通过这些数值可以直观看到动画有多少条曲线、压缩优化后的存储形式,用来评估动画内存开销。
当然,到这应该不止我一个人还没有理解这个曲线的概念吧:动画曲线本质就是一条记录"时间‑数值"关系的函数 ,时间是X轴,属性数值是Y轴,每根曲线只负责控制某一个对象的某一项属性,比如某根骨骼的X位置、旋转、缩放,人形角色额外还有肌肉曲线,Unity靠这条曲线在每一帧根据当前时间插值算出属性该取多少值,不需要每一帧都存关键帧,关键帧只是曲线上你手动设置的控制点,中间画面由引擎切线算法自动算出来;动画优化看曲线,就是因为每一条曲线都要CPU每帧运算插值,曲线数量越多、Dense密集存储越多,内存和CPU开销就越大,压缩的本质就是删掉影响很小的冗余控制点、把不变的曲线降级为Constant常量,减少需要实时计算的曲线条数与数据量。
工作流优化
工程目录和Asset目录设置
我们首先来学习一下一个Unity项目的默认文件路径分布。

稍微补充一下的是,这里的Library存储的内容说得可能不够准确,Unity的Library不存你的业务源代码,也不存原始资源文件,它是编辑器的本地缓存文件夹,保存导入之后处理完成的数据,原始的脚本、FBX、贴图、Prefab都放在Assets文件夹里,当你把文件丢进Assets,Unity会解析、转换、编译,把处理后的版本输出到Library;里面包含脚本编译后的dll二进制、模型贴图动画导入烘焙出来的引擎内部格式资源、资源元数据索引、缓存、Shader变体、AssetDatabase数据库,相当于编辑器的"工作缓存仓库",游戏打包的时候,Unity就是从Library读取这些已经处理好的数据来构建包体,删除Library文件夹不会损坏Assets里的源码和素材,但下次打开项目Unity要全部重新导入一遍,耗时比较久,版本控制里一般要把Library整个忽略不上传。
既然说到了托管代码,我们来聊一聊一般哪些文件夹会托管到代码工程:
| 文件 / 文件夹名称 | 是否建议托管 | 核心原因 | 关键注意事项 |
|---|---|---|---|
Assets/ |
✅ 是 | 存放项目全部核心业务资源(脚本、模型、贴图、场景、Prefab、动画、材质等),是项目的核心资产目录 | 必须连同同目录的.meta元数据文件一起提交,meta 文件丢失会导致资源导入设置、引用关系完全错乱 |
ProjectSettings/ |
✅ 是 | 存储项目全局统一配置(渲染管线、物理规则、输入系统、Layer / 标签、打包参数、质量等级等),保证团队所有成员的项目环境完全一致 | 仅提交正式的.asset配置文件,忽略带~后缀的临时备份文件 |
Packages/ |
✅ 是 | 记录包管理器的依赖清单,确保团队所有成员安装完全一致的包版本,避免包版本不一致导致的报错 | manifest.json、packages-lock.json必须提交,自定义本地包也需纳入托管 |
Library/ |
❌ 否 | 编辑器本地导入缓存目录,存放资源处理后的二进制产物、脚本编译结果、资源索引等,体积庞大且可自动重建 | 版本控制必须完全忽略,删除后项目会触发全量重新导入,耗时较长 |
UserSettings/ |
❌ 否 | 仅存放当前用户的编辑器本地偏好(窗口布局、快捷键、本地路径、登录信息等),属于个人环境配置 | 提交会导致团队成员的个人配置互相覆盖,必须完全忽略 |
Temp/ |
❌ 否 | 存放编译、运行、AssetBundle 打包过程中生成的临时文件,运行时自动生成,无持久化价值 | 无需任何场景下提交 |
Logs/ |
❌ 否 | 存放编辑器运行、编译的日志文件,仅用于本地排查问题,无项目依赖价值 | 无需提交 |
IDE 生成文件(.sln/.csproj) |
❌ 否 | IDE 自动生成的解决方案文件,本地打开项目会自动重建,不同 Unity/IDE 版本生成的文件易冲突 | 避免提交,防止团队成员 IDE 环境差异导致的文件冲突 |
临时备份文件(*.asset~/*.meta~) |
❌ 否 | Unity 自动生成的配置 / 资源备份文件,无实际运行依赖 | 版本控制需忽略这类带~后缀的文件 |
只有三个是必要的,Assets主管资源文件,Packages存储调用的包,Projectsettings是配置文件,其他的都可以不托管。

Editor、Editor Default Resources、Gizmos 属于编辑器开发专用,打包就消失 ;Plugins、Resources、Standard Assets、StreamingAssets 会参与打包,其中 Resources 容易造成包体膨胀,StreamingAssets 资源保持原始文件格式;以.、~、.tmp、cvs 这类文件 Unity 直接无视,不做任何导入处理。
接着我们进入Assets文件夹内部,这里面属于是一个自定义的文件夹结构,所以也没有绝对的标准,但是有一些基本的设计原则:

核心是一级目录数量尽量精简,不要在 Assets 根目录按贴图、模型这类资源类型划分文件夹,要区分编辑器专用和游戏运行时的内容,便于工程大版本迭代管理,保证场景、全局配置文件能够快速找到,视频这类特殊资源建议直接放在 StreamingAssets 目录下,通过这套规则避免根目录文件夹泛滥,方便团队协作维护项目结构。

Assets 二级目录只做大的资源类型划分,把模型、贴图、动画这类大类建齐全,不在二级目录下细分子类型、不按业务功能、也不按资源生命周期去拆分,把更细的分类下沉到三级以及更深的子文件夹,以此保持二级目录简洁规整,避免目录层级前期就过度碎片化。

三级目录里,音频、贴图、模型这类资源继续往下拆分子类型,其余资源则按照业务功能模块或者资源生命周期来划分,把细化分类下沉到这一层,承接二级的大类,兼顾资源归类和业务模块的组织管理。

四级目录仅给音频、贴图、模型这几类资源使用,不再给其他资源继续往下建更深层级,在这一层按照业务模块或者资源生命周期进一步划分,把资源归属到对应业务,避免目录层级无限嵌套,维持整体目录深度可控。
资源导入工作流
目前主流的导入工作有三种:


自己写工具自定义导入资源的方式可以最大化效率,也非常的灵活,但是问题就是上手成本高,且对于新手来说不友好。

第二种就是利用presets功能,使用起来非常的简单快捷,但是缺点就是和后续的工作流程难以产生联动,只有资源导入这一个过程有价值。

至于这个可视化工具嘛,我个人其实是不喜欢的,但是客观地说确实降低了整个流程的理解门槛,更清晰直观,且操作起来更方便。
接下来我们来学习具体如何书写一个资源导入工具,课程中以伪代码的方式呈现:

AssetPostprocessor 是 Unity 资源导入管线提供的钩子基类 ,引擎内部在资源导入流程里自动创建子类对象,并且把当前正在处理资源的assetImporter父类实例赋值给基类自带的成员变量,回调我们写好的OnPreprocessXXXAsset方法,我们只做向下强制转型拿到对应类型导入器,修改属性,调用保存重导入接口即可。
当把 fbx/png 这类外部文件丢进 Project 窗口,Unity 底层会生成对应 Importer 对象读取.meta 导入配置 ,在真正解析生成引擎资源之前,引擎会调用所有继承 AssetPostprocessor 类里面匹配资源类型的 OnPreprocessXXX 回调;基类自带assetImporter成员,就是引擎传过来的当前资源导入器父类引用,我们强转为 TextureImporter/ModelImporter 这种具体子类,修改压缩、mipmap、动画等导入参数,最后**SaveAndReimport()把修改写回 meta 文件,触发重新导入,让新配置生效**。
当然,这里我有一些疑问:
- 什么叫「钩子基类」?
- 钩子(Hook) :引擎内部本身跑一套完整资源导入流程,引擎代码写死了一套执行顺序,但是引擎预留了 "空位",允许我们写自己的代码塞到引擎流程的指定节点执行,这种机制就叫钩子。
AssetPostprocessor就是 Unity 给我们写好的编辑器基类 ,不是接口。我们继承这个类,重写里面约定好的回调函数(OnPreprocessTexture、OnPreprocessModel这类),我们永远不会手动 new 这个子类,也不会手动调用这些 OnXXX 函数 。资源导入事件发生时,Unity 引擎会自动反射找到项目里所有继承AssetPostprocessor的 Editor 脚本,自动实例化我们的子类对象,在导入流水线的对应时机,调用我们写的回调函数。 - .meta 文件从哪里来?.meta 到底存的是什么?
- .meta 和外部资源(png/fbx)成对出现,meta 保存这份资源的全部导入配置,也就是各个 Importer 的参数:纹理压缩、Mipmap 开关、fbx 动画导入选项、模型缩放、材质导入设置等等。当你第一次把外部资源拖入 Unity Project 窗口: Unity 识别文件后缀,自动创建对应 Importer 实例(png→TextureImporter,fbx→ModelImporter),用引擎默认参数,生成一份.meta 文件,和资源同目录。
- 资源导入的流程到底是怎样的?
- 初次导入时,外部原始资源文件进入项目,此时还不存在 meta,Unity 根据资源后缀创建对应 Importer 实例并加载默认参数,随后触发 AssetPostprocessor 钩子回调,我们在回调中修改内存里 Importer 对象的参数,导入流程结束后把 Importer 参数序列化生成 meta 文件落盘,再依据 Importer 配置解析原始文件产出引擎可用资源;非初次导入时,磁盘上已经存在资源和配套 meta,Unity 读取 meta 将配置反序列化填充到新建的对应 Importer 实例中,再执行 AssetPostprocessor 钩子,我们可以覆写 Importer 内存参数,若调用 SaveAndReimport 就会把改动写回 meta 磁盘文件并触发一轮完整重导入,不调用则本次导入使用修改后的内存参数,但改动不会持久保存到 meta,下次导入依旧读取磁盘旧 meta 配置。
OK,概念理清楚之后,我们回到这份伪代码来,课程中提出了一些新的问题:
一是即使同类型的资源可能也会有不同的导入设置,我们要如何通过一套代码实现多套配置 ?使用无限的条件判断是愚蠢的,我们需要通过某种特定的方法对同类资源进行分类如不同的文件名,那这个时候我们在资源导入的方法中就需要对路径进行管理 ;我们还需要做导入资源的设置的持久化 (是设置的持久化不是资源的持久化),一般通过继承SO文件实现;
二是我们完成了一版导入设置之后后续可能还要进行修改,如何处理?课程中提到有一种处理方法是将导入的工作流与打包结合起来,其实就是在打包发布前再次检查包中的资源的导入设置是否与当前一致,这样非常省事,但是导入设置出现问题要一直等到打包前才能发现,非常的不及时;于是up主提出了一个新的概念:

AssetsModifiedProcessor是 Unity 的试验性编辑器钩子,它不介入资源导入解析流程 ,而是在磁盘上 Assets 目录下文件发生增、删、改、移动这些文件系统事件之后触发OnAssetsModified回调,会把变更资源路径做分类给到四个入参:changedAssets 是内容被修改的资源路径数组,addedAssets 是新增资源,deletedAssets 是被删除资源,movedAssets 携带移动前后路径信息,适合做资源变更后的后置业务,比如资源变更日志记录、自动校验资源目录规范、更新外部索引表、刷新自定义工具缓存。
看起来很复杂,说白了就是当unity项目的asset目录下有资源被增删查改了的话就会自动回调实现了这个接口的方法,所以当我们需要去修改资源导入设置的话在这里做后续的处理非常适合。
介绍完如何手写资源导入工具,我们接着来看如何利用preset来执行资源导入:

具体在哪里使用这个内容呢:

点击create的话可以:

Preset 说白了就是 Unity 的导入配置模板文件 ,把一套 Importer(纹理、模型等)的导入参数打包存成.preset资源文件,可以复用这套配置。你可以手动把调好的 Mipmap、压缩格式、FBX 动画参数存为 Preset,既可以在 Inspector 面板点一键应用到选中资源;也能在AssetPostprocessor代码里加载 Preset,直接把整套配置赋值给 Importer,不用一行行手动写importer.xxx = xxx。
在Project Manager中也有专门的地方方便你导入preset:

这样就可以在导入其他的资源时复用导入设置了,但是这个做法的问题是依然没有解决后续修改导入资源设置的需求。这个时候还是需要之前提到的Asset Modified Processor,文档中也有对应的代码:

该代码遍历指定文件夹,检索目录下全部 preset 模板文件,将 preset 的路径与目标 Importer 类型信息合并计算生成哈希值,通过 RegisterCustomDependency 注册自定义依赖标识,当文件夹内 preset 发生增删改动时哈希便会变化,再配合 AssetDatabase.Refresh 触发数据库刷新,以此为前提,在 AssetPostprocessor 中声明资源依赖该自定义标识后,preset 模板修改就能驱动相关资源自动重新导入,重新应用最新的 preset 配置,解决默认情况下 preset 改动不会自动更新已使用该模板资源的问题;放入editor中后就可以实现一旦原资源发生变化就更新导入设置,而人为的修改导入设置不受影响的效果。
至于最后的asset graph工作流的话,我不是很感兴趣,所以我直接跳过了。
编辑器创建资源优化
我们进入下一个优化的部分,也就是图中的第二部分:

我们现在从几个方面来介绍这部分的内容:
场景Scene

Unity的场景是后缀为.unity的文件,作为承载游戏内容的容器,保存着编辑器内摆放的游戏对象、组件、层级与各类配置数据,它不会存储模型贴图这类外部资源的原始数据,只会记录对应的资源引用,打开场景时Unity依靠这些引用还原完整关卡内容。

编辑器中的一个场景长这样,课程这里介绍了一些基本的操作,我这里就不复述了,比较基础。
接下来介绍了几个场景结构的设计原则:

场景节点深度太深不好找具体的某个object,不是一定需要父节点的对象优先放根节点方便查找。

无需多言,能用prefab的地方请使用。
这里还提到了一点就是我们可以用记事本打开unity文件,可以看到:

这进一步揭示了所谓的Unity的.unity场景、预制体、preset这类资源底层是YAML格式文本序列化文件 ,所以可以直接用记事本打开查看,它把场景里的光照设置、剔除配置、游戏对象组件数据全部序列化成可读YAML文本,遇到外部资源不会存资源本身二进制,只保存guid+fileID引用用来关联贴图材质等外部资产,Unity打开文件时再解析这套YAML文本重建内存里的对象;但不要手动乱改文本,格式写错会直接损坏场景文件,二进制模式的Unity资源则不能用文本打开。
课程中通过向场景文件中添加prefab文件与添加gameobject文件,对比用文本打开的Unity文件可以直观的看到内存占用的差异,我这里用自己的文件展示一下:


--- !u!1001 &1264603907
PrefabInstance:
m_ObjectHideFlags: 0
serializedVersion: 2
m_Modification:
serializedVersion: 3
m_TransformParent: {fileID: 0}
m_Modifications:
- target: {fileID: 1655433686935368269, guid: 824417fcaea34f14e83f2fbfefd73131, type: 3}
propertyPath: m_Name
value: Cube (1)
- target: {fileID: 8509690270156427077, guid: 824417fcaea34f14e83f2fbfefd73131, type: 3}
propertyPath: m_LocalPosition.x
value: 152.45862
......位置、旋转等多条修改记录
m_RemovedComponents: []
m_RemovedGameObjects: []
m_AddedGameObjects: []
m_AddedComponents: []
m_SourcePrefab: {fileID: 100100000, guid: 824417fcaea34f14e83f2fbfefd73131, type: 3}
没有 把 Cube 的 GameObject、Transform、MeshFilter、MeshRenderer、BoxCollider 全部写在场景 yaml 里,只写PrefabInstance,记录外部预制体 GUID,以及哪些字段做了修改(名字、坐标)。 完整对象数据存在外部.prefab文件,场景只存 "引用 + 增量改动"。
--- !u!1 &1169594465
GameObject:
m_Name: Cube
......
--- !u!65 &1169594466
BoxCollider:
......
--- !u!23 &1169594467
MeshRenderer:
......
--- !u!33 &1169594468
MeshFilter:
......
--- !u!4 &1169594469
Transform:
m_LocalPosition: {x: 152.45862, y: 16.289751, z: 183.73164}
把 Cube 的 GameObject 以及全部组件完整序列化写入场景.unity文件,不再引用外部 prefab。
前者场景通过PrefabInstance保存预制体引用和增量修改,数据依赖外部prefab便于同步更新;后者将物体完整展开序列化进场景,切断预制体关联,二者打包运行性能一致,差异主要体现在编辑器工程维护与文件存储。

第三个点是DontDestoryOnLoad里的对象要慎选,这里引申一下两个概念:
DontDestroyOnLoad 并不是给对象设置布尔标记,而是将 GameObject 移动到 Unity 引擎内部一个用户不可见的常驻特殊场景中,在 Single 模式切换场景时普通场景的物体会全部销毁,而移入该特殊场景的对象就得以保留,前提要求对象必须为根物体。
Additive 是场景的叠加加载模式,和默认 Single 模式加载新场景会卸载销毁旧场景全部内容不同,Additive 加载不会清除现有场景,会把新场景的对象追加到游戏世界,内存中同时存在多个独立场景,可以单独卸载其中任意一个场景。

给频繁访问的节点添加 Tag,主要优化对象查找性能 ,FindWithTag依靠引擎 tag 索引避免遍历整个层级树,减少查找的 CPU 消耗,但不优化渲染与内存。
给静止不动的节点勾选 Static 标记是渲染与烘焙层面的优化,会让物体参与静态批处理降低 DrawCall、光照烘焙省去实时光照计算、遮挡剔除、导航网格预计算等,显著降低 CPU 和 GPU 开销,但静态物体运行时不能做位移旋转,否则优化失效。

预制体Prefab
上一节的课程我们讲解了部分场景优化的思路,其中提到了能使用prefab的资源就优先使用吗,这里展开一节详细说说prefab。

预制体(Prefab)是 Unity 的资源格式(.prefab),它保存一套完整的 GameObject 树形结构:GameObject 自身、所有组件、组件字段、Transform 层级、引用的材质 / Mesh 等资源引用,编辑器里把 Hierarchy 物体拖拽到 Project 窗口,就会生成独立的.prefab资源文件;场景里的实例不再完整复制全部数据,分两种形态:原始预制体资源 、场景预制实例 (PrefabInstance)。

prefab最大的好处就是可以实例同步,我们修改prefab本身就可以同步到所有copy,所以对象管理非常方便。
预制体还可以嵌套,也就是预制体内部还有预制体。

嵌套预制体主要优势集中在编辑器工程层面,方便资源复用、模块化拼装、精细配置 LOD 和遮挡剔除;代价是资源依赖复杂,容易破坏静态合批造成 DrawCall 上涨,不适合大规模远景,需要团队规范约束,它本身不会自动带来运行性能提升,使用不当反而会产生性能缺陷。
然后介绍一下预制体变体的内容:

预制体变体类似C++的父类与子类,它基于基础预制体派生,可以在变体上做自定义修改;基础预制体发生改动时,变体中没有被手动覆盖的属性会同步更新,已经被变体显式覆盖修改过的属性则不受基础预制体影响,而变体自身的修改永远不会反向同步到基础预制体。
UGUI
啊什么,居然又是UGUI,看来性能优化怎么都绕不开UI层面呢,毕竟一个游戏一定会有UI,且一定非常多且杂,那么优化空间就一定很大。
UP主上来就将UI性能主要分为了四类问题啊:

Canvas Re‑batch时间过长,就是一个Canvas里面塞了太多UI控件,只要有一点改动,CPU就要一次性处理一大堆UI做合批重建,单次干活耗时就会很高;Canvas Over‑dirty属于脏标记泛滥,UI频繁发生变动,一帧之内反复触发合批重建,哪怕每一次重建不算慢,但架不住次数太多,照样吃CPU;生成网格顶点时间过长,是图片、文字这类UI组件在生成渲染用的顶点数据时计算太慢,大量文字或者复杂样式UI就容易出现这个问题;Fill‑rate overutilization是GPU这边扛不住,UI层层叠在一起,很多像素被反复绘制,超出了显卡的像素处理能力,UGUI做性能优化,本质就是分别搞定这几件事:降低单次合批的耗时、减少合批触发的次数、减轻生成顶点的计算负担、缓解GPU的像素绘制压力。
我们来一个一个复习一下:

Canvas触发Re‑batch重建时,首先按照UI的层级深度对所有UI控件排序,接着判断控件之间的互相覆盖关系,最后依据材质、图集的情况分组完成合批,生成最终的UI绘制批次,整个过程都在CPU执行,Canvas里UI越多,这套流程耗时就越高。

可以看到我们的canvas有一个按钮和一个输入文本框,他们分别占用一个batch,因为他们的材质不同,无法合批。

接着我们分别在两个UI元素下再添加同样的UI(按钮下面加按钮,输入文本框下面加输入文本框),依然还是两个batch,这就是所谓的UI合批。
可是假如我们调换顺序呢?

能发现,居然又变成了四个批次,很简单,在canvas上的顺序不同了,这也体现出UGUI优化很基本的一个方向,相同的UI尽量按顺序放在一起,可以合批处理。
然后是关于UGUI的渲染方面的问题:

UGUI使用半透明Transparent渲染队列,绘制顺序是从后往前,依靠Alpha混合实现透明效果,UI层层叠加就会产生大量Overdraw过度绘制,像素反复渲染,加重GPU片元着色器压力;另外如果SpriteAtlas图集利用率差,图集中存在大片完全透明像素,GPU依旧会对这些无效透明像素做纹理采样,既拉高片元着色器负载,也浪费纹理采样带宽,同样带来性能损耗。
用人话说就是,UGUI 半透明队列从后往前画,UI 互相叠加时同一个屏幕像素会被多次绘制混合,叠加层数越多 GPU 压力越大;图集采样贴图的时候,哪怕图集里那块区域是完全透明空白像素,GPU 依旧会执行纹理采样、跑片元着色器,白白消耗算力,所以图集要尽量把图紧凑打包,减少内部大片透明空白像素,避免做很多无效采样计算。
然后是Re-Build过程:

rebatch是rebuild中的一部分,当UI被标记为脏之后就会触发UGUI的Rebuild重建流程,首先执行Layout Rebuild布局重建 ,完成布局组件计算,确定各个UI控件最终的位置和尺寸,接着执行Graphic Rebuild图形重建 ,生成各个UI元素的网格顶点数据,之后才会执行作为Rebuild其中一环的Re‑batch,对UI按深度排序、判断覆盖关系并根据材质完成合批,整理出绘制批次,最后把处理好的数据提交给GPU完成渲染输出。
一些UI的使用准则:

然后还提出了一些UGUI射线检测的优化思路:

当然,我们要先搞清楚射线检测的主要性能开销来源:
UGUI射线检测的主要开销集中在主线程,当点击或者触摸屏幕时,EventSystem会从鼠标/触摸点出发,按照层级从前往后遍历Canvas下所有可以接收射线的UI对象,逐个做矩形碰撞判断,找出命中的UI;性能开销主要来自,一是Canvas下参与射线检测的UI节点数量太多 ,每一次输入事件都要遍历大量物体做碰撞校验;二是很多不可见、被遮挡的UI依旧开启了raycastTarget,白白参与遍历判断 ;三是Mask、RectMask2D裁剪组件会额外增加判断逻辑,嵌套多层Mask会进一步放大开销;还有CanvasGroup的blocksRaycasts、Graphic组件的raycastTarget开关使用不当,大量无效UI参与射线检测,每一次鼠标或者触摸输入都会在主线程执行这套遍历逻辑,输入频繁或者UI数量庞大的时候就会产生明显CPU消耗。
下一个部分是UI的字体部分:

这里讲UI文字的两个性能坑:一是尽量别让文字组件的包围框互相重叠,就算用的是同一份字体,重叠也会打断合批,增加绘制批次;二是Text组件在改文字、父物体变动、自身或父物体开关显隐的时候,都会重新生成文字网格,触发UI重建消耗主线程性能,开发里要避免频繁改文字、反复开关文本相关对象。
然后课程在这里补习了一下UI字体相关的内容,比如导入设置:

各个参数的含义:
| 参数 | 含义 |
|---|---|
| Font Size(基准字号) | 只影响动态字体的初始渲染参考,运行时 Text 组件仍可随意改字号 |
| Rendering Mode(渲染模式) | 控制抗锯齿效果,Smooth 是平滑,Hinted 针对小字号做优化 |
| Character(字符模式) | Dynamic 动态模式:用到哪个字才实时生成字形,包体小但运行时消耗 CPU;Set of Characters:打包时预生成指定字符集,运行时性能更好 |
| Ascent Calculation Mode(基线计算) | 计算文字基线高度,一般保持默认即可 |
| Use Legacy Bounds(旧版包围盒) | 使用旧版字体包围盒算法,新项目不勾选 |
| Should Round Advance Value(字距取整) | 开启会把字间距取整,避免文字细微抖动 |
| Incl. Font Data(是否打包字体原始数据) | 勾选把字体原始数据打进包;不勾选依赖系统自带字体,需注意版权风险 |
| Font Names(字体名称标识) | 字体的名字标识 |
然后是有关动态字体与字体图集的部分:

动态字体是UGUI旧版里的Dynamic字体模式,ttf、otf字体源文件会打进安装包,打包的时候不会提前生成好文字贴图,游戏运行过程中,用到哪个字,就临时把这个字生成到贴图上。字体图集Font Texture就是动态字体在运行时生成的贴图,把各个文字字形打包在这张图里,Text渲染文字的时候,就采样这张贴图来画出字符,逻辑和Sprite精灵图集差不多。
动态字体是运行时根据激活的 Text 组件内容实时生成字体图集,每种字体各自维护一套独立图集;字号、大小写、粗斜体不同,都会生成单独的图集,会拉低图集利用率,少见特殊文字建议用图片或者预生成静态字体资源;当要显示的字符不在现有图集里、或者图集空间不够时,就会触发图集重建扩容,而且图集只会不断增加字符不会自动清理;可以用Font.RequestCharactersInTexture接口提前预加载需要的字符,减少游戏运行中临时重建图集的开销,优化启动与运行性能。
课程中还建议以TextMeshPro作为UI文本的主要方案,这里可以引申一下两种文本组件的差异:
| 对比项 | UGUI‑Text(旧文本) | TextMeshPro(TMP) |
|---|---|---|
| 底层技术 | 动态字体图集贴图 | SDF 有向距离场 |
| 缩放效果 | 放大容易模糊 | 任意缩放保持清晰 |
| 图集机制 | 字号 / 粗斜体生成不同图集,只增不减 | 单张 SDF 贴图支持多字号 |
| 排版能力 | 基础功能,能力弱 | 支持描边、连字、字重、富文本等丰富效果 |
| 合批表现 | 包围盒重叠容易打断合批 | 合批更稳定 |
| 资源准备 | 直接导入 ttf/otf 即可 | 需要生成 FontAsset 字体资源 |
| 性能问题 | 频繁改文字易触发图集重建 | 运行时字形重建开销更小 |
| 项目建议 | 新项目不推荐 | 官方推荐,优先选用 |
有向距离场(SDF)简单说就是不存文字像素图片,存每个点离文字轮廓边缘的距离数据,靠算法实时算出文字模样,所以放大缩小都不会糊。原生UGUI Text靠运行时生成像素字体图集,字号、样式一变就可能出新图集,还容易打断合批,改文字会触发图集重建;TMP用SDF技术,一张字体资源就能适配各种字号,文字缩放依旧清晰,排版特效多,合批更稳,只是要额外生成FontAsset字体资源,新项目优先选TMP。
最后给出了几条UI优化建议:

OnDemandRendering是Unity的按需渲染API,它可以把渲染帧率和游戏主循环拆开,脚本、输入、物理依旧全速跑,只是静态UI界面时降低画面渲染次数,用来省电降温,用户点击交互的时候再切回满帧保证响应;优先裁剪UI Shader是因为UI着色器内置了Mask、Clip等一大堆变体关键字,很多项目根本用不到这些特性,多余的变体既增大包体,运行时还会增加GPU片元着色器计算负担,UI又是屏幕上绘制占比最高的部分,把没用的裁剪、遮罩相关变体剥离掉,收益会比裁剪3D着色器更加明显。
物理

unity中的物理解决方案如上图所示。
Box2D是专门对应二维物理的,实现简单,性能也不差,但是只支持二维;PhysX是Unity默认的3D物理引擎底层,诸如Gameobject,Rigidbody底层都是基于这个实现的,支持诸如刚体,碰撞检测,破坏等物理效果,但是大批量物理计算效率一般;Unity Physics 是 DOTS‑ECS 体系下自研的数据驱动物理实现,专为海量实体多线程并行设计,它和 Havok Physics for Unity 共用同一套 ECS 物理 API 可以直接切换,并且 ECS 整套物理和传统 GameObject 的 PhysX 物理世界互相隔离,二者不会产生碰撞交互。
课程里介绍PhysX时,有说到一个概念叫做非确定性物理库:PhysX 默认属于非确定性物理模拟,相同初始条件在不同环境下物理结果可能出现偏差,虽然 PhysX 本身提供确定性配置,但需要关闭多线程、严格固定时间步、舍弃部分特性,Unity 传统 GameObject 体系默认没有开启这套确定性模式,使用成本很高。
而 DOTS 生态的 Unity Physics 原生就支持确定性物理,Havok 同样具备确定性选项,更适合需要跨设备物理结果一致的场景,但是这两个物理库又没有提供完整的物理模拟效果。服务器物理更偏向 DOTS‑ECS 物理方案,关键原因是 Unity Physics 原生确定性、Havok 也支持确定性模式,能够保证多台服务器、不同 CPU 环境下物理推演结果完全一致,方便做状态同步与回滚校验。
大型 Unity 项目物理方案一般会按客户端表现物理、服务端逻辑物理两套分开处理,客户端绝大多数业务依旧沿用 GameObject+PhysX 做表现层物理,处理碰撞、角色、布料、交互物件,接受非确定性带来的微小偏差;服务端逻辑物理优先选用 DOTS 下的 Unity Physics 或者 Havok 获取确定性模拟,保障多服务器实例推演结果一致,避免不同机器产生不一样的战斗、弹道、碰撞判定,不会直接用 GameObject 的 PhysX 做服务端物理,因为开启确定性代价极高限制多;部分项目会做折中方案,不用完整服务端物理模拟,改用预测 + 校验,只在服务端做简单射线、球体检测,把繁重物理计算放在客户端,再由服务端做结果校验拦截作弊;如果项目有大量碎片、子弹这类海量物理实体,客户端也会局部引入 ECS 物理做这一部分,和主 PhysX 物理世界隔离分开运行,而载具、复杂堆叠物体这类对物理精度要求高的模块,ECS 侧优先选 Havok,追求海量物体性能就选 Unity Physics,整套方案要注意两套物理世界不能互通交互,需要自己写代码做数据互相同步。
接着我们来学习如何实际在项目中针对物理部分进行优化,打开project settings中的physics(如果是2D游戏就打开physics 2D)。

这里最重要的就是碰撞矩阵,他决定了具体哪些层级的物体与物体之间参与碰撞检测,如果全选的话,CPU压力会很大。

然后再介绍几个可能有用的参数:

Auto Sync Transforms:保持关闭,开启会每帧把 transform 变化立刻同步物理,产生额外开销;只有代码直接修改 transform.position 带动刚体才需要打开,正常用 AddForce 不用开。

Reuse Collision Callbacks :建议勾选,复用 Collision 结构体,消除 OnCollisionEnter 带来的 GC 堆分配,注意不要缓存 Collision 对象到回调外部。

**Default Solver Iterations(位置迭代,当前 6)**负责修正刚体的位置、穿透、堆叠接触,解决物体互相卡进对方体内、堆叠物体下陷穿模的问题,迭代次数越高位置修正越精准,但 CPU 开销上升;
**Default Solver Velocity Iterations(速度迭代,当前 1)**专门处理速度、冲击力、反弹、摩擦,影响碰撞之后物体弹开、抖动的表现,迭代越高碰撞后速度、反弹效果越稳定。

Broadphase Type是PhysX的粗碰撞筛选(宽阶段)算法,物理模拟分为宽阶段Broadphase和窄阶段Narrowphase,宽阶段先用物体AABB包围盒快速筛掉完全不可能碰撞的对象,只把有可能相交的配对送入开销更高的精确碰撞(窄阶段),直接影响物理CPU消耗;Sweep And Prune Broadphase是默认选项,适合绝大多数普通游戏,物体运动平稳、增减不频繁场景性能好,但大量物体频繁生成销毁时性能会下滑;Multibox Pruning Broadphase是多盒分区,把世界切分成多个盒子分区,适合物体分布在多个离散区域、大世界场景;Automatic Box Pruning自动盒裁剪,动态调整分区,适合物体频繁大量创建销毁、大量子弹碎片瞬时生成的战斗场景,代价是会有少量额外内存开销,一般项目直接保持默认Sweep And Prune Broadphase即可,只有遇到大量物体频繁生成销毁的性能瓶颈才切换另外两种。

Simulation Mode 控制 PhysX 物理模拟的执行时机,FixedUpdate 为默认模式,物理跟随 FixedUpdate 以 FixedTimeStep 固定步长运行,保证物理模拟时间粒度稳定;Update 模式把物理模拟放在渲染 Update 中执行,物理步长跟随渲染帧率波动,刚体效果容易抖动,一般不使用;Script 模式下引擎不再自动执行物理,需要我们手动调用Physics.Simulate传入时间增量驱动物理计算,适合服务端物理、录像回放这类需要完全掌控物理时间流的场景。
然后还有一些跟物理相关的参数:

Fixed TimeStep 定义了每一次 FixedUpdate 的时间步长,物理模拟跑在 FixedUpdate 中,PhysX 物理的每一步模拟时长严格使用这个固定值,不会因为帧率高低改变单步模拟时长;但如果主机卡顿、渲染帧耗时变长,引擎会在同一渲染帧内堆叠执行多次 FixedUpdate 来追赶时间,性能太差时甚至会出现丢 Fixed 步的情况,所以 FixedUpdate 单步时间固定,但调用总次数依然受主机性能影响,这也是做物理、角色逻辑放 FixedUpdate 而表现动画放 Update 的根本原因。
Maximum Allowed Timestep 是最大允许时间步,用来做卡顿保护,当机器严重掉帧,真实时间差过大时,Unity 不会无限堆叠大量 FixedUpdate 去追赶时间,单帧最多允许累积这么大的时间用来跑 Fixed 步,超出的部分会直接丢弃,防止游戏陷入 "死亡螺旋":即渲染卡顿→疯狂执行大量 FixedUpdate 物理逻辑→CPU 负载更高→更加卡顿。
然后是collider与rigidbody部分的内容:

首先我们明确一个基本的概念是,collider只负责做碰撞的检测,回调碰撞发生,碰撞中以及碰撞离开这三个函数,并不做后续的物理模拟,所以如果你只是需要碰撞的结果,只需要collider就好,不需要rigidbody。
然后如果你想要触发 OnCollisionXXX 物理碰撞回调,两个物体至少其中一个必须带 Rigidbody;但 OnTriggerXXX 触发器回调只需要 Collider 勾选 Is Trigger,不强制需要 Rigidbody。

一句话就是,能不用MeshCollider就不用,如果确实需要使用,可开启 projectsettings中的Player里的 Prebake Collision Meshes,打包时预烘焙碰撞网格来优化。

Rigidbody部分的话,首先明确isKineMatic与RigidBody的差异:

Is Kinematic是Rigidbody组件上的开关,勾选之后刚体就变成运动学刚体,PhysX物理引擎不再对它做物理求解计算 ,普通Rigidbody由物理引擎驱动位置旋转,受重力、力、碰撞冲量影响;运动学刚体不受重力、外力、物理碰撞的驱动,完全由脚本直接修改Transform来控制移动旋转,它依然保留Rigidbody身份,可以产生碰撞回调,但是物理引擎不会计算它的碰撞反馈,不会被其他物体撞开,适合门、机关、移动平台这类需要碰撞回调但不想被物理力影响的物体,运动学刚体移动时不会更新PhysX物理场景,开销远低于普通动态刚体,不过它只能和动态刚体产生OnCollision回调,两个运动学刚体互相接触不会触发碰撞回调。

简单的说,又想要对物理世界其他物体产生影响,又想节省性能的话可以考虑这个。

优先选用 NoAlloc 版本接口消除 GC 开销,调用时传入图层掩码做过滤,射线检测限定最大查询距离减少无效计算;面对大批量射线检测场景,可以使用 RaycastCommand 结合 JobSystem 做批量处理,把计算分摊到多核线程提升性能。
动画

Unity 一共有三套动画方案:旧版Animation组件、Mecanim 状态机Animator组件,以及自由度更高的底层Playable API。

**这是Unity 动画系统的层级依赖关系,**Mecanim(Animator 状态机)底层是基于 Playable API 实现的,Playable API 作为底层动画图框架向上支撑两套能力,一边是 Animation C# Jobs 动画作业系统,另一边是 Timeline 时间线;而 Animation C# Jobs 又继续向下提供底层能力,支撑 Animation Rigging 程序化骨骼绑定以及 Kinematica 高性能动画引擎。
接着我们来逐个了解一下。

Legacy Animation 组件直接读取 AnimationClip 的曲线采样结果,直接赋值物体 Transform,没有状态机、混合树与人形重定向能力,逻辑简单单片段播放性能好,只适合简单物件动画,现已基本弃用。

Legacy 老动画播放单段 Clip 效率更高,原因是它直接采样动画曲线,直接赋值给 Transform,没有 Mecanim 那套状态机、混合图的额外开销;动画里缩放曲线的运算开销,要高于位移和旋转;常数曲线数值固定不变,引擎不会每帧重复写 Transform,性能更好。

Animator 组件同样使用 AnimationClip,但不会直接写变换,而是把 Clip 交给由 Playable API 驱动的状态机系统,完成状态切换、动画混合、图层叠加,配合 Avatar 实现人形动画重定向,支持根运动、混合树等复杂角色动画能力,是 Unity 项目的主流动画组件。

Animator的内部更新入口为UpdateAvatars,一帧之内会先执行状态机更新,完成状态跳转并触发状态机进出回调,随后处理Playable图完成动画片段采样与混合,依次派发动画事件、执行StateMachineBehaviour回调以及OnAnimatorMove根运动回调,接着经过ProcessAnimation算出骨骼变换数据,再执行OnAnimatorIK的IK逻辑,IK改动骨骼后会重新执行WriteTransform把变换写入骨骼对象,最后执行WriteProperties完成材质等非Transform属性的动画赋值,整套流程受Animator的UpdateMode控制,独立于C#的Update。

开发中不要直接使用字符串去查询 Animator 的参数与状态,因为字符串会带来运行时哈希查找开销还容易出现拼写错误 ,应提前通过Animator.StringToHash获取哈希 ID 再调用接口;
优先使用曲线标记替代传统动画事件,传统动画事件在动画过渡混合场景容易出现漏触发、时序错乱,曲线标记跟随动画采样,时序更加稳定,适合处理打击帧、脚步音效这类关键时间点逻辑;
可以借助Animator.MatchTarget目标匹配函数,引擎会自动微调动画进度完成骨骼和目标位置对齐,实现跳跃落地、手抓物体等效果,减少手写 IK 矫正代码;
性能优化上把 Animator 的 CullingMode 设置为 Based On Renderers,同时关闭 SkinMeshRenderer 的 Update When Offscreen 属性,当角色完全离开相机视野不可见时,就会暂停整套骨骼动画更新降低 CPU 消耗,但要注意开启后动画事件、IK 回调都会停止,如果角色即便不可见也需要运行 AI 或者根运动位移逻辑,则不能使用这套裁剪方案。
两个动画系统的对比表格如下:
| 对比维度 | Legacy Animation 旧动画组件 | Animator (Mecanim 现代动画组件) |
|---|---|---|
| 依赖资源 | AnimationClip | AnimationClip+AnimatorController+Avatar |
| 核心逻辑 | 直接采样曲线赋值物体 Transform | 底层基于 PlayableAPI,状态机驱动,做动画混合叠加 |
| 状态机 / 混合树 | 不支持 | 完整支持状态、过渡、Layer、BlendTree 混合树 |
| 人形重定向 | 不支持,动画绑定固定模型 | 支持 Avatar 映射,一套动画复用给多个人形角色 |
| 根运动 RootMotion | 支持有限 | 完整支持,可配合 OnAnimatorMove 处理角色位移 |
| IK 能力 | 无 | 支持 OnAnimatorIK 做骨骼 IK 矫正 |
| 运行访问方式 | 组件直接播放 Clip | 推荐使用 HashID 访问,禁止字符串频繁查询 |
| 单片段性能 | 开销小,无中间混合层 | 开销略高,存在状态与混合计算 |
| 动画裁剪优化 | 几乎无裁剪能力 | 支持 CullingMode,视野外可暂停骨骼更新 |
| 扩展能力 | 几乎不可扩展 | 可对接 Playable、Timeline、Animation Job |
| 适用场景 | 简单物件位移缩放动画,已淘汰 | 人形角色、复杂角色动画逻辑,项目主流方案 |
具体何时使用哪个动画组件呢,基本的准则就是:

Animation可以直接把任意对象属性制作成动画片段,Animator则是把动画片段交由状态机来编排管理;二者性能存在临界点,动画曲线数量少的时候旧Animation更快,曲线数量多则Animator性能占优,少CPU核环境下Animation更合适,多核环境Animator发挥更好 ;需要注意Animator控制器图内全部状态引用的动画片段都会加载进内存,大量动画节点会带来较高内存开销。
我们接着来学习Playable API部分的内容:

Playable API是Unity的底层媒体图形编程接口,核心是构造PlayableGraph播放图,由Playable处理节点完成动画采样、混合、时间调控,再经由PlayableOutput把运算结果输出到Animator,Animator与Timeline底层都基于该API实现,它支持在运行时用代码动态搭建、修改动画逻辑,不受编辑器状态机的固定节点约束,适合实现技能片段拼接、自定义动画过渡这类动态动画需求,但需要开发者手动管理图的创建与销毁,处理不当容易内存泄漏,没有可视化调试,普通角色动画优先使用Animator,状态机无法实现的场景才直接手写PlayableGraph。

简单少量曲线的UI、Transform动画,可用旧Animation或者Dotween这类补间库;骨骼少、动画资源不多、对动画混合要求低的蒙皮角色可以选用Legacy Animation,需要把控曲线总量;动画和逻辑交互多、动画资源不多的项目直接使用Animator状态机;动作游戏这类动画资源庞大、动画混合与高级效果要求高的项目,适合Animator搭配Playable API、Timeline组合方案。
性能优化之道
这个部分其实就是提出一些性能优化的基本方法论。

很好理解,为什么要性能优化,因为你的游戏玩起来卡,你想要你的游戏不卡;但是你不能为了不卡直接把游戏干崩溃了,比如原来是稳定30帧,你不能变成时而60帧时而10帧,这还不如稳定30帧;你也不能原本支持PC和安卓,优化完后有一边支持不了了,你还得考虑优化的成本是不是太大了,是简单的修改部分代码和设置还是直接重构代码的复杂度不是一个量级的。

老生常谈,当然这里必须提出一个观点就是,每一个具体的游戏或者说具体的项目的优化都是定制的,没有一个可以解决全部问题的通用方案,这也是为什么性能优化往往最难做也最消磨时间,因为他必须结合测试来做。

这里提出了四个方向,CPU,GPU,带宽和内存。

这个就是一个个人经验的总结,用到的时候再来看吧。

升维降维是通过增加或者删减辅助信息来简化问题,升维增加辅助数据降低算法逻辑复杂度,降维压缩信息减少计算与存储开销;空间与时间转换就是经典的时空权衡,要么用内存缓存预计算结果换取运行更快,要么复用内存牺牲计算速度;量纲转换是更换数据表达形式、坐标系或者单位,规避复杂运算,游戏开发里坐标变换、定点数运算都是典型体现。
性能优化实战
性能总览与瓶颈定位
这里开始拿我们的项目实际测试,up主手上有两款手机,非常遗憾我这里并没有,所以我就用模拟器来测试吧。

首先是打包的时候得勾选Development Build和Autoconnect Profiler,这两个选项可以让我们在真机烧录的时候看到具体的调试日志,并把对应的烧录结果传到unity这边的profiler来。
OK,现在我们开始在Mumu模拟器上运行这个apk包:


三十帧左右。

我们现在来学习怎么看profiler,以及如何利用profiler定位问题。

可以看到主要的消耗是浅绿色大块和黄色大块,其中浅绿色代表Rendering,黄色代表Vsync,其中Vsync代表我们设置的帧上限比较低,现在的机子可以轻松泡完CPU流程,然后就会花大量时间去等待渲染完成。
我们可以关闭Vsync部分,看到纯粹的CPU耗时:

可以看到其实我们这个项目是可以做到60帧左右的,同样的,大段的浅绿色色块,代表我们主要的性能瓶颈就是在Rendering部分,因此假如我们想要优化,就优先从渲染入手。
我们还要看看profiler下半部分的内容TimeLine:

首先看Main Thread,可以看到CPU跑一帧的时间是33ms,对应30帧,这33毫秒里:

该帧主线程总CPU耗时33.03ms,GPU耗时模拟器无法采集,PlayerLoop内大部分开销落在PostLateUpdate.FinishFrameRendering的19.63ms,其中Gfx.WaitForPresentOnGfxThread与Semaphore.WaitForSignal占14.01ms,为主线程等待渲染线程完成工作的同步阻塞,URP实际渲染逻辑Inl_UniversalRenderTotal仅5.46ms,渲染任务很快执行完毕,后续主线程进入WaitForLastPresentationAndUpdateTime和WaitForTargetFPS共计10.55ms的休眠等待,整体帧耗时主要来自线程同步、帧提交等待,并非渲染管线本身过载。
可以看到三个等待函数,这里引申一下:

Render Thread的情况是:

该渲染线程被包裹在Gfx.PresentFrame(24.23ms)内部,底层使用 Vulkan 图形设备,绝大部分耗时消耗在GfxDeviceVK.Present帧提交环节,中间经过WaitForSignal信号量同步后才执行 URP 管线的渲染命令与后处理工作,实际绘制指令执行时间占比不高,大部分时间都耗费在模拟器 Vulkan 驱动的帧呈现、交换链等待上。
综合整套Profiler数据与模拟器运行环境来看,CPU侧业务逻辑开销很低,URP渲染主线逻辑仅5ms左右,主要瓶颈来自MuMu模拟器Vulkan后端带来的帧提交与交换链等待开销,表现为主线程大量时间耗在Gfx.WaitForPresentOnGfxThread信号同步、渲染线程耗在GfxDeviceVK.Present,属于模拟器虚拟显卡的等待阻塞而非游戏本身绘制过重,项目本身有600个不透明DrawCall、开启SSAO、运动向量与全套后处理,这些功能会在真实安卓设备上带来实打实的GPU负载,真机上潜在瓶颈大概率会转移到GPU,另外VSync垂直同步也会拉高整体帧耗时,当前33ms帧时间大多是模拟器环境放大出来的,不能直接作为真机性能结论。
渲染流程分析
针对渲染,我们使用Frame Debugger。

运行安卓包后长这样:

可以看到有大量的渲染内容啊,从FrameDebugger可以看到这是URP延迟渲染管线,完整渲染流程依次包含主光源阴影渲染、附加光源阴影渲染、颜色分级LUT生成、GBuffer渲染、深度拷贝、延迟光照Pass、SSAO环境光遮蔽、天空盒绘制、颜色拷贝、运动向量生成、透明物体绘制、一整套后处理效果,之后执行UGUI Overlay模式的UI渲染以及GUI纹理绘制,中间穿插多次color+depth+stencil缓冲区清除操作,其中SSAO、MotionVectors、全套后处理、多套阴影Pass、GBuffer多RT输出会带来不小GPU开销,DrawCall统计上不透明物体57个、延迟光照69、SSAO4、透明物体3、后处理20,再叠加UI的DrawCall,整体渲染链路比较重。
课程帮我们总结了整个渲染链路:

现在的问题是,我们确实获取到了整个渲染流程,也能看到具体各个阶段的参数,但是我们如何判断哪个部分是可以优化的呢?课程又给我们介绍了一个新的工具:Xcode的Metal Capture功能。
持续更新中