UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - 简谱
Unity小游戏优化简谱 | 吃透底层逻辑，告别掉帧与流失
做Unity小游戏的开发者，大概都遇到过这样的困境：明明在编辑器里跑得好好的，打包成WebGL上线后，却出现掉帧、发热、启动慢、甚至闪退的问题。用户一卡就流失，好不容易拉来的流量也留不住。这篇文章，就从小游戏的底层原理讲起，帮你一步步定位性能瓶颈，把这些“隐形杀手”逐个击破。 1. 前言 本章从“什么是小游戏”出发，简要介绍小游戏的概念、适用范围与开发原理，并梳理Unity小游戏性能优化的必要性及问题定位的基本思路，为后续各章节的深入讨论提供背景框架。 1.1 什么是小游戏 “小游戏”是一个较为宽泛的概念。广义上，它通常指轻量、上手快、适合碎片化时间游玩的游戏；而对开发者而言，它更多是指依托于特定平台运行的游戏应用。例如微信在2017年推出的爆款小游戏“跳一跳”，无需额外安装，直接在微信内即可游玩。如今，微信、抖音、快手、QQ、支付宝等多个平台都提供了小游戏入口，品类也日益丰富。 这类基于常用平台的小游戏往往无需下载、安装或卸载，即点即玩，体验更轻便。除即时体验外，小游戏还能借助平台的账号体系、好友关系等能力，具备较强的社交属性，例如排行榜与邀请等功能。相较于APP手游，小游戏在类型上更偏轻松休闲，但也不乏玩法更复杂的重度产品。近年来，不少APP游戏也移植了小游戏版本，借助平台流量实现快速获量与更高的用户留存。 1.2 适用范围 本文主要针对Unity WebGL转微信小游戏（下文简称微小）的性能优化场景，文中的经验数据、工具链说明和优化建议大多基于此方案。部分结论（如性能差异倍数、内存阈值、WASM编译内存估算等）不一定适用于Cocos/Laya/Godot等其他引擎，也不一定适用于抖音、快手等非微信平台。读者在参考时请结合自身项目的引擎、宿主平台和运行环境具体分析。 1.3 小游戏的开发原理 各类小游戏平台通常面向多样化的开发需求，支持开发者使用不同的游戏引擎或技术方案进行开发，常见包括Unity、Cocos、Godot、Laya等。小游戏平台并不直接绑定具体的游戏引擎，而是以引擎构建的产物作为接入与运行的基础。各引擎均有对小游戏的适配与支持，能输出合适的产物在小游戏环境中运行，具体适配原理可参考对应引擎的官方文档，此处不再展开。当前简谱主要针对使用Unity引擎开发并转换的小游戏项目。 以上常见的游戏引擎可分为两类：基于JavaScript/TypeScript的HTML5游戏引擎，以及原生目标的游戏引擎，Unity属于后者。两种类型的适配方式有所不同。对于Unity项目而言，适配方式基于WebAssembly技术，将游戏代码导出为WASM代码，通过胶水层代码运行在浏览器环境中。这种方式的优势在于保持原有引擎工具链和技术栈不变，开发者无需重写游戏核心逻辑，通过转换工具即可完成小游戏适配。 1.4 为什么要关注Unity小游戏的性能 玩家遇到掉帧、卡顿、发热、闪退等性能问题时，无论游戏美术是否精良、玩法是否有趣，体验都会直接变差，黏性随之下降。小游戏易上手、节奏快，遭遇性能问题后玩家流失会更快。 此外，相比于APP，小游戏即开即用的特点使玩家对启动耗时更加敏感 —— 从点开到进入正式场景的时间过长，用户流失的可能性就会越大。 从技术角度来看，Unity小游戏以WebAssembly（WASM）+ WebGL为核心技术方案，运行性能会极大影响可承载的游戏内容玩法。开发者需要关注不同技术栈带来的性能差异，以及不同系统平台（Android、iOS、Windows PC）之间的性能差异。 1.4.1 Unity WebGL与APP的运行性能差异 CPU性能差异： Unity WebGL以WASM虚拟机的形式运行在类浏览器环境中，CPU算力受限于虚拟机的执行效率。 在多数小游戏运行环境和Unity WebGL转小游戏方案中，常规C# 多线程/Job多线程能力受限，不能按原生APP的线程模型使用，导致AI、动画、渲染等模块无法获得多线程加速。新版Unity Web平台已提供WebAssembly threads支持入口，但标准C# 多线程仍有限制，且依赖浏览器/宿主对SharedArrayBuffer等能力的支持。 这是导致Unity WebGL与APP存在性能差距的最主要因素。作为常见经验值/保守预算，Unity WebGL通常约为APP手游性能的1/3，开发者应特别关注CPU侧的性能瓶颈。但需明确，不同机型、Unity版本、是否开启iOS高性能模式、业务瓶颈在CPU还是GPU，都会显著影响这一比例。 GPU性能差异： Unity以WebGL API进行渲染，其中WebGL 1.0相当于OpenGL ES 2.0，WebGL 2.0相当于OpenGL ES 3.0。 WebGL在原生渲染API之上封装存在少量开销，但基本渲染能力与原生APP接近。 GPU Instancing、SRP Batcher等渲染特性需要WebGL 2.0。当APP手游使用了这些特性而小游戏未开启WebGL 2.0时，性能差距会进一步拉大。 1.4.2 WASM与JS的运行差异 WASM是静态类型的二进制指令格式（虚拟机字节码），其类型系统使JIT优化能更准确地预判运行期类型，因此能更快达到JIT指令优化后的峰值。在计算密集型、类型稳定的场景中，WASM通常更容易获得稳定性能，但具体差距依赖引擎、宿主、代码形态和平台，不能简单以固定倍数对比。 Unity引擎目前未针对浏览器环境做充分优化（如WASM与宿主接口互调频次偏高、未做充分的代码路径裁剪），整体较为臃肿，部分应用场景反而逊于JS的轻量实现。 因此，两者在实际使用中不能简单以语言算力对比，需以实测游戏为准。 1.4.3 系统平台之间的性能差异 Android与Windows PC：Android平台（以微信小游戏为例，通常基于XWeb/Chromium体系，WASM虚拟机内核大体可理解为V8）与Windows PC均使用V8作为WASM虚拟机内核，均支持JIT，在相同算力条件下两者性能接近。但需注意移动平台散热更差，对性能要求更苛刻。不同平台和宿主版本的实际内核可能存在差异，不宜一概而论。 iOS：iOS默认为普通模式，不支持JIT，可用于超休闲游戏；中重度游戏建议开启iOS高性能模式以支持JIT，但该模式需要更多精力进行调优，尤其在启动发烫与内存方面。 1.5 如何确定问题 定位思路 发现性能问题后的第一步就是确定问题。如果阅读过UWA提供的《Unity移动端游戏性能优化简谱》，读者可能已对Unity常见性能问题有一定认知。在小游戏侧，移动端的知识体系大部分仍然适用，但也有不少小游戏独有的问题需要特别留意。总之，确定问题本身也是一项挑战。 可用工具 工欲善其事必先利其器，直接精确的性能数据能更直观地反映性能瓶颈，事半功倍。从小游戏角度，可用工具主要分为三个层面： 平台层：各小游戏官方平台基本都提供自带的性能调试与监控工具（如微信/抖音开发者工具）。 引擎层：Unity Profiler、Memory Profiler等，用于定位引擎内部的CPU与内存瓶颈。 系统层：Android Studio Profiler、Xcode Instruments等，用于从进程视角分析整体资源消耗。 此外，UWA已对微信、抖音等小游戏平台提供支持。UWA GOT Online工具可通过SDK快速集成到测试项目中，真机测试完成后在极短时间内完成数据上传与解析，自动生成一系列可视化图表。同时，基于UWA丰富的优化经验和数据库进行评分，针对各性能模块提供分析建议和参数变化趋势。 通用原则 —— PC开发者工具 vs 真机测试：小游戏开发中，PC端开发者工具的便捷性很容易让人依赖其数据进行性能判定，但工具环境与真机环境在多方面存在显著差异，贯穿CPU、内存、GPU、启动耗时等所有性能维度。例如：纹理压缩格式（ASTC等）在PC上会回退为RGBA32导致内存虚高、WASM编译与JIT行为在iOS/Android真机上与PC模拟差异明显、PC的高性能CPU会掩盖真机上更易暴露的瓶颈。因此，任何时候都应优先以真机测试数据为最终依据，PC开发者工具仅用于初步调试和功能验证。后续各章节中也会反复强调这一原则。 问题定位检查清单 确认问题现象：掉帧/卡顿/发热/闪退/启动慢，明确优化目标 使用平台调试工具（微信/抖音开发者工具）获取初步性能数据 使用Unity Profiler/Memory Profiler定位引擎层瓶颈 真机测试（Android + iOS），勿仅依赖PC开发者工具数据 借助UWA GOT Online等第三方工具获取深度分析报告与优化建议 对比APP版本与原生性能基线，区分通用问题与小游戏特有问题 2. 启动耗时 启动耗时是玩家对小游戏最直接的性能第一印象。本章围绕启动流程中的三个核心环节 —— 首包资源下载、WASM代码下载与编译、引擎初始化及首帧逻辑 —— 逐一分析各阶段的优化方法，并汇总可落地的检查清单。 2.1 启动流程概览 当玩家上手一款小游戏时，最先影响到体验的性能指标就是启动耗时。在小游戏快节奏的环境下，启动过长可能导致玩家尚未体验游戏内容就已流失。根据微信小游戏官方数据，普通小游戏启动时间为7~10s，不经优化的Unity WebGL游戏启动可达该时间的2~3倍以上。优化目标是将首屏启动耗时控制在5~10s甚至更短。 以微信小游戏（以下简称微小）为例，Unity WebGL转换的微信小游戏主要依靠Unity Loader进行初始化，其工作流程如下： 不同平台小游戏的启动细节可能略有差异，但总体可归纳为三个核心环节：首包资源下载、WASM代码下载和编译、引擎初始化与开发者首帧逻辑。 此外，UWA GOT Online的小游戏报告中也提供了启动耗时的相关指标、推荐值和优化建议，可帮助快速定位耗时较高的阶段： 2.2 首包资源下载 首包是什么：一般名称为xxx.webgl.data.unityweb.bin.txt，存放在CDN服务器上。首次进入游戏时下载、解压后保持在内存，二次启动可直接走缓存。因此对启动耗时而言，主要关注首次下载或首包更新时的下载耗时。 首包内容构成：首包文件大小直接影响下载耗时，可使用AssetStudio查看其中包含的资源，通常包括： Unity Default Resources — 引擎默认资源（Arial字体、默认Mesh、纹理等） IL2CPP Meta Data — C#代码经IL2CPP生成的类、方法等元信息 unity_builtin_extra — Always Included的Shader BuildSettings中所有Active的场景 Resources文件夹中的资源及其引用的其他资源 全局设置及引用到的资源（如Splash图片等） 优化方法： 1. 排查与精简：结合AssetStudio查看首包内容，排查其中内存占用较高或不符合预期出现的资源，尽量只保留首场景中的必要资源。下图为AssetStudio中看到的部分首包内容。 举例来说，加载过程的提示文字可能会使用自定义字体，如果用全量字体其内存占用对于首包来说可能过大。然而实际在加载过程中用到的文字量有限，可考虑单独拆分出一个更小的字体专门用于首包。 2. 转化工具设置：勾选转换工具面板的“压缩首包资源”选项，可对首包进行进一步br压缩。微信还提供了首包资源优化功能，可清理首包中项目并未实际使用的资源，进一步瘦身。 3. 目标值：一般建议将首包资源大小控制在5MB以下。 2.3 WASM代码下载与编译载 代码包是什么：一般名称为xxx.webgl.wasm.code.unityweb.wasm.br，默认进行br压缩。在启动阶段，wasmcode与首包资源并行下载，共同占用下载带宽；下载完成后进行编译，编译过程本身也消耗CPU资源。综合来看，核心优化方向是降低WASM代码体积，一般建议将原始代码包控制在30MB以下。 优化方法： 1. 代码分包（最常用）：在开发者工具中安装wasmCodeSplit插件，原理是将原WASM拆分为主包（启动加载）和子包（延迟加载）。小游戏先加载较小的主包进入主场景，再异步加载剩余分包，可大幅降低下载与编译耗时。具体操作可参考官方文档。 注意：如果直接从拓展商店中安装插件失败，出现如下报错，可以直接从设置-拓展设置-编辑器拓展中找到wasmCodeSplit插件进行安装。 2. 代码裁剪：Unity未针对WebGL平台做特别裁剪，默认会将引擎、业务代码、第三方插件全部编译为WASM二进制。建议勾选“Strip Engine Code”并将“Managed Stripping Level”设为High，可有效缩减代码包体积。 3. IL2CPP Size选项：使用Unity 2021以上版本时，可在PlayerSettings中将IL2CPP选项设为更小尺寸（SIZE），减少函数量。 4. 删除多余插件：排查项目使用的第三方插件，手动移除不必要的Unity模块（如物理模块、Unity数据统计等），从源头减少代码量。 2.4 引擎初始化与开发者首帧逻辑 该阶段做什么：引擎自身模块与数据初始化、首个场景加载以及MonoBehaviour的Awake/Start流程。此阶段CPU处理密集，但网络处于空闲状态，可利用预下载功能提前缓存后续资源（详见第3章资源加载）。 优化要点： MonoBehaviour脚本在首帧的Start/Awake中应尽量少做逻辑，优先把画面呈现出来 初始场景不宜过大，通常只呈现Splash场景即可 初始场景中如需加载后续主场景或配置，建议采用分帧策略，切勿在Start/Awake中阻塞 调试工具 —— Android CPU Profiler 微信提供了Android CPU Profiler功能，可用于排查此阶段的CPU逻辑开销。使用方法如下： 从右上角“…”打开菜单 → 开发调试 → 选择Start CPU Profile开始采集 采集一段时间后点击Stop CPU Profiler，生成.cpuprofile文件 文件路径通常为：Android/data/com.tencent.mm/MicroMsg/appbrand/trace 数据可通过Chrome、Edge，或开发者工具中的JavaScript探测器打开分析 注意：若希望在堆栈中看到可读函数名，需勾选Profiling-funcs或使用Development模式，但会带来一定性能开销。否则函数仅显示数字ID，需借助symbols文件进行映射（微信提供Python替换脚本可自动处理）。此外，若要排查启动阶段，可手动修改game.js，让游戏在启动时增加一段黑屏等待时间，便于在启动期间打开调试。 2.5 启动耗时检查清单 首包资源<5MB，仅包含首场景必要资源 WASM原始代码包<30MB 开启代码分包（wasmCodeSplit插件） 勾选“Strip Engine Code”，Managed Stripping Level设为High Unity 2021 + 设置IL2CPP选项为更小尺寸（SIZE） 首场景只保留Splash，Awake/Start不阻塞主线程 初始场景中后续加载采取分帧策略，不在首帧同步完成 3. 资源加载 启动耗时章节提到首包内容应尽可能精简，其余资源需放在CDN中延迟加载，因此资源的按需加载是小游戏中至关重要的环节。本章从加载方案选择、缓存机制、AssetBundle适配三个维度展开，帮助开发者在加载效率与内存占用之间找到平衡。 3.1 常规加载方案 三种方案对比：目前常见的加载策略有三种 —— Addressable方案、AssetBundle方案、Unity Instant Game方案。无论哪种方案，微信小游戏环境均不支持本地Bundle加载，最终都采用上传CDN方式在游戏运行时异步按需下载。 Addressable（AA）：Unity官方推荐的资源管理方案，支持远程加载与本地缓存，适合大多数项目。 AssetBundle（AB）：传统分包方案，在内存和加载效率方面优于AA，尤其适合相对重度的游戏。目前UWA接触的小游戏项目中使用AB方案的偏多。 Instant Game：Unity官方提供的自动加载方案，仅适合原生APP未使用资源按需加载、总包体较小的轻度游戏，具体参考团结引擎文档。 对于AA和AB两种方案，一般可以沿用原APP游戏工程的管理方案，转化工作量相对较小。后续讨论以AssetBundle方案为主，其余方案可参考官方文档。 AB打包参数建议：使用AssetBundle进行资源打包时，推荐以下设置： BuildAssetBundleOptions.AppendHashToAssetBundleName：开启后Bundle名会携带Hash，是小游戏中资源缓存及缓存淘汰机制的重要依据（详见3.2节）。 BuildAssetBundleOptions.ChunkBasedCompression：LZ4压缩方式，加载速度与包体大小更均衡。 DisableWriteTypeTree：如无新老Unity版本兼容需求，建议开启以提升加载速度并降低内存。 加载接口：小游戏不支持AssetBundle本地加载，从服务器下载Bundle主要使用以下接口： UnityWebRequestAssetBundle.GetAssetBundle UnityWebRequest 不建议使用WWW.LoadFromCacheOrDownload或WWW等带Cache接口，WebGL模式下会通过JS模拟文件系统，带来额外内存消耗。 3.2 资源缓存 缓存机制：Unity Loader插件已内置资源缓存与淘汰功能，无需开发者自行实现。UnityWebRequest和UnityWebRequestAssetBundle接口均会自动触发缓存，业务侧无需关心资源是否有缓存，正常调用API即可。可通过Loader插件返回的Log判断当前是走下载还是缓存。 预下载：Loader插件提供预下载功能，目的是充分利用网络带宽。启动流程中「引擎初始化与首场景准备」阶段CPU处理密集，但网络处于空闲，利用预下载可在此阶段提前下载并缓存资源，后续即可直接走本地缓存。预下载文件总体积建议控制在3~5MB以内，文件数量不超过10个（此阶段最多10个并发，超出将排队）。 缓存过滤：并非所有文件都适合缓存。可在导出面板中配置不自动缓存的文件类型（如默认不缓存.json文件），也可在minigame/unity-namespace.js中手动处理缓存逻辑。 版本管理：若资源文件已缓存到本地但后续版本更新，不特殊处理会继续加载旧缓存。需要以Hash区分资源版本 —— 本地有旧缓存时先清理再写入新版本。只需将资源URL携带Hash作为版本依据即可，默认Hash长度为32；若游戏自行计算CRC，也可设为CRC长度。注意Hash必须以‘-’或‘_’作为分隔符，其他符号无法正确处理版本信息。 缓存上限与淘汰：缓存体积随游戏进程逐渐增大，默认上限为200MB。可通过 maxStorage调整上限，也可前往微信后台申请空间提升，最高可达1GB。达到上限后Loader按LRU规则清理早期缓存。为避免频繁触发，默认额外多清理30MB（可通过defaultReleaseSize修改）。清理时支持忽略指定文件使其不被自动清理，仅可主动删除，具体通过minigame/unity-namespace.js中isErasableFile函数控制。 常用接口： public string WX.PluginCachePath：获取自动缓存的文件存储路径，返回值：${wx.env.USER_DATA_PATH}/__GAME_FILE_CACHE public string WX.GetCachePath(string url)：传入URL或文件相对路径，返回缓存路径（无缓存则返回空字符串） public void CleanAllFileCache(Action action)：清理所有自动缓存的文件 public void CleanFileCache(int fileSize, Action action)：从缓存目录中释放指定大小的文件 public void RemoveFile(string path, Action action)：从缓存目录中删除指定文件 3.3 小游戏中的AssetBundle AB内存差异：AssetBundle打包后的文件由文件头（属性数据、Asset信息、依赖关系等）和主数据（平台对应的Asset数据）两部分组成。在Standalone、Android、iOS等具有本地文件系统的平台上，只需加载文件头，运行时按需读取资产数据。而WebGL平台受浏览器权限限制不能直接访问本地IO，加载AB时需将两部分全部加载到内存中。因此，原生APP只需关注AB文件头内存，但小游戏还需额外关注AB文件本身的内存占用。 WXAssetBundle方案：小游戏平台提供了专用AB接口来解决上述问题。以微信小游戏为例，使用WXAssetBundle将文件系统接口桥接到微信的文件系统，使AB可读写到小游戏缓存目录，从而避免AB文件整体进入内存。经测试，使用Unity原生AB接口时，AB文件本体内存会进入UnityHeap并被Reserve；更换为WXAssetBundle后，该内存不再出现在UnityHeap中，而是进入本地缓存（仍需关注默认200MB上限）。 在UWA GOT Online小游戏报告中，也专门统计了WXAssetBundle接口的内存数据。下图为同一AssetBundle、不同加载接口的对照测试 —— 前半段为WXAB接口加载/卸载，后半段为Unity原生AB接口。可见前者几乎不会使Reserved Total升高，后者则有明显变化。 打包策略：无论使用哪种AB接口，打包策略都值得关注。分包粒度太大，单个AB下载易卡顿；粒度太小则文件数量过多，IO和下载效率下降。建议根据资源使用频率和生命周期采用混合策略 —— 常更新的资源（如UI元素）打包至较小Bundle便于热更新，不常更新的资源（如场景资源）可打包至较大Bundle。通常单个AB包建议控制在10MB以内，总体AB驻留数量在1000个以内。 AB利用率：UWA提出了AB利用率的概念 —— 在特定测试流程中，某AB文件被加载进内存但真正用到的资产（含主动加载与被动依赖）比例很低，则判定为低利用率。这类AB的打包策略可能存在浪费，定位到具体AB后可进一步排查，对包内资产进行更精细的划分。 3.4 资源加载检查清单 打包参数：开启AppendHashToAssetBundleName，使用ChunkBasedCompression (LZ4) 非跨版本兼容需求时，开启DisableWriteTypeTree 使用UnityWebRequest/UnityWebRequestAssetBundle加载（不用WWW等带Cache接口） 预下载文件总体积控制在3~5MB，文件数≤10个 单个AB包<10MB，总体AB驻留数量<1000 个 检查AB利用率，避免大量未使用资产进入内存 关注缓存上限（默认200MB，可申请提升至1GB），必要时调整maxStorage 4. 内存 内存是小游戏中与闪退直接挂钩的核心性能指标，其分布结构、扩容机制和平台限制均与原生APP存在显著差异。本章从小游戏进程视角出发，梳理内存的整体构成与各子模块的优化要点，并对比iOS不同运行模式下的内存分布差异。 4.1 进程视角的内存限制 Android：小游戏通常运行在独立进程中（如微信小游戏为 com.tencent.mm:appbrand0/1/2），内存相对宽松。一般建议低端机<1.2GB，中高端机<1.5GB。可通过ADB或UWA Gears工具查看进程内存，下图为Gears采集到小游戏运行时的PSS内存走势。 iOS：整体限制更严格，建议低端机<1GB，中高端机<1.4GB，最终需结合Xcode Instruments实际数据具体分析，微小通常关注com.apple.WebKit.WebContent进程。 iOS根据运行模式的不同，内存限制也有所差异： 普通模式（默认）：WASM运行无JIT，计算性能受限。小游戏进程与微信绑定，同在WeChat进程中。 高性能模式：CPU算力明显提升，但内存限制更严格 —— 2GB内存设备上限约1GB，3GB及以上设备上限约1.5GB，工程上建议控制在1.2~1.3GB以内。此时小游戏运行在独立进程com.apple.WebKit.WebContent中。 高性能+模式：在高性能模式基础上进一步升级，保留游戏独立进程的同时将渲染重新挪回微信进程，渲染效果和渲染内存消耗均得到改善。推荐使用WebGL2、内存压力大的游戏开启该模式。 4.2 小游戏内存结构 Unity WebGL以WebAssembly+WebGL技术为基础，游戏内存分配完全托管在浏览器环境中。适配到小游戏后，进程成为“容器”，内存组成结构基本一致。典型游戏的内存分布如下图所示： 各内存区域简要说明： UnityHeap：托管堆、本机堆与原生插件底层内存，初始值为转化面板中的“UnityHeap预留内存”，运行时只增不减且存在内存碎片。这是大部分小游戏项目中最主要的内存来源，是排查与优化的重点。 WASM编译：代码编译与运行时指令优化产生的内存，占比相对较高。一般无工具直接检测该部分精确占用。常见经验上可按未压缩WASM代码包的约8~12倍进行数量级估算，但实际比例受平台内核（iOS/Android）、是否开启JIT、分包策略及编译优化等级影响较大，以真机实测为准。 GPU内存：纹理或模型Upload GPU后的显存占用。压缩纹理格式支持受宿主、浏览器、设备GPU和转换方案影响，PC开发者工具中可能回退为RGBA32，必须以真机实测为准。 音频：Unity将音频传递给容器后，播放时占用的内存。 基础库+Canvas：小游戏公共库、Canvas画布等固定开销，基本无法针对性优化。 其它：Emscripten文件系统模拟等相关占用。 4.2.1 UnityHeap 扩容机制：UnityHeap是为实际使用的内存部分提前预留的上限，实际占用指标为DynamicMemory。当DynamicMemory接近预留上限时（如初始500MB，达到约490MB以上），会触发扩容 —— 在内存中新分配一个更大的堆空间（如550MB），将旧内容复制过来后再释放旧堆。短时间内产生内存块复制导致的内存尖峰，极易造成闪退，尤其在iOS上更为严重。 初始值设定：不宜过小（频繁扩容导致尖峰），也不宜过高（≥1024MB直接导致启动失败）。官方参考值：轻度游戏（休闲类）256MB，中度游戏（模拟经营、卡牌成长）496MB，重度游戏（SLG、MMO）768MB。 DynamicMemory构成：DynamicMemory=MonoHeap+NativeReserved+原生插件内存，需分别关注各部分走势，避免泄漏。 Mono堆内存是C#脚本托管对象（string、List、自定义类实例、委托等）的核心内存区域。GC会正常回收对象，Mono使用量可以有增有降；但受WebAssembly内存沙箱机制影响，堆容量一旦被峰值撑高便难以回缩，表现为峰值保持。因此在小游戏上要特别关注Mono的分配，避免单帧内出现大量分配把峰值顶上去。 NativeReserved由Unity Native产生，属于引擎内部对象。动画、字体、Shader等资源类内存均包含在此。纹理、网格等传给GPU的资源原本计入GPU内存，但若开启了Read/Write Enable选项，CPU端还会额外存储一份，计入NativeReserved。此外，使用Unity原生AssetBundle接口时AB文件本体内存也会进入NativeReserved，使用WXAssetBundle可将其转移至本地缓存。 从WXAB和原生AB的加载对比测试数据中可以看到：使用原生AB接口会使NativeReserved明显上升，连带推高DynamicMemory；即使卸载后NativeReserved回落，DynamicMemory也已被撑高，剩余空间相当于被预留住了。 针对Native部分的排查可使用Unity Memory Profiler工具，需开启Development Build及AutoConnect。 第三方原生插件内存（如Lua）通常没有单独数值展示，可观察DynamicMemory与Mono、Native部分的差值是否偏大。若对插件管理较明确，可通过对比测试（开关插件观察内存变化）进一步定位来源。 4.2.2 WASM编译 小游戏启动时下载WASM代码包并进行编译，编译后占据的内存很大。常见经验上可按未压缩WASM代码包的约8~12倍进行数量级估算（如微信官方数据：iOS上30MB未压缩代码约需300MB运行时编译内存），但实际比例受平台内核、是否开启JIT、分包策略及编译优化等级影响较大，以真机实测为准。 最常见有效的优化方案是代码分包 —— 将原WASM代码包拆分为主包和子包： 主包（wasmcode）：启动时首先加载，大小约为原包的1/3~1/2，建议控制在3~5MB，需尽量覆盖大多数游戏场景和流程。 子包（wasmcode1/wasmcode2）：分别对应Android和iOS平台，约7~15MB，在游戏运行一段时间后自动加载或缺失函数时再加载。 分包后总大小可能比原包更大（因多出复制文件），但只有主包一定加载，其余延迟或按需下载，对运行几乎无影响。 4.2.3 常见资源类型内存 对于Unity开发者而言，更直观、更容易上手修改的是纹理、网格、动画、字体等常见资源类型。各类资源在小游戏内存中的归属如下： GPU内存：纹理、网格、RenderTexture NativeReserved（UnityHeap内）：动画、字体、Shader、开启了Read/Write的纹理/网格、使用原生AB接口的AB文件本体 独立内存：音频资源 《Unity移动端游戏性能优化简谱》中对各类资源内存优化已有系统介绍，以下仅提炼各类型的核心优化要点： 纹理：内存大户，重点关注三个方面 —— ①格式：避免RGBA32/ARGB32等未压缩格式，优先使用ASTC/ETC2等移动端压缩格式（注意小游戏中格式支持因宿主和真机而异，务必真机验证）；②分辨率：1024以上的纹理需评估是否必要，高分辨率在小屏幕上往往看不出差异却占4倍内存；③Read/Write Enabled：开启后CPU端额外存一份，计入NativeReserved，非必要一律关闭。此外，开启Mipmap会使纹理内存增至约1.33倍，需权衡带宽收益后决定。 网格：关注顶点数和面片数是否超出实际表现需求，顶点属性（UV2、Color、Tangent等）在不必要时可剥离以减小数据量。网格同样存在Read/Write Enabled问题，开启后CPU端多存一份。 动画：动画Clip本身占用内存，且播放时涉及骨骼Transform更新等CPU开销。关注Animation Clip数量与时长，适当压缩关键帧或降低采样率。大量Animator处于Active状态还会额外增加CPU负担。 音频：小游戏中音频内存独立于UnityHeap，但也需关注。建议使用压缩格式（如MP3/Vorbis），并根据音频类型选择Loader（Decompress On Load会将完整数据常驻内存，Streaming则按需读取但CPU开销更高）。 字体：使用TextMeshPro时，SDF Atlas以Alpha8格式存储，内存较低；但若使用动态字体（.ttf直接渲染），会产生较大内存占用。建议优先使用TMP静态字体方案。首包中使用的字体应尽可能精简字符集。 粒子系统：粒子数量、发射器复杂度直接影响CPU端计算和内存分配，中低端机上应限制Max Particles并精简粒子层级。 以上资源内容可以在UWA GOT Online小游戏报告中进行详细排查，在Resource模式中可获取具体的资源列表。 注意：新上手小游戏平台的开发者常遇到纹理内存远超预期的现象 —— 明明做了纹理压缩，测试数据却显示很高。这通常是PC开发者工具中ASTC等压缩格式回退为RGBA32所致，纹理压缩格式务必以真机测试为准（详见1.5节关于PC工具与真机测试的原则说明）。 4.3 iOS高性能与高性能+模式对比 iOS的高性能模式和高性能+模式中内存分布存在明显差异，UWA通过对比测试帮助读者形成更直观的认识。 高性能模式：小游戏运行在独立进程com.apple.WebKit.WebContent中。几乎所有类型的内存都会进入WebContent进程（不会使WeChat进程内存上升），仅音频资源例外。真正导致闪退的进程是WebContent进程。内存分布大致如下： 高性能+模式：官方描述为“开创性地在保留游戏独立进程的基础上将渲染重新挪回了微信进程”。测试结果显示，属于GPU的纹理、网格、RT不再进入WebContent进程，而是进入WeChat进程。但开启了Read/Write的纹理/网格在CPU端仍有一份内存位于NativeReserved中，属于WebContent进程。 再次验证，小游戏闪退只与WebContent进程有关，一般建议控制在1.3GB以内；WeChat进程即使大幅超过这个范围也不会闪退。内存分布如下： 可见高性能+模式的内存占用相比高性能模式确有下降，若高性能模式下内存吃紧可考虑开启高性能+。但具体仍需自行评估客户端版本、是否存在新的画面或性能问题。 4.4 内存检查清单 UnityHeap初始值合理设置（参考：轻度256MB/中度496MB/重度768MB，避免<256MB或≥1024MB） 关注DynamicMemory走势，避免泄漏和频繁扩容引发的内存尖峰 Mono堆避免单帧内大量分配 纹理避免开启Read/Write Enable（否则CPU端多存一份计入NativeReserved） 考虑使用WXAssetBundle代替原生AssetBundle接口，避免AB本体进入UnityHeap WASM代码包按约8~12倍体积数量级估算编译期内存，以真机为准，关注分包效果 纹理压缩格式以真机测试为准，勿依赖PC开发者工具数据 内存压力大的游戏考虑开启iOS高性能+模式 5. CPU压力 受限于WASM虚拟机的执行效率以及常规C# 多线程/Job多线程能力受限，小游戏上CPU性能约为原生APP的1/3（实际比例因机型、Unity版本、iOS模式、瓶颈类型等因素而异），是小游戏平台上最易出现高压问题的维度。本章聚焦小游戏中独有或更容易放大的CPU瓶颈场景，通用排查思路可参考《Unity移动端游戏性能优化简谱》中的CPU相关章节。 5.1 蒙皮动画 小游戏中较常见的CPU性能压力来自蒙皮动画（Skinned Mesh Renderer）。在原生APP上，蒙皮计算可在工作线程处理，对主线程影响较小；而在小游戏中常规多线程能力受限，MeshSkinning.Update的耗时直接影响主线程，不经处理可能成为瓶颈。下图为某款SLG项目在Mi 10上的运行数据，在小怪较少的战斗场景中也会有持续4ms的MeshSkinning.Update开销，相对偏高。 优化方向有两个： GPU Skinning：将蒙皮计算从CPU移至GPU处理。团结引擎已内置该功能，也可使用第三方GPU Skinning方案。上述SLG场景中替换为GPU Skinning方案后，在小怪数量更多、更复杂的战斗场景开销也仅有1~2ms，且表现无明显差别。 控制蒙皮规模：进一步限制骨骼数量和顶点数量，适当牺牲表现精度以换取性能。 5.2 逻辑代码 部分在APP上运行稳定的计算逻辑，直接移植到小游戏中效率可能明显偏低，需要适量精简。这一部分与项目业务逻辑强相关，难以给出统一方案，但可遵循以下排查与优化思路： 配合Profiler打点定位耗时的函数，优先处理耗时最高的热点 关注每帧都在执行的逻辑（Update/FixedUpdate），检查是否有不必要的重复计算 WebGL中Lua不支持JIT，避免将Lua用于重度计算逻辑 回顾1.5节中关于PC工具与真机测试的原则：PC端CPU性能远超真机，务必以真机Profiler数据为准 5.3 UI模块 UGUI是许多Unity项目中CPU开销的重要来源，在小游戏单线程环境下影响更为明显。主要关注以下几个高耗时函数： EventSystem.Update：UGUI事件系统的每帧更新，耗时偏高时通常与两个因素有关 —— 一是输入回调中挂载了耗时过高的逻辑函数，可通过Profiler打点进一步定位；二是大量UI元素默认开启了Raycast Target选项，而实际上多数Image、Text并不需要响应点击事件，关闭该选项可直接降低事件遍历开销。 Canvas.SendWillRenderCanvases：Canvas的渲染重建入口，当UI元素的Transform、颜色、层级等发生变化时触发。该函数耗时与发生变化的UI元素数量成正比。优化方向包括：将动态UI和静态UI拆分到不同Canvas中，避免静态元素被频繁连带重建；减少不必要的Layout Group、Content Size Fitter等自动布局组件使用。 Canvas.BuildBatch：UI元素合批为Mesh的主要耗时点，通常紧随SendWillRenderCanvases之后。合并批次时若发生打断（如同一Canvas下使用了不同材质/图集），会显著增加重建耗时。规划好UI的图集和材质共用策略、保持合批连续性，可有效降低该开销。 CanvasRenderer.SyncTransform：当UI元素的Transform发生变更时触发，频繁调用时会连带导致渲染更新开销增高。需注意某些动画或逻辑中对UI元素Transform的频繁修改。 通用建议：动态/静态UI分离到不同Canvas、关闭不需要的Raycast Target、减少自动布局组件使用、谨慎使用UI Particle（会作为UI元素高频更新并重建Mesh）。 5.4 实例化与销毁 在游戏运行过程中，频繁的Instantiate和Destroy操作在小游戏上开销明显，原因在于单线程环境下内存分配与对象初始化的耗时直接阻塞主线程。 场景/模块切换：进入新场景或新界面时，建议采用分帧加载策略，避免在一帧内完成大量对象的Instantiate导致明显卡顿。可结合对象池（Object Pool）复用高频创建销毁的对象（如子弹、特效、UI列表项），减少频繁的内存分配与回收。 Activate/Deactivate vs Instantiate/Destroy：对于需要频繁显隐的对象，使用SetActive的代价远低于Instantiate/Destroy，但大量同时处于Active状态的对象仍会增加Update等逻辑开销，需要在两者之间平衡。 Resources.UnloadUnusedAssets：卸载未使用资源是一项耗时操作，建议在场景切换完成后的静默期执行，避免在游戏流程中频繁调用。 5.5 CPU压力检查清单 关注SkinnedMeshRenderer数量和MeshSkinning.Update耗时 考虑使用GPU Skinning（团结引擎已支持）或第三方方案 控制蒙皮骨骼数和顶点数，适当牺牲精度换取性能 逻辑代码配合打点定位高耗时函数，针对性精简 WebGL中避免用Lua处理重度逻辑（不支持JIT） UI：动态/静态分离到不同Canvas，关闭非必要的Raycast Target UI：减少自动布局组件使用，规划好图集合批策略 频繁创建销毁的对象使用对象池，场景切换采用分帧加载 6. GPU压力 小游戏上GPU运行效率与APP接近，相对于CPU较不容易成为性能瓶颈，但GPU压力同样关系到帧率、发热与功耗。本章提炼《Unity移动端游戏性能优化简谱》中GPU章节的核心要点，并补充微信小游戏平台在iOS上经验证的几个GPU压力关键来源（HDR、RT使用、纹理精度等）。 注意：1.5节中关于PC开发者工具与真机测试的原则在GPU侧尤为重要 —— ASTC等压缩纹理格式在PC上可能回退为RGBA32，WebGL 2.0特性（GPU Instancing、SRP Batcher）在PC与真机上的表现也可能存在差异，GPU数据务必以真机实测为准。 6.1 判断GPU压力 当整体帧耗时明显超出目标帧，但CPU侧各模块耗时之和与之存在较大差距时，通常倾向于判定存在GPU压力。在原生APP上可借助GPU Clocks或同步等待函数标记辅助判断，但小游戏环境下渲染线程模型存在差异，更建议结合实际表现综合评估 —— 例如CPU耗时已优化到位而帧率仍不达标、或降低渲染分辨率后帧率明显回升，均可作为GPU Bound的佐证。 6.2 顶点阶段压力 顶点阶段压力取决于渲染面片数与顶点着色器复杂度。GPU Primitive参数中，若剔除图元占比高达70-80%，说明存在大量浪费，需排查模型制作是否合理、大模型是否未拆小导致整体提交GPU、网格是否过于精细而屏占比有限等。 优化方法包括：LOD分级控制远距离面片数、精简复杂模型、CPU端提前剔除不可见物体、在中低端机上关闭多光源/阴影/多Pass等使面片数翻倍的特性。 6.3 片元阶段压力 片元阶段的计算量取决于每帧绘制的像素数，由渲染分辨率和Overdraw共同决定。 渲染分辨率：分级控制分辨率是最实用的GPU优化手段。低端机降至0.7-0.8倍，像素数可减半，3D场景通常可接受轻微模糊。需注意实际渲染分辨率与设备DPR（Device Pixel Ratio）相关 —— 高DPR屏幕即使设置较低倍率仍可能产生高像素负担，建议按像素总数而非固定比例制定分档标准。URP项目中可利用Camera Stack将3D场景渲染到低分辨率RT中，避免UI同时变模糊。 Overdraw：指像素被重复绘制的次数。不透明物体应通过Render Queue调整渲染顺序来控制；粒子系统和UI是半透明Overdraw的主要来源 —— 中低端机精简粒子层级、限制Max Particles、裁剪纯透明贴图面积、考虑动画帧烘焙替代；UI侧注意全屏遮挡时关闭底层、Mask替换为RectMask2D等。此外，后处理、Blit、Copy等操作同样会产生全屏级别的Overdraw。特别地，开启HDR会引入额外的全分辨率RT拷贝操作以及额外的显存开销，在小游戏环境下对GPU压力影响显著，建议谨慎评估是否必要。 6.4 着色器与后处理 着色器：优先关注屏占比较高、Shader相对复杂的渲染对象（地表、建筑、特效），而非屏占比低的角色。项目中“万能Shader”常导致GPU对大量未使用特性进行全额计算，应通过关键字开关或拆分Shader优化。 后处理：是GPU压力的常见来源 —— Bloom中低端机可从1/4分辨率开始下采样；SMAA开销较高不建议在中低端使用；DOF移动端开销大需谨慎；尽量使用Local Volume按需开启而非全局常驻。 6.5 GPU带宽 GPU带宽主要影响能耗发热而非帧率。Mali官方数据显示约1GB/s带宽造成80-100mW功率开销，在游戏总功率中占比可观。在小游戏中，渲染产生的GPU带宽开销在DRAM带宽中占绝大部分，主要来自：频繁的RT使用与切换、高精度纹理资源的采样、以及HDR或后处理引入的额外RT拷贝。 优化手段包括：尽量复用RT减少切换、使用合理压缩格式降低单像素传输量、3D场景务必开启Mipmap（改善效果远超纹理压缩）、避免全局强制各向异性过滤、减少不必要的Copy和后处理采样。 6.6 GPU压力检查清单 关注渲染面片数，排查剔除图元占比过高的场景 按像素总数分档控制渲染分辨率，中低端机可降至0.7-0.8倍 排查Overdraw（粒子系统、UI、后处理），定位热点资源 关注屏占比高且Shader复杂的渲染对象，拆分“万能Shader” 后处理效果分级取舍，中低端机关闭开销较高的效果 关注GPU带宽 —— 纹理压缩、Mipmap、减少不必要的Copy和采样 GPU数据（纹理格式、WebGL 2.0特性等）务必以真机测试为准 7. 功耗优化 小游戏的功耗优化整体上可参考移动端的优化思路与经验，且由于移动设备散热条件有限，功耗问题在小游戏上同样不容忽视。功耗不仅关乎设备续航，更与发热直接挂钩 —— 当设备温度超过系统温控阈值后，会触发CPU/GPU降频，导致帧率骤降，形成“高功耗→发热→降频→卡顿”的恶性循环。因此功耗优化本质上也是性能稳定性优化，核心手段与CPU、GPU章节的优化思路一脉相承。 7.1 帧率对功耗的影响 首先要明确一个基本规律：在其它条件不变的情况下，帧率与功耗近似成正比，60FPS的功耗约为30FPS的两倍。这意味着高端机型上若不加限制地跑满帧率，反而可能因为功耗过高导致发热更严重。建议根据项目类型制定合理的目标帧率（如休闲游戏30FPS，中度游戏30~60FPS），也可对不同场景动态调整 —— 例如主界面、挂机场景降低帧率，核心战斗场景再恢复到目标帧率，从而有效控制整局功耗。 7.2 功耗的来源 小游戏运行时的功耗主要来自以下几个方面： CPU：逻辑计算、蒙皮、动画、物理、脚本等，是小游戏功耗最主要的来源。 GPU：顶点处理、片元着色、纹理采样、带宽传输等。 屏幕显示：屏幕亮度与分辨率直接关联功耗，但开发者通常无法直接控制。 网络传输：持续的网络请求与数据收发也会产生额外功耗。 对于开发者而言，能够直接着手优化的主要是CPU与GPU两部分。 7.2.1 CPU侧功耗优化 在原生APP上，通常需要分别排查CPU主线程与子线程中的压力来源。但小游戏环境下常规多线程能力受限，因此重点关注CPU主线程上各模块的耗时即可。排查思路与本文「CPU压力」章节一致：通过Profiler定位高耗时模块（渲染、UI、物理、动画、逻辑脚本等），找到项目中压力较大的模块并予以针对性优化。由于功耗与CPU占用正相关，降低主线程耗时本身就是在降低功耗。 7.2.2 GPU侧功耗优化 GPU侧功耗主要从GPU计算压力与GPU带宽两个维度进行排查，与本文「GPU压力」章节的思路一致：控制同屏面片数、合理设置渲染分辨率、减少Overdraw、精简Shader复杂度，同时关注纹理采样次数与带宽占用。降低GPU负载同样直接降低功耗。 7.3 功耗优化检查清单 根据项目类型制定合理的目标帧率，避免高端机无限制跑满 对不同场景动态调整帧率（主界面/挂机降帧，核心玩法恢复目标帧率） 通过CPU主线程Profiler定位高耗时模块并针对性优化 从GPU计算压力与GPU带宽两个维度排查GPU侧功耗 将高温环境（户外、充电时游玩）纳入测试覆盖范围 iOS高性能模式下尤其关注启动阶段的发热，避免长时间连续高负载 8. APP转小游戏经验分享：同步转异步功耗优化 本章分享一个实际项目中将APP迁移到小游戏时遇到的典型问题 —— AssetBundle同步加载的兼容性改造，以及我们在实战中摸索出的一套低成本解决方案。 8.1 问题背景 在原生APP项目中，同步加载与异步加载在实际运行时的体验差异通常不大，开发团队在早期也不一定会严格区分两者的使用场景。但在小游戏环境中，AssetBundle文件本身不支持同步加载（从已加载的AssetBundle中加载具体资源仍可使用同步接口），这意味着原本在APP中大量使用的同步加载逻辑都需要逐一改造。如果项目在原生APP阶段没有提前做好这方面的规范和管理，后续转换时的工作量会相当可观。 8.2 快速定位方法 在Unity编辑器中将小游戏工具面板的Play Mode切换为Web Play Mode，此时在Editor中运行游戏，一旦触发AssetBundle的同步加载就会直接抛出WebGL platform not support sync load method这类报错，帮助快速发现哪些加载链路存在问题。 进一步地，还可以在同步加载接口的报错位置额外增加一条报空日志，显式输出对应资源的路径信息，这样每次报错时就能直接看到是哪个AB文件在哪个时机被同步加载了，排查效率大幅提升，不必逐个翻看代码调用链。 8.3 集中预加载方案 从已加载的AssetBundle中加载具体资源仍然可以使用同步接口。基于这一点，核心思路就清晰了：只要在使用同步接口加载资源之前，确保对应的AssetBundle已经通过异步方式加载到内存中且未被卸载，那么原来的同步资源加载逻辑就完全可以保持不变。 按照这个思路，我们的做法是在游戏流程中找到一个统一的、早于所有实际资源加载的时机点，把所有会被同步加载使用的AssetBundle提前用异步接口统一加载一遍。只要保证后续真正使用同步接口加载资源时这些AB仍在内存中且未被卸载，整个项目中原有的同步加载逻辑就不需要逐一修改，也不会在小游戏环境中触发报错。 例如原先在Initialize阶段使用了同步加载会导致报错，我们找到了一个更早的时机点，通过PreloadAsync先用异步接口集中加载了timeConfig在内的多个后续会用同步加载的资源，那么后续的逻辑无需修改也不会报错了。 这种方式本质上是将“同步改异步”的改造工作从散落在各处的资源加载点集中收拢到了一个统一的预加载节点，既降低了修改工作量，也避免了逐处改造时容易遗漏或引入新问题的风险。 9. 结束语 这篇文章之所以称为简谱，实在是因为这些笔墨远不能达到面面俱到，很多内容还未涉及到，或者限于篇幅和重点不能深入讨论。它更多的是立足于如何以用好一套完善完整的性能工具为基础，构建发现问题-解决问题-监控问题的优化思维和优化体系，使得性能优化的工作事半功倍。更多的优秀内容，欢迎在UWA社区中进行搜索。 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。
Unity移动端游戏性能优化简谱之 功耗、发热和降频在软件层面的优化
《Unity移动端游戏性能优化简谱》共分为五个部分，今天向大家介绍文章的最后一部分：功耗、发热和降频在软件层面的优化，包含了移动端高功耗与发热产生机制、功耗与发热的定量数据分析、使用Perfetto的功耗调优实践等多项常见的游戏画面表现讲解。（全文长约3200字，预计阅读时间约15分钟） 前四部分可点击以下对应文章查看： 一、《Unity移动端游戏性能优化简谱之 前言》 二、《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（上）》 《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（下）》 三、《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（上）》 《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（下）》 四、《Unity移动端游戏性能优化简谱之 达到GPU压力与画面表现间的平衡》 1. 功耗、发热与降频基本原理 1.1 移动端高功耗与发热产生机制 手游运行时功耗由核心计算功耗（CPU/GPU/NPU）、访存功耗、外设功耗（屏幕/射频/传感器/存储）、系统调度开销四部分构成，其中对于手游来说尤其常见和需要关注的情况有： 高负载场景下（战斗、大世界渲染、物理模拟、网络同步），CPU/GPU持续高频运行，功耗可能持续过高； 手机机身散热面积有限，热量无法快速传导至外部，形成热堆积，且往往出现部分硬件模块压力尤其大，发热严重并传导到附近其他模块，使得这些模块也出现性能问题； 功耗与性能并非线性关系，超过能效拐点后，性能小幅提升会带来功耗大幅上涨，发热急剧恶化。 当SoC温度达到温控阈值，Android内核与固件会启动多级热防护机制，强制降低性能以控制发热，多数情况下体现为CPU/GPU频率降低、限制大核调度、减少并发线程数。 降频同时导致了CPU/GPU的算力下降，常直接导致游戏运行过程中帧率暴跌。尤其相对于其他前文讨论的性能问题，该问题反而在一些玩家的高端设备上更加显著，对于一些算力强劲的CPU/GPU，可能计算压力并不足以直接导致掉帧，但发热降频却导致了这些玩家有手机发烫、低帧卡顿等负面体验。 1.2 功耗与发热的定量数据分析 作为开发者和性能优化执行者，观察问题的视角显然还是要与玩家区分。因此，要精准定位功耗与发热的核心诱因，应避免依赖 “手感发烫”、“续航缩短”等主观感受，必须通过定量数据分析建立客观判断标准。正如上文讨论的，移动端游戏的功耗发热本质是硬件模块（CPU/GPU/存储/射频等）持续高负载运行的结果。所以定量分析的核心目标是：明确各硬件模块的功耗占比、定位高功耗场景的时间分布。 不过定量分析功耗发热问题的核心困难是，该问题其实已经脱离了游戏引擎可掌控的范畴，属于非常硬件层的问题。这就导致了不同芯片厂商、不同SoC厂商、不同手机厂商，其散热设计、保护机制等因素的不同都会导致发热和降频的情况互不相同；且他们提供的数据采集接口即便看起来单位一致，其实表示的实际开销也互不相同；在测试过程中，也会受到环境的干扰，比如冬夏气温、插电测试都会导致测试结果受到影响。 所以，应当尽量在固定设备使用可靠的工具进行测试，将数据进行按时间的、优化调试前后的纵向测试，而不是不同硬件、不同工具之间的横向测试。 1.3 使用Perfetto的功耗调优实践 1.3.1 市面上功耗测试工具评测 功耗优化的第一步是精准采集数据，市面上各类功耗测试工具各有优劣，需结合实际测试需求、设备适配性择优选用，以下是主流工具的实操测评与选型建议： （1）GOT Online作为常用性能监测工具，可实时采集帧率、帧耗时、CPU核心频率、内存占用、功耗峰值等核心数据，支持设备截屏、数据导出与对比分析，能直观呈现游戏运行时的功耗波动，基础监测能力在线，但针对细分硬件功耗的拆解能力有限。 （2）Trepn Profiler实测实用性较低，数据采集精度与稳定性难以满足专业调优需求；adb shell dumpsys batterystats指令虽能获取电池消耗数据，但数据维度单一、参考价值不大，仅能作为辅助参考，无法支撑深度功耗分析。 （3）Android Studio自带的Profiler工具，搭载On Device Power Rails Monitor（ODPM）模块，可监测CPU大小核、GPU、内存、屏幕、WiFi等硬件组件的功耗，但缺陷十分突出：不仅需要Debuggable包体，还仅适配Pixel系列机型，且测试时长不宜过长，通用性极差，难以适配主流安卓机型的规模化测试。 （4）Perfetto是移动端功耗测试的最优选择之一，无需Debuggable包体，支持USB/WebSocket连接实时录制。只不过在采集各硬件功耗轨道数据时，谷歌官方只在自己旗下的Pixel品牌手机中进行了专门的插桩，从而能够清晰呈现power.rails.cpu.big、power.rails.gpu、power.rails.memory.interface等细分模块的能耗值，还能通过ui.perfetto.dev在线分析Trace文件，精准定位功耗峰值与异常节点，是下文介绍的调优实践的核心工具。 移动端Unity游戏的能耗分布并非无迹可寻，核心能耗集中在CPU、GPU、内存、屏幕、网络和SoC等硬件模块，各模块的运行状态直接决定整机功耗。开展功耗测试前，需统一测试标准：固定30FPS帧率、固定屏幕亮度与刷新率，排除外界因素干扰；采用AB Test对比优化前后数据，依托Perfetto采集硬件细分功耗，搭配GOT Online排查CPU耗时、Gears监测GPU Counters，必要时用Renderdoc、Xcode截帧分析渲染细节，全方位定位问题。 结合实测数据逐一拆解如下。 1.3.2 GPU质与带宽对功耗的影响 测试数据如上图。GPU是游戏功耗的核心来源之一，其能耗与画质等级、数据带宽强相关，画质档位越高，GPU时钟频率、带宽占用越高，整机功耗随之陡增。实测数据显示，从省电、流畅、标准到精致画质，GPU带宽从1195MB/s升至1483MB/s，ddr.c、memory.interface、gpu.l2都有不同程度的上升；GPU Clocks从264M/s升至393M/s，GPU功耗从335mW攀升至543mW；整机功耗涨幅超270mW，GPU压力与能耗呈正相关。 带宽是GPU能耗的关键影响因子，半透明叠加层数、纹理格式与Mipmap开启状态，对带宽和功耗的影响尤为显著。对比测试发现，同等层数下，开启ASTC 4x4纹理压缩可降低带宽与功耗，而开启Mipmap的优化效果远优于纹理压缩：30层纹理开启Mipmap后，带宽从3.38GB降至0.9GB，GPU功耗直降49mW；50层纹理开启Mipmap，功耗降幅超160mW。据此可得出核心结论：3D渲染纹理务必开启Mipmap。 且带宽与能耗存在固定估算关系——1GB/s≈67mW，带宽越高，内存交互、GPU缓存的功耗损耗越大。 综上，建议所有3D场景纹理开启Mipmap，优先选用ASTC 4x4、ETC2等移动端压缩纹理，摒弃无压缩格式；根据机型性能划分画质档位，低端机锁定省电/流畅档位，降低GPU时钟频率与带宽占用；减少半透明叠加层数，规避无效渲染拉高带宽。从而最终降低GPU带宽。 1.3.3 屏幕和网络对功耗的影响 亮度： 刷新率： 画面内容（色彩鲜艳程度）： 测试数据如上图。屏幕能耗受三大因素主导：刷新率、亮度和画面色彩复杂度。实测数据表明，亮度与画面内容相同时，90帧刷新率的屏幕功耗高于60帧；刷新率与画面内容固定时，屏幕亮度越高，功耗越大；而刷新率和亮度不变，则彩色画面的屏幕功耗远高于纯黑画面——100%亮度下，彩色壁纸屏幕功耗达575mW，纯黑壁纸仅140mW，差距超4倍。开发者可通过动态调节亮度、适配机型刷新率和简化冗余画面色彩，实现屏幕功耗压降。 测试数据如上图。网络传输是隐性功耗增长点，10MB/s下载速率下，WiFi与移动数据的功耗差异显著，且均会大幅拉高整机功耗。无下载状态下，WiFi模组功耗仅62mW，开启下载后飙升至359mW；移动数据模组则体现在modem模块从280mW飙升至583mW功耗，加上CPU线程的开销整机总功耗涨幅超500mW。 综上，根据场景动态调节屏幕亮度，非核心场景降低屏幕刷新率，简化复杂彩色画面；网络端优化资源下载逻辑，避免大体积资源后台高频下载，线上游戏精简网络传输数据量、尽量减少持续性传输的做法，降低模组功耗。 1.3.4 CPU对功耗的影响 CPU功耗遵循动态功耗公式：P_dynamic ∝ C * V² * f，功耗与工作频率并非线性关系，核心频率越高，功耗呈指数级增长。主线程密集运算的功耗远高于多线程分摊，实测主线程运算功耗达3.67W，大核频率2.58GHz；改用JobSystem多线程后，总功耗降至2.55W，大核频率压至1.4GHz，优化效果显著。 此外，子线程运算对CPU功耗也是不可忽视的因素，但在引擎层缺乏可靠的手段予以分析。这里介绍一个使用SimplePerf工具采集并分析子线程的流程。只要用Release模式打包，搭配Create Symbols生成符号文件，便于SimplePerf工具解析函数调用栈，即可精准定位子线程高耗函数；获得的测试结果如上图。观察到该项目运行过程中存在一个占比极高的逻辑线程，超过了渲染线程甚至直逼主线程，这是非常异常的现象，点开观察细节后从该线程的函数名分析得到该线程为专门处理网络的线程。 如图测试，优化前CPU功耗高达4820mW，定位并清理冗余运算后，功耗降至3838mW，压降近千毫瓦。 综上，建议拆解主线程密集运算，将物理计算、动画更新等任务迁移至JobSystem，利用多线程分摊负载，避免CPU大核高频运行；通过SimplePerf定位子线程高耗函数，清理空转循环、冗余遍历等无效逻辑；严格采用Release模式打包，关闭调试相关冗余配置，减少CPU额外开销。 2. 结束语 这篇文章之所以称为简谱，实在是因为这些笔墨远不能达到面面俱到，很多内容还未涉及到，或者限于篇幅和重点不能深入讨论。它更多的是立足于如何以用好一套完善完整的性能工具为基础，构建发现问题-解决问题-监控问题的优化思维和优化体系，使得性能优化的工作事半而功倍。更多的优秀内容，欢迎在UWA社区中进行搜索。 至此《Unity移动端游戏性能优化简谱》已经全部更新完毕，文章从Unity移动端游戏优化的一些基础讨论出发，例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题，并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题，希望能提供给Unity开发者更多高效的研发方法和实战经验。
Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（下）
《Unity移动端游戏性能优化简谱》从Unity移动端游戏优化的一些基础讨论出发，例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题，并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题，希望能提供给Unity开发者更多高效的研发方法和实战经验。 今天向大家介绍文章第三部分：以引擎模块为划分的CPU耗时调优，共10小节，包含了渲染模块、UI模块、物理模块、动画模块、粒子系统、加载模块、逻辑代码、Lua等多个模块等常见的游戏CPU耗时调优讲解。 本章包含6-10小节，1-5小节请点击《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（上）》。 （全文长约14115字，预计阅读时间约30分钟） 文章第一部分《Unity移动端游戏性能优化简谱之 前言》、第二部分《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（上）》、《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（下）》可戳此回顾，完整内容可前往UWA学堂查看。 6. 粒子系统 围绕粒子系统相关优化更全面的内容可以参考《粒子系统优化——如何优化你的技能特效》。 6.1 Playing粒子系统数量 UWA统计了粒子系统数量和Playing状态的粒子系统数量。前者是指内存中所有的ParticleSystem的总数量，包含正在播放的和处于缓存池中的；后者指的是正在播放的ParticleSystem组件的数量，这个包含了屏幕内和屏幕外的，我们建议在一帧中出现的数量峰值不超过50（1GB机型）。 针对这两个数值，我们一方面关注粒子系统数量峰值是否偏高，可选中某一峰值帧查看到底是哪些粒子系统缓存着、是否都合理、是否有过度缓存的现象；另一方面关注Playing数量峰值是否偏高，可选中某一峰值帧查看到底是哪些粒子系统在播放、是否都合理。 前文提到的控制同种粒子数量上限也是常见方案。 分级策略也是粒子系统优化过程中必不可少的手段。比如对于一个有12345，五种子粒子的火焰特效Prefab，其中345可能是美术同学用来做火焰燃烧时飞溅的火星、飘散的烟尘、空气的波动的，只有12是火焰本体。则低端机上应该考虑只保留12。 虽然我们在讨论粒子系统对耗时的影响时，多数时候还是在讨论粒子系统的渲染开销，无论是CPU端还是GPU端的。但对于粒子系统使用非常重度、数量非常多的项目来说，Playing的粒子过多也会对其本身在CPU端进行更新计算的ParticleSystem.Update()耗时有所影响。 6.2 Culling Mode 为粒子系统设置合理的Culling Mode也是一种有效降低粒子系统CPU端更新耗时的方式。 相关的关键设置为如上图的Simulation Space和Culling Mode。 其中，Simulation Space可选项有Local，World，Custom；CullingMode可选项为Automatic，Pause And Catch-up，Pause，Always Simulate。通常创建的粒子系统的默认选项是Local和Automatic。 我们显然希望当粒子在屏幕外看不到时停止相关运算、减少其耗时开销。因此我们测试在不同设置组合下粒子系统在屏幕外时的ParticleSystem.Update()函数耗时，结果如下： 在Local模式下，只有Culling Mode为Always Simulate有例子更新耗时（没有开启额外的Module）。 在Local模式下，如果粒子系统开启了Collision等Module，那么Culling Mode为Automatic时也会产生耗时。 在Local模式下，如果是Culling Mode为Pause或者Pause and Catch-up，就算有Collision Module也没有耗时。 在World模式下，Culling Mode为Automatic也会有耗时（没有开启额外的Module）。 在World模式下，Culling Mode为Pause，就算有Collision，也没有耗时。 综上，我们可以看到Culling Mode的设置对不同设置的粒子系统开销的限制能力是合理递进的，这说明我们在设置粒子系统时应尽量至少设为Automatic；应慎用Simulation Space为World的粒子；应慎用Collision、Trigger、Noise、Trails这些颇为常见的Module；对于确实要使用Module的粒子，也应考虑进一步调整设置为Pause等以节省耗时。 6.3 Prewarm ParticleSystem.Prewarm作为粒子系统的核心初始化选项，其设计初衷是为了让粒子系统在实例化或从Deactive状态激活时，立即完成一次完整的生命周期模拟，使粒子效果从激活瞬间就呈现出“已运行一段时间”的成熟状态（例如持续燃烧的火焰、循环播放的光晕等），避免出现“从无到有”的突兀感。但该机制在带来表现优势的同时，也隐藏着显著的性能风险——尤其在粒子系统数量较多、效果复杂的场景中，Prewarm触发的瞬时计算开销可能成为CPU耗时峰值的主要诱因，甚至引发卡顿。 主要优化手段如下： 优化粒子系统本身的复杂度，通过降低Prewarm触发时的计算量，从源头减少耗时： 规避集中触发Prewarm的场景，当多个带Prewarm的粒子系统需要激活时，通过分帧激活、延迟激活、或缓存复用的方式分散计算压力。 7. 加载模块 围绕加载模块相关优化更全面的内容可以参考《Unity性能优化系列—加载与资源管理》。 7.1 Shader加载 （1）Shader.Parse Shader.Parse是指Shader加载进行解析的操作，如果此操作较为频繁，通常是由于Shader的重复加载导致的，这里的重复可以理解为2层意思。 第一层是由于Shader的冗余导致的，通常是因为打包AssetBundle的时候，Shader被被动打进了多个不同的AssetBundle中而没有进行依赖打包，这样当这些AssetBundle中的资源进行加载的时候，会被动加载这些Shader，就进行了多次“重复的”Shader.Parse，所以同一种Shader就在内存中有多份了，这就是冗余了。 要去除这种冗余的方法也很简单，就是把这些会冗余的Shader依赖打包进一个公共的AssetBundle包。这样就会主动打包了，而不是被动进入某些使用了这个Shader的包体中。如果对这个Shader进行了主动打包，那么其它使用了这个Shader的AssetBundle中就只会对这个Shader打出来的公共AssetBundle进行引用，这样在内存中就只有一份Shader，其它用到这个Shader的时候就直接引用它，而不需要多次进行Shader.Parse了。 第二层意思是同一个Shader多次地加载卸载，没有缓存住导致的。假设AssetBundle进行了主动打包，生成了公共的AssetBundle，这样在内存中只有这一份Shader，但是因为这个Shader加载完后（也就是Shader.Parse）没有进行缓存，用完马上被卸载了。下次再用到这个Shader的时候，内存里没有这个Shader了，那就必须再重新加载进来，这样同样的一个Shader加载解析了多次，就造成了多次的Shader.Parse。一般而言，经过变体优化以后的开发者自己写的Shader内存占用都不高，可以统一在游戏开始时加载并缓存。 特别地，对于Unity内置的Shader，只要是变体数量不多的，可以放进Project Settings中的Always Included中去，从而避免这一类Shader的冗余和重复解析。 （2）Shader.CreateGPUProgram 该API也会在加载模块主函数甚至UI模块、逻辑代码的堆栈中出现。相关的讨论上文已经涉及，优化方法相同，不再赘述。 7.2 Resources.UnloadUnusedAssets 该API会在场景切换时被Unity自动调用，一般单次调用耗时较高，通常情况下不建议手动调用。 但在部分不进行场景切换或用Additive加载场景的项目中，不会调用该API，从而使得项目整体资源数量和内存有上升趋势。对于这种情况则可以考虑每5-10min手动调用一次。 Resources.UnloadUnusedAssets的底层运作机理是，对于每个资源，遍历所有Hierarchy Tree中的GameObject结点，以及堆内存中的对象，检测该资源是否被某个GameObject或对象（组件）所使用，如果全部都没有使用，则引擎才会认定其为Unused资源，进而进行卸载操作。简单来讲，Resources.UnloadUnusedAssets的单次耗时大致随着（（GameObject数量+Mono对象数量）*Asset数量）的乘积变大而变大。 因此，该过程极为耗时，并且场景中GameObject/Asset数量越高，堆内存中的对象数越高，其开销也就越大。对此，我们的建议如下： （1）Resources.UnloadAsset/AssetBundle.Unload(True) 研发团队可尝试在游戏运行时，通过Resources.UnloadAsset/AssetBundle.Unload(True)来去除已经确定不再使用的某一资源，这两个API的效率很高，同时也可以降低Resources.UnloadUnusedAssets统一处理时的压力，进而减少切换场景时该API的耗时。 （2）严格控制场景中材质资源和粒子系统的使用数量 专门提到这两种资源，因为在大多数项目中，虽然它们的内存占用一般不是大头，但往往资源数量远高于其他类型的资源，很容易达到数千的数量级，从而对单次Resources.UnloadUnusedAssets耗时有较大贡献。 （3）降低驻留的堆内存 堆内存中的对象数量同样会显著影响Resources.UnloadUnusedAssets的耗时，这在上文也已经讨论过。 7.3 加载AssetBundle 使用AssetBundle加载资源是目前移动端项目中比较普遍的做法。 而其中，应尽量用LZ4压缩格式打包AssetBundle，并用LoadFromFile的方式加载。经测试，这种组合下即便是较大的AssetBundle包（包含10张1024*1024的纹理），其加载耗时也仅零点几毫秒。而使用其他加载方式，如LoadFromMemory，加载耗时则上升到了数十毫秒；而使用WebRequest加载则会造成AssetBundle包的驻留内存显著上升。 这是因为，LoadFromFile是一种高效的API，用于从本地存储（如硬盘或SD卡）加载未压缩或LZ4压缩格式的AssetBundle。 在桌面独立平台、控制台和移动平台上，API将只加载AssetBundle的头部，并将剩余的数据留在磁盘上。AssetBundle的Objects会按需加载，比如：加载方法（例如：AssetBundle.Load）被调用或其InstanceID被间接引用的时候。在这种情况下，不会消耗过多的内存。 但在Editor环境下，API还是会把整个AssetBundle加载到内存中，就像读取磁盘上的字节和使用AssetBundle.LoadFromMemoryAsync一样。如果在Editor中对项目进行了分析，此API可能会导致在AssetBundle加载期间出现内存尖峰。但这不应影响设备上的性能，在做优化之前，这些尖峰应该在设备上重新再测试一遍。 要注意，这个API只针对未压缩或LZ4压缩格式，因为如果使用LZMA压缩，它是针对整个生成后的数据包进行压缩的，所以在未解压之前是无法拿到AssetBundle的头信息的。 由于LoadFromMemory的加载效率相较其他的接口而言，耗时明显增大，因此我们不建议大规模使用，而且堆内存会变大。如果确实有对AssetBundle文件加密的需求，可以考虑仅对重要的配置文件、代码等进行加密，对纹理、网格等资源文件则无需进行加密。因为目前市面上已经存在一些工具可以从更底层的方式来获取和导出渲染相关的资源，如纹理、网格等，因此，对于这部分的资源加密并不是十分的必要性。 在UWA GOT Online Resource模式下的资源管理页面中可以排查加载耗时较高的AssetBundle，从而排查和优化加载方式、压缩格式、包体过大等问题，或者对反复加载的AssetBundle考虑予以缓存。 7.4 加载资源 有关加载资源所造成的耗时，若加载策略比较合理，则一般发生在游戏一开始和场景切换时，往往不会造成严重的性能瓶颈。但不排除一些情况需要予以关注，那么可以把资源加载耗时的排序作为依据进行排查。 对于单次加载耗时过高的资源，比如达到数百毫秒甚至几秒时，就应考察这类资源是否过于复杂，从制作上考虑予以精简。 对于反复频繁加载且耗时不低的资源，则应该在第一次加载后予以缓存，避免重复加载造成的开销。 值得一提的是，在Unity的异步加载中有时会出现每帧进行加载所能占用的最高耗时被限制，但主线程中却在空转的现象。尤其是在切场景的时候集中进行异步加载，有时会耗费几十甚至数十秒的时间，但其中大部分时间是被空转浪费的。这是因为控制异步加载每帧最高耗时的API Application.backgroundLoadingPriority默认值为BelowNormal，每帧最多只加载4ms。此时一般建议把该值调为High，即最多50ms每帧。 在UWA GOT Online Resource模式下的资源管理页面中可以排查加载耗时较高的资源，从而排查和优化加载方式、资源过于复杂等问题，或者对反复加载的资源考虑予以缓存。 7.5 实例化和销毁 实例化同样主要存在单个资源实例化耗时过高或某个资源反复频繁实例化的现象。根据耗时多少排列后，针对疑似有问题的资源，前者考虑简化，或者可以考虑分帧操作，比如对于一个较为复杂的UI Prefab，可以考虑改为先实例化显眼的、重要的界面和按钮，而翻页后的内容、装饰图标等再进行实例化；后者则建立缓存池，使用显隐操作来代替频繁的实例化。 在UWA GOT Online Resource模式下的资源管理页面中可以排查实例化耗时较高的资源，从而排查和优化资源过于复杂的问题，或者对反复实例化的资源考虑予以缓存。 7.6 激活和隐藏 激活和隐藏的耗时本身不高，但如果单帧的操作次数过多就需要予以关注。可能出于游戏逻辑中的一些判断和条件不够合理，很多项目中往往会出现某一种资源的显隐操作次数过多，且其中SetActive(True)远比SetActive(False)次数多得多、或者反之的现象，亦即存在大量不必要的SetActive调用。由于SetActive API会产生C#和Native的跨层调用，所以一旦数量一多，其耗时仍然是很可观的。针对这种情况，除了应该检查逻辑上是否可以优化外，还可以考虑在逻辑中建立状态缓存，在调用该API之前先判断资源当前的激活状态。相当于使用逻辑的开销代替该API的开销，相对耗时更低一些。 在UWA GOT Online Resource模式下的资源管理页面中可以排查激活隐藏操作较频繁的资源，从而排查和优化相关逻辑和调用。 8. 逻辑代码 逻辑代码的CPU耗时优化更多是结合项目实际需求、考验程序员本人的过程，很难定量定性进行讨论。不过UWA SDK中提供了方便开发者在逻辑代码中进行打点的API&UWA GOT Online，从而将复杂的函数拆解开，在报告中排查堆栈耗时、更快速地验证优化效果。 我们发现有越来越的团队在使用JobSystem将主线程中的部分逻辑代码放入子线程中来进行处理，对于可以并行运算的逻辑，非常推荐将其放入到子线程中来处理，这样可以有效降低主线程CPU处理逻辑运算的压力。 9. Lua GOT Online Lua模式提供的分析Lua造成的CPU耗时工具可视化程度高，堆栈清晰明了，还提供了实用且特色的倒序调用分析功能。以下结合一个Lua报告Demo简单介绍使用该工具分析Lua耗时的方法。 重申：Lua报告中出现的函数名称格式为：函数名称@文件名：行号。 可以通过报告提供的Lua文件名/行号/函数名来定位CPU耗时的瓶颈函数和CPU耗时峰值的具体原因。Lua函数的命名格式为X@Y:Z，其中X是其函数名，在无法获取时，X会变为默认的unknown；Y是该函数定义的文件位置；Z则是该函数被定义的行号。需要注意的是，当Lua脚本以字节码运行时，该值将始终为0，因此建议在测试时尽可能使用Lua源码来运行。 （1）正序调用分析——总表（曲线图+列表） 曲线图： 曲线选取了选取总体Lua代码耗时和按照耗时均值正向排序的前五个函数耗时组成耗时曲线图，每一个数据点代表了该函数在当前帧（横坐标）的耗时（纵坐标），有助于定位耗时瓶颈函数。 列表： 列表默认按照耗时均值从高到低对Lua函数进行了排序，粗略展示了函数名、总CPU耗时、场景CPU耗时、耗时均值等数据。通过点击函数，可以进入对应的单个函数分析页面。 （2）正序调用分析——单个函数页（截图+曲线图+堆栈信息） 截图： 项目运行时截图与使用者选中的帧大致对应，有助于定位问题。 曲线图： 曲线图包括了CPU耗时曲线图和调用次数曲线图；也可以使用下方条缩放曲线观察局部耗时情况。 从曲线图中可以观察到：函数是否存在持续性高耗时；函数是否存在短暂的大量耗时，导致卡顿；某些函数单次耗时并不高，但因为被大量的调用，导致函数总耗时较高。 函数XXXX堆栈信息 （列表）： 其中，可以在右上角选定列表数据的时间范围：总体堆栈信息时，时间范围为全部测试时间；指定场景堆栈信息时，时间范围为指定场景的开启时间；指定帧堆栈信息时，时间范围为当前在曲线图中选中的指定帧。 列表中各项指标含义是：总体占比，以根节点函数的总耗时为100%，当前节点函数总耗时相对根节点函数的总耗时占比；自身占比，以根节点函数的总耗时为100%，当前节点函数自身耗时相对根节点函数的总耗时占比；总耗时，时间范围内执行该函数的耗时；自身耗时，时间范围内去除子节点函数（该函数调用的函数）耗时剩余的耗时；调用次数，时间范围内该函数被调用的次数；单次耗时，总耗时/调用次数，表示每次执行该函数的平均耗时；显著调用帧数，该函数自身耗时大于3ms的帧数。 （3）倒序调用分析——总表（曲线图+列表） 曲线图：与正序调用分析不同的是，选取了自身耗时正向排序的前五个函数，每一个数据点代表了该函数在当前帧（横坐标）的自身耗时（纵坐标）。 列表：与上同理。 （4）倒序调用分析——单个函数页（截图+曲线图+堆栈信息） 函数XXXX堆栈信息 （列表）： 各项指标含义（与正序相比有所不同）变为了：自身占比，以选定函数的自身耗时总和为100%，这条调用路径下选定函数的自身耗时相对选定节点函数总自身耗时的占比；自身耗时，时间范围内，这条调用路径下，选定函数自身耗时的总和；调用次数，这条调用路径的调用次数；单次耗时，代表这条路调用路径下，选定函数的平均耗时。 在通过以上界面定位到自身耗时较高的函数后，常见的优化手段有：优化该函数的函数体，减少该函数自身的耗时；定位调用次数较多的调用路径，减少调用次数。 （5）优化建议 优化单次调用耗时过高的函数的函数体，减少该函数自身的耗时； 定位调用次数较多的调用路径，减少调用次数； 在高调用次数的函数中，尤其关注其中是否同时存在大量Lua和C#之间的穿梭调用，效率较低的同时可能对CPU端的能耗和发热产生大幅度贡献； UWA报告中的Lua函数耗时相当于在进出函数时打点，统计耗时。所以如果Lua脚本运行时调用了C#函数，这部分C#函数是会被统计进去的。 因此，需要关注和C#穿插调用的情况。对于一些反复调用同一个C#函数，或者调用多个C#函数但流程比较固定的情况，应考虑将相关逻辑移入C#层，把需要改动的参数开放。即可能需要多传几个参数，但跨语言穿梭调用的次数能够大幅降低，从而节省开销。 10. ILRuntime ILRuntime的使用率相对低一些，但有些团队延续着之前项目的开发习惯，仍在移动端项目中大规模使用ILRuntime。但根据UWA经验，使用ILRuntime的项目无论是逻辑耗时还是堆内存相关的性能表现都较为糟糕，因此建议尽量不用或少用。在热更方案的选择上，更多项目团队都在使用的Lua，或最近使用率飙升的HybridCLR（huatuo），仅从性能层面来看也都是更好的替代方案。 以下针对已经遇到ILRuntime导致的性能问题，但短时间内无法调换方案的情况，提出一些相对有效的优化手段。 CPU层面： 尤其是战斗部分的逻辑尽量少用，考虑写成固定的C#代码。在ILRuntime代码中如果有大量的密集型的计算或者循环，会导致性能急速的下降。所以在ILRuntime的使用上，可以用来做一些UI的更新，在核心战斗逻辑中避免使用，需要控制力度； 打DLL的时候选择Release的方式； 对于Android平台，可以选择使用Mono的模式打包，然后使用反射的方式进行加载，这样可以做到和原生C#效率相当。 堆内存层面： 尽量少用，尤其是核心战斗模块，因为其对于数值类型的变量也存在装箱的问题； 关闭Debug的宏，可以降低堆内存的分配，在PlayerSettings中定义DISABLE_ILRUNTIME_DEBUG宏。 官方文档提供的一些优化建议可以作为进一步节省ILRuntime开销的思路：https://ourpalm.github.io/ILRuntime/public/v1/guide/FastQA.html 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。
Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（下）
《Unity移动端游戏性能优化简谱》从Unity移动端游戏优化的一些基础讨论出发，例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题，并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题，希望能提供给Unity开发者更多高效的研发方法和实战经验。 今天向大家介绍文章第二部分：资源内存、Mono堆内存等常见游戏内存控制，共13小节，包含了纹理资源、网格资源、动画资源、音频资源、材质资源等多个资源内存以及Mono堆内存等常见的游戏内存控制讲解。 本章包含8-13小节，1-7小节请点击《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（上）》。 （全文长约11400字，预计阅读时间约20分钟） 文章第一部分《Unity移动端游戏性能优化简谱之 前言》可戳此回顾，完整内容可前往UWA学堂查看。 8. Render Texture 8.1 渲染分辨率 资源列表中的一些RT资源能反映项目当前的渲染分辨率。对于GPU和渲染模块压力较大的项目，在中低端机型上降低其渲染分辨率是非常直观有效的分级策略。但相关调整比起说影响内存，还是更多地影响GPU片元计算压力和带宽，后文GPU章节会进行更加详细的讨论。 如果一些其他的RT资源分辨率过高也应引起注意，尤其是2048*2048以上的资源。应当排查是否有必要用到如此精细的RT，在低端机上考虑采用更低分辨率的效果。 8.2 抗锯齿 资源列表中展示了RT资源的AA倍数。开启多倍AA会使RT占用内存成倍上升，并对GPU造成压力。建议排查是否有必要开启AA，尤其在中低端机上，可以考虑关闭此效果。 特别的是，在华为部分机型上2倍的AA会失效。即已经造成了性能消耗但没有实际起到抗锯齿效果。 8.3 后处理 一些常见的后处理相关的RT（如Bloom、Blur）是从1/2渲染分辨率开始采样，可以考虑改从1/4开始采样、并减少下采样次数，从而节省内存并降低后处理对渲染的压力。 站在性能优化的角度，在中低端机型上甚至最好分级地关闭各类后处理。围绕一些常见后处理效果的讨论会在下文GPU部分中进一步展开。 8.4 URP下的RT 使用URP时，内存中会多出_CameraColorTexture和_CameraDepthAttachment两份RT资源作为渲染目标，而开启URP相机的CopyDepth和CopyColor设置时会额外产生_CameraDepthTexture和_CameraOpaqueTexture作为中间RT。当资源列表中出现这两种RT时，需要排查确实是否用到CopyDepth和CopyColor，否则应予以关闭以避免不必要的浪费。 9. Shader资源 9.1 ShaderLab Unity 2019.4.20是Shader内存统计方法的一个转折点。在此之前，Shader的内存主要统计在ShaderLab中，而之后则主要统计在Shader资源自身身上。 对于Unity 2019.4.20之前的版本的项目，查看ShaderLab的内存使用Memory Profiler截帧查看。而无论是Shader资源本体还是ShaderLab内存占用过高，都要着手于控制Shader的数量、变体数量和Shader复杂度。 9.2 变体数和复杂度 对于单个Shader资源而言，其内存占用高本质在于其编译后的行数高。因此也可以视为，怎么控制变体的数量和每个变体的平均行数（也就是Shader的复杂度），这两个乘数的问题。 其中，变体数过多是造成一个Shader资源内存占用过大、占用包体过大的主要原因，也是相对比较好和排查和修改的部分。在项目迭代中可能会出现已经被弃用或者没有被实际使用到的关键字，导致变体成倍上升；又或者Shader写的比较复杂，其中一些关键字组合永远不会被用到，从而导致很多变体是多余的。UWA的本地资源检测中提供了Shader检测功能，可以看到变体数量，定位变体数过多的Shader资源。 针对上述情况，Unity提供了回调函数，在项目打AssetBundle包或者Build时线剔除用不到的关键字或关键字组合相关的变体。剔除Shader变体的方法可以参考《Stripping scriptable shader variants》。 而Shader的复杂度同时还涉及到GPU性能相关的优化。具体在后文GPU部分有更加详细的讨论。 9.3 冗余 Shader冗余尤其需要予以关注，Shader的冗余不光导致内存上升，还可能造成重复解析，即运行时不必要的Shader.Parse和Shader.CreateGPUProgram API调用耗时。 一般的做法是，首先使用ShaderVariantCollection这一Unity自带的资源类型收集项目渲染过程中会使用到的变体。将其和Shader依赖打成一个包。在游戏运行过程中统一加载、缓存和Warmup，将Shader.CreateGPUProgram API调用耗时集中在游戏初期或场景切换时完成，避免战斗过程中出现首次加载和参与渲染的Shader变体因而产生耗时卡顿的问题。 收集变体还有一个好处，即可以反推哪些关键字组合或变体是渲染中用不到的，再进行上文的通过回调剔除变体的做法，可以使得Shader整体的内存得到良好的控制。 一般而言，在复杂度和变体数量都控制得比较好、排除了冗余并且剔除了多数用不到的变体的前提下，根据经验，一些对画质需求非常高、PBR Shader也使用得非常重度的移动端项目也完全可以把Shader内存控制在50-60MB，而这种值进行缓存是完全可以接受的。 特别地，以上描述主要对开发者自己写的Shader适用。而内置Shader一般建议放进Project Settings-Graphics中的Always Include Shader列表中以防止冗余；相反地，自己写的Shader则不建议放入，以防止冗余。 9.4 Standard Shader 在资源列表中发现Standard、ParticleSystem/Standard Unlit。这两种Shader变体数量多，其加载耗时会非常高，内存占用也偏大，不建议直接在项目中使用。出现的原因一般是导入的FBX模型中或者Unity自身生成的一些3D对象使用了自带的Default Material，从而依赖了Standard Shader，建议予以排查精简。也可以结合UWA在线AssetBundle检测工具排查是哪个AssetBundle包中哪些资源引用了Standard Shader和ParticleSystem/Standard Unlit。如果确实要使用Standard Shader或ParticleSystem/Standard Unlit，应考虑自己重写一个Shader并只包含自己需要用到的变体。 9.5 使用本地资源检测排查Shader问题 在本地资源检测中包含了“项目中：全局关键字过多的Shader”、“项目中：可能生成变体数过多的Shader”、“Build后：生成变体数过多的Shader”、“使用了Standard Shader的材质”等上文已经提及的检测规则，方便开发者精确定位存在潜在性能问题的Shader资源。 10. 字体资源 若单个字体资源内存占用超过10MB，可以认为该字体资源内存偏大。可以考虑使用FontPruner 字体精简工具或其他字体精简工具，对字体进行瘦身，减小内存占用。 我们也需要关注项目中字体数量过多的情况，因为每个Font会对应一个Font Texture字体纹理，所以字体资源数量多了，Font Texture的数量也多了，从而占用较多内存。 11. 粒子系统 将UWA的粒子系统曲线结合粒子系统资源列表来看。会发现很多项目的内存中粒子的数量会远远高于实际Playing的粒子数量，在曲线中表现为蓝色曲线要比紫色曲线高出很多；在资源列表中表现出有大量资源在内存中的数量峰值远高于Playing数量峰值。这说明粒子系统内存很可能存在大量的缓存浪费。 此时一方面需要检查是否存在迭代过程中有被弃用但未删除的粒子系统对象、或制作过程中测试过的组件但未解除依赖的粒子系统组件。尽管是Deactive状态，但仍会随着Prefab进入包体、加载进场景，从而进入内存；另一方面则可以考虑优化对粒子的缓存策略，减少不必要的粒子缓存，尽量做到播多少粒子就缓存多少粒子。 12. Mono堆内存 UWA GOT Online Mono模式报告提供了堆内存具体分配和堆内存泄露分析两个主要功能，供开发者分析项目中堆内存存在的问题。 12.1 原理简述 对于游戏项目来说，除了上述常见的资源导致的内存占用外，还有一部分大头来自于代码相关的占用。 Mono堆内存是Unity C#脚本运行时的核心内存区域，主要用于托管对象（如string、List、自定义类实例、委托等）的动态分配与管理。除了驻留量高同样会和其它内存问题一样影响闪退风险外，堆内存的分配情况不合理还会直接影响GC（垃圾回收）耗时、帧稳定性，也需予以关注。 机制上，+Unity采用分代式垃圾回收机制管理Mono堆，核心逻辑可分为三步： 对象分配：C#中new创建的引用类型对象、值类型装箱、字符串拼接等操作，均会从Mono堆中申请内存空间。堆内存以连续内存块形式管理，优先使用空闲区域分配新对象。 堆扩容：当堆内空闲空间不足时，Unity不会立即触发GC，而是向系统申请扩容堆内存（默认扩容幅度为当前堆大小的50%~100%），直至达到设备内存上限。 垃圾回收：当堆内存占用达到阈值、或手动调用GC.Collect()时，GC会遍历所有托管对象，标记不再被引用的对象，在连续约6次GC都不被引用时，释放其内存，回收的空间会重新加入堆的空闲列表，供后续分配使用。 这就涉及到堆内存分析中三个相当常见的问题： 堆内存的构成。我们不妨延续2.1章节的讨论。如图，结合Memory Profiler截图来看，Mono堆内存占用（即Reserved Mono）实际上主要分为3个部分，即依次为预留值（210.9-201.5的部分）、实际对象占用（Objects）、碎片（Empty Fragmented Heap Space）。开发者可控的部分基本只有对象实际占用部分，从上文的扩容机制来看、这部分基数越高则预留的部分也会越高。因此优化对象实际占用对内存的降低效果往往大于表面数值。 堆内存碎片。结合以上堆的扩容机制，游戏过程中常存在高频的堆内存分配，则在大块对象被释放后立刻分配小对象，即很容易出现碎片。在堆内存分配释放较为频繁的项目中，UWA常观察到对象实际占用的部分和碎片会逐渐趋于相等，因此只能通过尽量避免大小对象频繁交错分配释放的方式，减缓碎片逐渐上升的趋势；且与预留值同样地、降低对象实际占用仍是主要的优化对象 “Mono 堆内存只增不降”的错误说法。时不时开发者向UWA如此提问：测试报告中Reserved Mono曲线为什么是有增有降的？按网上普遍的说法，Mono堆内存不该是只增不降的吗？事实上，GC回收的空闲内存会保留在堆内供复用，若堆内空闲空间长期过剩，Unity会在特定时机（如低内存告警、场景切换）主动将多余空闲内存归还系统，实现堆内存收缩。也即，造成堆内存主要占用的内存页是会返还给系统的，仅有地址空间是只增不降的。 事实胜于雄辩。如图为2022.3.60版本下使用IL2CPP打包的测试，分配了1GB大小的String类型堆内存对象并在一段时间后释放，并适当调用GC。可以观察到无论是Reserved Mono还是整个进程的PSS Total都得到了正常释放。 对于堆内存分配和驻留不合理导致的性能问题，UWA GOT Online Mono模式报告都有相应的子功能定位和分析具体问题。 12.2 持续/峰值分配堆栈 在堆内存具体分配页面中，可以排查高堆内存分配函数的具体堆栈。我们主要关注两种形式的堆内存分配。 一种是单次过高的堆内存分配。这种峰值一般出现在游戏初期的读表操作导致的大量分配，需要开发者结合具体堆栈信息排查是否合理。而游戏运行过程如果还出现堆内存分配峰值则需要着重关注。 另一种则是持续偏高的堆内存分配。如果项目中存在每帧或者每隔几帧就分配较多堆内存的现象需要引起注意。持续的高堆内存分配会导致GC频率增高，从而在游戏中形成频繁的卡顿，可以结合堆栈排查是什么子节点在持续分配堆内存。 12.2 泄露分析 在泄露分析页面中排查项目中各个函数的堆内存驻留情况。选中图表中前后两处采样帧进行比较，就可以从堆栈中查看堆内存驻留情况的变化，查看驻留上升主要是什么堆栈分配造成的。 一方面可以避免堆内存持续上升造成泄露的风险，另一方面针对驻留高的函数进行优化，予以及时释放，可以降低单次GC的耗时。我们一般推荐测试GOT Online Mono模式的测试时长尽量长一些，比如1个小时，否则泄露问题往往难以被暴露。 13. 其他内存 我们在处理完相对可控的Unity分配的Reserved Total部分内存后，仍然不能掉以轻心。常见情况下，我们在内存部分一开始介绍的PSS Total值是要比Reserved Total值高出200-300MB，可以认为是在接受范围内。但是，也会有项目出现这两个数值差值高出500-600MB甚至更高的情况。 13.1 Lua 内存 UWA GOT Online Lua模式提供了针对Lua脚本语言的性能测试。 其中出现的函数名称格式为：函数名称@文件名：行号。 可以通过报告提供的Lua文件名/行号/函数名来定位CPU耗时的瓶颈函数和CPU耗时峰值的具体原因。Lua函数的命名格式为X@Y:Z，其中X是其函数名，在无法获取时，X会变为默认的unknown；Y是该函数定义的文件位置；Z则是该函数被定义的行号。需要注意的是，当Lua脚本以字节码运行时，该值将始终为0，因此建议在测试时尽可能使用Lua源码来运行。 针对Lua分配的内存，报告在总体堆内存、Lua内存峰值分页中的折线图每30帧采一次值。根据折线图走势，帮助开发者对项目运行过程中的堆内存分配情况有大致的了解。其中，堆内存的下降意味着发生了一次GC。相对高频的GC使得Lua内存峰值变低，但也会有相应的额外耗时开销。关于Lua GC机制和策略对内存的具体影响，下文还将具体讨论。从下图的示例曲线中就可以看出，该项目的Lua内存在整体测试流程中存在非常显著的上升趋势，不过所幸总体基数很小，如果这里是从70MB持续上升至120MB，就需要重点关注Lua模块中可能的泄露风险了。 还可以注意到在总体堆内存页面中还有对Table、Function、Userdata的统计，提供一些辅助信息。整体的内存变化趋势其实开发者已经可以从刚才的曲线中窥知一二了，那这些具体的数据结构的数量统计意义在于，一方面可以推测是那种数据结构的上升趋势相同，那么它可能就是罪魁祸首，为之后的分析提供参考和抓手；另一方面则是对数量尤其高的类型产生警惕，比如大部分项目Table数量主体一般在10w-20w，但有的项目则达到了80w-90w的驻留，这就存在比较大的排查和优化空间了。 其中，查看内存具体分配功能和Mono模式报告大同小异。 Lua模式报告中还有一个重要功能，即Mono对象引用统计。该功能的意义就在于辅助开发者排查Lua端GC机制导致的Mono堆内存泄露风险，具体的解释将在下文讨论。 在柱状体选择对应帧后，列表中会显示该帧的Mono对象类型列表。其中： 对象类型：表示Unity Object对象的具体类型； 对象个数：表示这种类型的对象个数； Destroyed对象个数：表示已经被Destroyed，但Lua层还有相关引用的这种类型的对象个数。需要关注Destroyed对象个数，如果数目较大，C#堆内存存在泄露风险。 13.2 Lua GC 对于现代编程语言的自动化内存管理机制而言，为了实现在内存不再使用的时候能够自动释放它们的目的，有两种主流做法：引用计数和扫描标记。Lua使用的是基于后者的GC机制，对于动态语言来说，引用计数带来的额外开销太大，尤其是即使一段程序完全不分配内存，也需要承担这额外开销。Lua的垃圾收集器本身并不负责内存分配释放，内存的底层管理是通过在创建Lua虚拟机时从外部注入的分配器完成的。虚拟机工作时所有产生的对象都被串在一个链表上组成一个集合，而被虚拟机根集间接引用的对象都会被保留，剩下的对象引用无法被根集引用，则会在恰当的时机回收。虚拟机的根集包括了注册表，以及原生类型的metatable。而全局表、主线程、标准库的代码等等，都被注册表所引用。简单来说，就是扫描并标记了无引用的对象，并在适当的时候回收。 在Lua 5.0以前，Lua使用的是一个非常简单的标记扫描算法。它从根集开始遍历对象，把能遍历到的对象标记为活对象；然后再遍历通过分配器分配出来的对象全集链表，把没有标记为活对象的其它对象都删除。 从Lua 5.1开始实现了一个步进式垃圾收集器。这个新的垃圾收集器会在虚拟机的正常指令逻辑间交错分布运行，尽量把每步的执行时间减到合理的范围。Lua 5.1采用了一种三色标记的算法。每个对象都有三个状态：无法被访问到的对象是白色；可访问到，但没有递归扫描完全的对象是灰色；完全扫描过的对象是黑色。基于此，我们可以假定在任何时间点，下列条件始终成立： 所有被根集引用的对象要么是黑色，要么是灰色的。 黑色的对象不可能指向白色的。 白色对象集就是会被回收的部分；黑色对象集就是需要保留的部分；灰色对象集还需要被进一步遍历。 那么，在垃圾收集器工作过程中，通过充分遍历灰色对象，就可以把它们转变为黑色对象，直至所有灰色对象消失，收集过程也就完成了。 接下来，Lua GC的时机究竟是什么时候呢？事实上，GC的步进并不和真实的时间相关，它只和虚拟机分配新的内存有关。也就是说，只要虚拟机不分配更多内存，GC是不会自动运行的。GC依靠新增内存量来决定该做多少工作，它也依靠标记或清理的内存量来决定工作做了多少。每次做多了，会导致程序更多额外的停顿，做少了，会导致内存回收速度赶不上新增速度。步进式GC比全量GC复杂之处在于，它不能再只用一个量来控制GC的工作时间。对于全量GC，我们能调节的是GC的发生时机，对于Lua 5.0，就是2倍上次GC后的内存使用量；在5.1以后的版本中，这倍数可以由参数LUA_GCSETPAUSE调节。另外增加了LUA_GCSETSTEPMUL来控制GC推进的速度，默认是2，也就是新增内存速度的两倍。Lua用扫描内存的字节数和清理内存的字节数作为衡量工作进度的标准，有在使用的内存需要标记，没在使用的内存需要清理，GC一个循环大约就需要处理所有已分配的内存数量的总量的工作。这里2是个经验数字，通常可以保证内存清理流程可以跑的比新增要快。 举例而言，默认不做任何修改的状况下，上次GC回收后是100MB，那么Lua就会等到内存一直达到200MB时才会进行下一次GC。这种默认的频率有其风险性，而实际上是可以通过LUA_GCSETPAUSE调节的——当开发者结合数据判断当前GC频率过低而一定程度上导致了Lua内存峰值偏高时，完全可以考虑调低这个参数来提高GC的频率。 13.3 Lua的Mono对象引用 在使用Lua进行游戏开发时，肯定会涉及到去访问一些Unity（或者说C#的）的对象。此时，我们在Lua中写了Object.Instantiate来实例化一个对象出来，实际上在Lua层会基于各个类自动生成Wrapper代码，把所有的接口都额外包一层，来把这些Unity的接口注册到Lua中。 在Lua中调这些函数时，会传入Lua的状态机到这些Wrapper代码中，就可以通过这个状态机取得Lua这头的数据，比如能根据栈中参数的数量和类型去判断实际上是要调函数的哪个重载。 继续以Object.Instantiate为例，调用完该函数进行实例化以后，返回的对象在Unity这一层实际上就已经进了Scene场景，产生了引用。但不难想象到一种情况是，当我们自己的Lua逻辑代码New了一个对象，在C#端却没有任何地方会引用到这个对象，那么当当前函数直接Return后，这个对象就会在下次GC被回收掉，但你实际上很有可能还是要在Lua端访问它的。即这个对象的生命周期应该是Lua端在管，而C#端得先建立引用，否则C#端的GC将予以回收。 所以，一定要通知C#端去把它引用起来，即会做一个Push操作来把这个对象放在一个容器中。但这样为了不GC掉可能要用到的对象而进行的操作，反过来又有了另一种风险：C#端可能不知道什么时候该回收这部分内存了，从而导致Mono堆内存越来越高的泄露问题。这时就要靠Lua的GC来回收存储的Userdata，从而也回收掉C#端对应的引用。 GOT Online的Lua模式中，就可以通过Mono引用对象统计页面中进行分析。由于采集时主动定时调用了GC，所以在图表中仍然统计到的对象，就是Lua端没有解引用、短期内回收不掉的。数量不宜过高。 重点在于同时统计的Destroy对象数量。它统计了上述对象中其实在场景里已经被销毁的对象。但这些对象还在容器中、还占着堆内存，尤其数量越来越高时就可能存在Mono堆内存的泄露了，需要结合GOT Online Mono报告的情况进行综合分析。解决的方法就需要在Lua中找到相关对象的引用链，在Lua中及时释放。 所以，UWA的Lua专项报告提供的Mono对象引用柱状图中，黑色部分表示未被Destroyed的对象数目，由于受到Lua端GC的影响，导致会有一些Destroyed对象。这时候就要注意它是否是趋于稳定的，如果持续上涨就需要引起重视。 13.4 Lua配置表优化 Lua除了用于业务逻辑外，还有一种常用的用法，就是数据存储，也即配置文件。一般来说配置文件的初始来源是策划同学以Excel表格形式维护的，经由程序员提供的导出工具，把Excel的表格数据导出成为游戏能直接读取使用的Lua源码。 这些源码文件以Lua table的形式存储与Excel等价的数据，通常可以简单把这个配置表看成是一组2维数组，转换成配置就是一个key（Excel第一列）对应一组子数据（Excel中一行），那么整个配置数据就是一个大表包含着若干小表。 如果配置文件中的数据过大，或着是有冗余的无用数据，那么势必会导致输出的Lua文件过大，这将严重影响加载速度和增大内存占用量。冗余的原因在于： 大量的数据是重复的，或着是代表没有意义的空值（比如0，[]等）； 大量的中文字符串是需要游戏后期做本地化处理的； 很多复合型数据（子表，数组）内容是一样的。 基于此的优化方案为： 对于Excel中的一列，出现次数最多的值认定为默认值，然后把它从Lua表中剔除掉，然后利用metatable机制实现全局默认值存储； 对于中文字符串，替换为一个唯一的ID标识，写回到Lua表中，读取的时候加上相应的查找替换； 对于一些复杂的子表或着数组，做唯一化替换处理，替换后写回到原始数据中。 上面的操作其实做的都是唯一化处理，所以有个要求就是整个Lua表的数据必须是只读的，如果不满足以上条件，一切优化都是错误的。这样做势必会降低一些可读性，一些被多处引用的子表已经被替换成了一个变量，这些变量是以Local的形式存储在作用域的。由于Lua本身的一些限制，一个作用域内能够存放的最大Local变量的个数是200个（lparser.c #define MAXVARS 200），所以超过个数限制的多余部分表会被放入一个临时数组中，初始化的时候需要额外的查表，稍微多一些开销，但可以接受。 13.5 Native Heap优化 如2.1章节讨论的，当通过adb dumpsys meminfo发现Native Heap持续走高、且已排除第三方插件与Lua模块的明显问题后，Perfetto是定位原生堆内存分配与泄漏的核心工具，尤其适用于IL2CPP打包、引擎底层或自定义C++插件导致的Native内存异常。该工具基于Android系统底层Heapprofd，可采样malloc/free调用栈，以火焰图直观呈现内存分配来源，弥补Unity Memory Profiler无法追踪Untracked部分中Native Heap内存的短板。 前置条件 设备系统：Android 10及以上；非UserDebug设备需将游戏APK设置为Debuggable或Profileable。 工具准备：下载Perfetto工具包，配置Python环境；确保adb可正常连接设备并识别游戏进程。 采集步骤 启动游戏并进入内存异常场景，通过adb shell ps获取游戏包名/进程 ID。 执行Perfetto堆采样命令：python perfetto/tools/heap_profile -n游戏包名，启动Native内存追踪；首次运行会自动下载依赖工具，需保证网络通畅。 复现内存增长场景（如反复切换场景、加载资源、运行核心玩法），过程中可通过adb shell killall -USR1 heapprofd手动触发多轮内存快照，便于后续对比。 追踪结束后停止命令，导出raw-trace文件，在Perfetto官方在线分析平台（ui.perfetto.dev）打开文件。 结果解读与问题定位 核心视图：优先查看Unreleased Malloc Size（未释放内存大小），按内存占用排序调用栈，定位持续持有内存的分配路径；Total Malloc Size可用于排查频繁分配释放导致的内存抖动与碎片。 火焰图分析：色块宽度代表内存占用量，自上而下为调用链路。重点关注IL2CPP虚拟机、Unity引擎底层（如Rendering、Audio模块）、自定义C++插件对应的色块，若某一路径未释放内存持续增长，基本可判定为泄漏或不合理驻留。 符号化解析：若调用栈显示为乱码地址，需注意是否以IL2CPP Debuggable模式打包；或配置llvm-symbolizer并指定符号文件，将地址映射为具体函数名，精准定位代码行。 常见问题和优化方向 引擎触发的泄漏：若定位到和Unity原生模块有关的占用（如有时会观察到AssetBundle序列化信息），需检查相关缓存机制。 外部C++代码占用：Lua、HybridCLR等方案的部分版本，或有些项目自己写了一些C++代码，都可能会导致一定的Native Heap占用，可在堆栈中观察到相应的分配函数、对象构造节点，从而予以定位优化。 渲染线程下图形API相关占用：最常见的有Shader运行时生成的GPUProgram信息；还有的项目因引擎设置或渲染功能导致的一些Buffer占用等。 13.6 .so mmap优化 .so mmap是Android系统对共享库文件的内存映射，属于“Untracked Memory”的重要组成部分，在游戏项目中，主要包含libunity、libil2cpp、插件、第三方SDK的.so文件。在中低端机型上，代码量过高或插件过多导致的.so mmap占用过高同样会挤压游戏运行内存，提升OOM闪退风险。 连接真机，执行adb shell dumpsys meminfo游戏包名，查看.so mmap对应的PSS占用，若超过设备总内存的10%或超过200MB，需予以关注。 只观察到.so文件的总占用显然不足以具体定位问题，还是需要进一步拆分具体是哪些库文件占用过高。 首先对硬盘中的文件进行静态分析，可解压APK安装包，提取lib目录下所有.so文件，统计文件数量与体积。 紧接着结合运行时的数据进行映射关系核对，通过adb shell cat/proc/游戏进程ID/maps，查看内存映射表，对比lib目录下的.so文件，确认运行时的实际占用，并检查是否存在未使用的.so被强制加载、重复映射或加载后未卸载等情况。 优化上，可优先减少浪费：按发行目标机型保留主流架构（如国内市场仅保留arm64-v8a）；删除已弃用SDK的.so文件；剔除调试、测试用的工具类.so。 若libil2cpp.so体积过大，需检查编译打包时是否进行了合理的代码裁剪设置，避免最终包含大量未使用的引擎模块、第三方库代码。如，可尝试在Player Settings中开启Strip Engine Code，选择High级别的代码剥离；移除项目中未使用的Unity模块（如AR、VR、物理模块）。 若项目用到Lua等类似插件，也会出现liblua这类库文件占用过高的现象，同样也应关注代码量、代码裁剪是否合理。 针对以上代码导致的内存占用，还可检查是否存在以下不合理的情况，也都会导致.so文件内存占用过高： 泛型Template用的是否较多； 自动化生产的代码是否较多； Lua生产的Wrapper代码是否较多。 13.7 Wwise音频内存优化 Wwise作为移动端主流音频插件之一，采用事件驱动+SoundBank模块化架构，其内存管理独立于Unity Native内存与Mono堆内存，往往观察到其内存分配时主要体现在系统层Native Heap和Unknown部分内存的上升，一般可使用Wwise自带的编辑器工具分析、设置和优化内存。Wwise音频内存由元数据内存、媒体内存、引擎运行时内存、I/O与流送内存四部分构成。 元数据内存。加载Bank时分配，包含Event、Switch/State、RTPC、混音图、触发规则等结构，占用小但决定音频逻辑链路，卸载Bank可完整释放。 媒体内存。音频波形数据（WEM）的驻留区，是音频内存大头。和引擎自带音频累死地，需使用合理的加载方式。 引擎运行时内存。播放实例、路径计算、3D空间化、混音器缓冲区等动态开销，随播放启停自动分配与回收，受初始化设置中最大播放数、最大路径数约束。 I/O与流送内存。流送音乐的预读缓冲区与Bank异步IO缓存，大小可按平台配置，不使用时可收缩归还系统。 在UWA观察到的使用Wwise的项目中，常见的性能问题和一般优化方案如下： Bank粒度失控。大Bank全量加载，整个Bank中可能只有个别音频同时被用到，而大多数音频对象用不到却随该大Bank长期驻留；过于碎片化的小Bank则可能导致元数据与IO开销叠加。需尽量按场景/关卡/功能拆分Bank，并配套加载前预载、切换后卸载的生命周期控制；高频通用音效（UI、点击、脚步）则打入SharedBank，全局一次加载。 加载/卸载不匹配：只加载不卸载、重复加载、跨场景残留引用，引发媒体内存泄漏；需结合Wwise Profiler监控相关资源缓存加载策略是否合理。 对象设置不合理：长音乐错误地用全额加载、高频短音效反而用流送，造成内存暴涨或解码卡顿。长音频应采用流式以降低内存；反复播放的短音频则采用全额加载以避免反复触发耗时。 初始化参数不合理。默认内存池、播放数、路径数、IO缓冲区过大，中低端机内存浪费。应同样地针对不同内存分档设计不同的策略和设置。 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。
Unity移动端游戏性能优化简谱之 达到GPU压力与画面表现间的平衡
《Unity移动端游戏性能优化简谱》共分为五个部分，今天向大家介绍文章的第四部分：达到GPU压力与画面表现间的平衡，共7小节，包含了GPU Clocks与GPU Bound、GPU顶点阶段计算压力、GPU片元阶段计算压力、着色器复杂度、后处理、带宽等多项常见的游戏画面表现讲解。（全文长约4400字，预计阅读时间约15分钟） 前三部分可点击以下对应文章查看： 一、《Unity移动端游戏性能优化简谱之 前言》 二、《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（上）》 《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（下）》 三、《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（上）》 《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（下）》 1. 总览 近几年中，市场上部分画面质量很高的成功项目正逐渐使得行业变得更“卷”，甚至一定程度上跨越了游戏类型和赛道的限制，让更多开发者发现优质的画面和诱人的玩法一样，成为吸引和留住玩家不可或缺的重要手段。伴随着这种倾向，许多团队在开发过程中把大量重心倾斜到了美工制作和渲染效果的实现上。但随之而来就产生了很多问题：渲染压力越来越高，尤其是很多开发者对配套的渲染性能监控和维护不够了解，产生了很多低效的流程甚至后期弥补造成的大量成本。 而其中，GPU端更是已经超越了CPU端，成了许多项目性能压力的最主要来源之一。但GPU端往往更多地涉及到硬件层面的问题，市面上不同的厂商不同的芯片会反应出不同的现象，为开发者进行机型分档和标准制定造成了困扰。从另一个角度来说，针对GPU性能的检测往往不是Unity引擎相关的工具所能提供的，而硬件厂商提供的各类工具则繁多而难用，看得人眼花缭乱而抓不住重点，实质上为GPU性能问题的排查和优化带来了巨大的困难。那么，我们有没有一个相对具有共性和普遍性的思路或方法论，来判断一个项目是否有GPU压力——如果有，是什么类型的压力、又分别该如何处理呢？ 基于市面上这类工具的缺位和日益迫切的GPU性能优化的需求，UWA在2023年下半年开始推出了GOT Online全新的第五大模式：专注GPU性能的GPU模式，力求做到一个工具兼容市面上所有移动端硬件厂商的设备，并且给出对实际优化工作具有现实指导意义的参数。 但在讨论GPU模式如何处理分析GPU性能问题之前，我们不妨先回顾一下移动端游戏每一帧渲染的流程。CPU负责应用阶段，GPU则负责后续的几何阶段和光栅阶段。对于多数开启多线程渲染的项目而言，主线程中的渲染模块CPU端主函数主要处理DrawCall的准备、Culling计算等工作，而同时开辟了渲染线程，将同样非常耗性能的向GPU提交纹理、向GPU提交DrawCall、等待GPU完成工作的工作全部交由渲染线程进行。一般对于渲染压力较大的场景，当你注意观察此时的Timeline时，可能会发现如下图所示的现象：主线程必须等待渲染线程完成上一帧的工作才能开始这一帧渲染模块的工作，而此时渲染模块中主要在进行DrawCall的提交和等待GPU完成上一帧的工作。 此处，主线程中的Gfx.WaitForPresentOnGfxThread即为等待渲染线程完成工作的标记，而渲染线程中的Gfx.PresentFrame则代表了等待GPU完成工作的标记。它们本身不代表实际的耗时开销，而仅仅是多线程渲染和CPU-GPU同步等待机制下的性能标记。 图中的性能情况即为非常典型的GPU Bound。此时CPU压力不大，各个模块的耗时总和小于目标30帧率所需的33ms，但GPU压力却使得游戏此时仅能跑十几帧，而此时对CPU作常规的优化将并无任何效果。比如你这一帧中CPU耗时为30ms，总计实际要等待GPU 80ms，你此时的同步等待标记显示为50ms，而你现在将CPU耗时大幅优化至20ms，你的帧率不会有所上升，因为此时同步等待标记要花80-20=60ms了。当然，反过来说，当一个项目的CPU耗时有80ms，此时完全检测不到等待标记耗时，但并不是就说一定就没有GPU压力了——而可能只是被CPU高压掩盖了。 就是这样一个简单的木桶效应，而它开始在近几年中大量地出现在UWA遇到的各种移动端项目中，这也是为什么上文有“GPU端更是已经超越了CPU端，成了许多项目性能压力的最主要来源”这一说法。 那么，到这里为止，我们似乎已经足以判断项目在当前测试设备和分档上的测试流程中是否存在GPU压力。但Gfx.WaitForPresentOnGfxThread本身到底只是一个没有实际耗时意义的同步等待标记，也会如上文提到的一样受CPU压力等影响，并不总是能够准确地标定究竟哪些场景哪些画面的GPU压力更大，更不用说它无法指导我们作具体的GPU性能优化和分级。 那有些性能分析工具出现的GPU Time呢？UWA实验发现，当使用完全一样的包体和测试流程，在不同分档的设备上进行测试时，经常会出现GPU芯片更好的硬件上测出来的GPU Time反而比相对差的硬件的GPU Time更高的现象；又或是在完全就没有压力的空场景跑30帧时，GPU Time也会高达33ms的情况。这可能是该参数的算法在不同硬件SOC上根本不一样，也可能因为在完全无压力或者CPU高压的情况下，GPU即便高频率运作也无法使得帧率更高，所以GPU自动出现了降频节省功耗的行为。总而言之，各种实验和实践说明了GPU Time参数也不可靠。 因此，我们要跳出引擎层概念的束缚，溯本求源地来找寻一个切实反应GPU渲染计算压力的参数。它就是GOT Online GPU模式重点围绕的核心，也是本章最主要展开解释、分析和进行相应优化建议的主题，GPU Clocks。 2. GPU Clocks与GPU Bound GPU模式的简报页面开门见山地展示了GPU Clocks参数曲线。GPU Clocks是UWA通过硬件底层接口统计到的数值，它反映了每帧耗费在GPU计算上的时钟周期数，单位为万Cycles/帧。若将该值乘以项目的目标帧率，则得到项目运行过程中每单位时间（每秒）所需要的GPU算力。我们称之为实时需求算力。 而每款SOC出厂时，硬件厂商会给其标定一个额定最大频率，也就是其释放全部性能时，理论上每秒钟能进行多少时钟周期的运算，单位是MHz，也即100万Cycles/秒。我们称之为理论最大算力。 显然地，若实时需求算力大于当前运行机型GPU芯片的理论最大算力，则即代表硬件算力不足以满足项目渲染所需要耗费的实时算力。反映在实际的性能表现上，变成需要更长时间来完成这些工作，也就是帧率降低。这实际上可以体现为一个公式，即：实时GPU Clocks*实时帧率=实时频率。 而在实际运行过程中，GPU高压还可能伴随严重的发热，从而触发硬件保护机制，使得频率下降。而此时若渲染绘制的工作并没有变少，显然帧率也会进一步降低。 上述是比较典型的压力很大的情况。而UWA认为，即使当前GPU压力不足以使得降帧，但仍迫使GPU必须以高频运作才能完成工作时，仍是一种压力比较大的状态，耗电和发热问题可能仍然显著。 因此，满足以上两种情况的帧会被UWA判定为GPU Bound并且标记为粉红色。判断的公式为不等式：实时GPU Clocks目标帧率>=GPU额定最大频率80%。我们可以看到，在图中这一测试流程里，GPU Bound帧数占比达到了惊人的93.09%，显然地只要黄色GPU Clocks曲线越高，蓝色的帧率曲线就越低，GPU压力毫无疑问地是该Demo场景最为致命的瓶颈。 经UWA一年以来在大量实验和实践中的验证来看，通过该方法判断的GPU Bound要远比其他任何方式可靠的多。并且，将公式作简单变种即可指导项目的分级策略和优化目标。 比如：项目根据发行市场状况和游戏类型，选择了一款市场占有率高、硬件性能稳定合适的Mali-G76设备作为自己中端分档的基准测试设备，想要在该分当上稳定运行30帧。发现其GPU额定最大频率为720MHz。那么，后续的优化工作中，就需要把所有的场景的GPU Clocks总是控制在720MHz/30=2400万Cycles/帧以内，若想要使得发热也得到良好控制，则还要乘以80%。那么在后续的迭代中，只要新的场景，或局部的后处理、特效、精致模型等因素，使得GPU Clocks超过了这一标准值，就说明相关的渲染策略或渲染资源需要在当前分档上进一步优化。 那么，我们在定位到哪里有GPU Bound后，就要进入到具体定位和排查问题的阶段了。一般来说，GPU压力主要来自两大方向，即顶点阶段的计算压力和片元阶段的计算压力。 3. GPU顶点阶段计算压力 我们首先讨论顶点阶段产生的压力。 本质上，GPU在某个阶段的性能压力大不大，就等同于它在这个阶段的计算量大不大的问题，也即取决于两个乘数的大小：总共要计算多少个单位的东西，和计算每个单位的东西平均要耗费多长时间——计算次数和计算复杂度的区别。 在顶点阶段，渲染面片数就是计算量层面的概念，而计算复杂度则是顶点着色器的复杂度。一般而言主动修改顶点着色器的开发者在少数，而排查这一点的方式和排查片元着色器的方式大同小异，所以我们放到之后的章节一起说。 此处的渲染面片数，指的是实际参与运算的数量，而与“同时出现在视域体内的模型的三角面数之和”这一概念是有差异的。具体来说，除了受到模型面数的影响外，它和裁剪结果和渲染次数都有关系。当渲染面片数过高时，会体现在参数GPU Primitive，即GPU绘制的图元数量中。 其中，总图元数量Input Primitive代表CPU传给GPU进行处理的图元总数，剔除图元数Culled Primitive表示在GPU阶段剔除的图元数量（面剔除、视锥剔除、微图元剔除等），可见图元数Visible Primitives表示最终实际进入下一处理阶段的图元数（即总图元数减去剔除图元数）。不难看出，为了让GPU要进行的处理和运算越少，传入GPU进行处理的图元数量越低越好，而其中可见图元数的占比越高越好。 一般认为，较为合理的情况是可见图元数和被裁剪图元数的比值在五五开。这是因为在场景中绝大多数模型制作都是均匀的情况下，朝向相机的可见三角形数量和反之的三角形数量相近。当然，如果能够把总是不朝向相机、玩家在任何视角都看不到的三角形从模型层面就予以剔除，当然是最好的，甚至可以是可见图元数的比例超过50%、越高越好，说明利用效率高。当然，这也是很多减模软件都在争取的优化方向。 但在实际具有顶点压力项目中，往往是被裁剪图元数占比更高，如图例，很多项目甚至达到70-80%数量级，说明可能存在大量浪费。那么，被裁剪图元数为什么会这么高，就需要我们对这个参数进行进一步拆解。它由三个部分组成：XY平面视域体剔除和背面剔除XYPlane&FacingCullingPrimitives、Z平面视域体剔除ZPlaneCullingPrimitives、微小面剔除CoverageCullingPrimitives。 其中，就像我们上面讨论的，背面剔除占比高是比较正常的但也不应该总是超过总图元数的一半，说明不朝向相机的三角形占比更高了，这些模型的制作和场景设计是不科学的。 而另外两种在理想情况下应该非常低。这里需要指出的是GPU阶段的视域体剔除和CPU阶段的视域体剔除概念是不一样的，CPU阶段是以渲染物体为单位作剔除，只有当一个物体完全在视域体外时才不会提交给GPU。但如果是一个巨大模型，比如建筑或地表，即便只是冰山一脚进入了视域体，也会全部提交给GPU。而GPU阶段是以像素为单位进行的剔除，相关计算全都算完以后才把这个大模型的绝大部分的三角形剔除，就造成了大量的计算浪费。所以才说尽量把复杂、巨大的模型尽量拆小，走SRP Batcher或运行时静态合批，这样它们中的个别进入视域体时就不会全部被提交给GPU了。 而微小面剔除相对更好理解，GPU会把一些屏占比极低、密度过高、过于狭长的三角形剔除。这一项如果较高的话显然说明我们某些模型明明屏占比有限，但网格过于精细。这一项其实可以结合我们早在内存章节提到的网格渲染密度这一概念筛选优化存在此问题的网格模型。 以上数据仍主要是供我们参考哪些场景存在图元绘制的浪费、优化空间较大。但我们在进行实际优化工作时，还是需要从相应分档的硬件算力出发，来把总图元数优化到相应数量级。为了达到这个目标，我们需要更具体的方法论。 为此，我们需要回到Unity引擎中，针对性截帧排查通过Triangle曲线或图元数曲线发现的顶点压力大的场景。 一种常见的排查方法是，我们需要使用Unity自带的Frame Debugger工具对该场景进行截帧。游戏被暂停后，就可以通过Stats面板看到此时的Triangle数量。此时，我们使用上下键在Frame Debugger中截取到的DrawCall中进行切换，就可以看到画面和Stats中的Triangle数量随之而变化。此时统计的是到选中DrawCall为止的所有DrawCall绘制的Triangle数量之和，从而轻易能够定位到造成Triangle数量过高的DrawCall有哪些了。 当然，你还可以直接在层级树关闭或打开一类物体的渲染来看它们对Triangle数量的贡献。比如场景中所有人物的对象都是放在某个节点下的，我只要手动显隐这个节点，就可以通过Triangle数量的变化知道我的项目中人物模型的制作和绘制策略是否需要调整。 通过上述一系列手段定位到问题后，应根据UWA提供的不同机型分档上的推荐面片数作分级机制。比如，当前项目在极低端机型上被建议控制到15w面，其中根据功能和表现需要，认为其中人物的表现效果更加重要，场景地形等可以牺牲的比较多。因此，开发团队准备使用5w面绘制场景地形、剩下10w面用来绘制人物。然后项目是一个多人回合制对战游戏，最高有5v5的同屏需求，但是低端机不需要实时阴影，不会把人物的三角形画两遍。那么每个角色最高允许的面数就是10w/10=1w面了。制定好标准后，就要对以下导致三角形过高的问题进行具体的优化调整： 1.Level of Details。这并不是一个普适性很高的做法，但对于视野距离较远的复杂3D场景或大世界类型的游戏而言，往往比其他游戏的顶点压力更大，也适合通过把LOD做得更狠来控制渲染面片数。 2.模型过于复杂。美术资源本身过于复杂是最常见也是最本源的渲染面片数过高的原因。可能是因为项目初期没有设定相关规范、或者出于成本和工期暂时只有一套美术资源。相关的调整可能花时间花成本，但也往往是必不可少的环节，同样可以参考网格渲染密度这一功能筛选需要修改的资源，达到事半功倍的效果。 3.进行CPU阶段的裁剪剔除。就像早一些时候提到的，渲染面片数还和裁剪结果和渲染次数有紧密联系。可以在CPU端就通过一些规则剔除部分实际上看不到的物体、减少提交给GPU的渲染面片数。当然，一些相关的方案也有风险，比如前文讨论过的Occlusion Culling遮挡剔除，只适用于一些在视域体中但是被遮挡不可见的物体数量极多的情况，其他情况下反而对性能有反效果。当然，如果结合项目的场景设计实际情况用更简单的裁剪规则去掉一些确定用不到的东西，性价比还是非常高的。 4.控制渲染次数。一些常见的渲染策略如多光源、阴影、Shader多Pass、Outline、反射、SSAO等，最常见和最本质的原因是Shader的多Pass，都会使得受影响的渲染物体面片数翻倍。这些都应该通过分级策略，在中低端机型上有选择性地开启或关闭。 4. GPU片元阶段计算压力 4.1 渲染分辨率 至于片元阶段产生的GPU压力，我们同样先从计算量出发进行考虑。片元阶段的计算量体现在总共要绘制多少个像素。在GPU模式报告中。GPU Shaded中的Fragment Shaded参数直接反映了这一因素。它代表了GPU在每帧中执行Fragment Shader的总执行次数，基本就等同于GPU每帧中要绘制多少像素了。 而其中，渲染分辨率就决定了绘制一遍移动端设备的全屏要绘制多少个像素。 分级地控制渲染分辨率，也是当前移动端游戏中最常见和实用的分级策略之一。根据UWA的渲染分析实验结果来看，市面上很多运营时间颇久、用户量相当大的游戏，普遍会把低端分档下的渲染分辨率设置在0.7-0.8倍左右，中端机0.8-0.9倍，而只在高端分档下才会使用全屏分辨率。甚至一些对画面表现效果要求有限，但对战斗流畅度要求极高的竞技类游戏中，会使用0.5倍的渲染分辨率。 举例而言，对于一个1080*1920分辨率的手机硬件，默认情况下全屏渲染分辨率就总共要绘制200w左右的像素。但当长宽都变为原来的0.7倍时，实际要绘制的像素就会变成0.7*0.7=0.49倍，即仅100w左右，即一般占GPU压力大头的片段阶段的压力直接减半，是非常可观的优化方法，而一般情况下3D场景在移动端上完全可以接受轻微的模糊而不会对游戏体验产生显著影响。 不过在实践中常会遇到的问题是，玩家的硬件千奇百怪，尤其是当前市面上有很多硬件算力有限，但真机分辨率非常高的长屏、宽屏手机。比如一台1080*2360的手机，它画一遍屏幕就要约250w像素，比同分档的1080*1920的手机就要多画25%的像素、承受多25%的GPU片元计算压力。所以制定渲染分辨率更为合理的方法是按像素总数为标准，将当前硬件的真机分辨率算出像素总数后，乘以一定比例来约束在相应分档能承受的像素总数内。将这里“能承受的像素总数”作为实际的分档标准，而不是一个简单的、不够科学的零点几倍的比例。 降低渲染分辨率的操作本身也非常简单，考虑到直接改Render Scale的方法会同时使得UI变模糊，可能不符合策划和美术同学的基本需求，在URP项目中，可以利用Camera Stack，将3D场景渲染到一张Render Texture资源里面，再将该Render Texture作为RawImage的Texture渲染到UI相机的UI里面。此时只需要调整该Render Texture的分辨率，就可以做到只降低3D场景造成的GPU压力，而不会使UI也跟着一起变模糊了。当然，也有项目组修改管线代码来实现UI相机和场景相机使用不同分辨率，总之达到类似效果就可以了。 4.2 Overdraw 基于渲染分辨率敲定了绘制一遍屏幕要画多少个像素，那一个画面中总共平均要画多少次全屏、即Overdraw的概念同样非常重要。在GPU模式报告中，将上文提到代表一帧中总像素数的Fragment Shaded参数除以1080*1920的标准1080p分辨率，换算得到测试过程中“总体Overdraw”这一模拟计算值的走势。 首先，我们需要了解Overdraw的常见来源。在Unity中，渲染队列小于2500的对象被认为是不透明物体，大于2500的对象被认为是半透明物体。不透明的物体是从前往后绘制的，而半透明的物体是从后往前绘制的。 对于不透明物体，在场景中渲染顺序控制合理的情况下，由于有深度测试的存在，后面绘制的物体无法通过深度测试，就不会进行渲染，因而很大程度上减少了Overdraw。理想状况下不透明物体的Overdraw应控制在1层。 然而，造成Overdraw的主要元凶往往是半透明物体，对于大多数项目也即粒子系统和UI。 以上这些原因导致的Overdraw，都可以使用GPU模式中更进一步的Overdraw快照功能进行分析。在测试中点击Dump后，在生成的报告中就会有相应帧的快照。其中包含各个相机的Overdraw数值、截帧画面图、以相机为单位的Overdraw热力图。在Overdraw热力图中，一个像素点被绘制越多次，就会越明亮且鲜艳。因此从图中就可以判断是哪些渲染物体导致的Overdraw过高、是否存在一些造成了Overdraw但是对表现贡献极低的浪费渲染资源等等。 但事实上，除了这些看得见摸得着的渲染物体，实际上还有一部分Fragment Shaded来自于相机渲染序列之外的渲染行为，如后处理采样、Copy操作、Blit操作等，它们也做出类似把全屏复制出来经过某些计算再画一遍的做法，从而造成相应的Overdraw。这体现在，在部分项目的测试中，发现某一帧的模拟计算值总体Overdraw数值要比同样帧中Overdraw快照的各个相机Overdraw之和要高得多，这就很大概率是上述的渲染行为造成的Overdraw非常高。 接下来，我们具体讨论一些常见的导致Overdraw过高的问题和优化手段。 4.2.1 渲染顺序不合理的情况 当场景中存在先画离相机较远的不透明物体，再画离相机较近的物体，而且两者有所重合时，较远物体被较近物体所遮挡部分的像素就有可能被绘制两次，从而造成Overdraw。 这种情况常发生在地形上。本来当不透明物体的Render Queue一致时，引擎会自动判断并优先绘制离相机更近的物体。但对于地形而言往往有的部分比其他物体离相机更近，有的却更远，从而被优先绘制。 所以，需要通过对Render Queue等设置，使得离相机越近的物体（如任务、物体等）越先绘制，而较远的如地形等最后绘制。则在移动平台上，通过Early-Z机制，硬件会在片段着色器之前就进行深度测试，离得较远的物体被遮挡的像素深度检测不通过，从而节省不必要的片元计算。 不过对于渲染顺序的调试主要是在不支持HSR的低端机型上有比较好的效果，而随着硬件的迭代，市面上很多已经支持HSR的机型上，影响就没那么大了。 4.2.2 粒子系统 灵活使用UWA的性能分析工具，可以有效定位对GPU压力贡献大的粒子系统。 一种做法是，建立一个专门的空的测试场景，在其中顺次播放我们项目中要用到的粒子系统，然后使用UWA SDK进行打包测试提交GOT Online GPU模式报告，就可以查看上文提到的一系列GPU参数的变化情况，并结合测试截图找到播放时GPU开销较高的粒子系统了。 还有一种做法是直接使用UWA本地资源检测报告，可以直接看到预测会造成Overdraw较高的粒子列表作为参考。 在筛选出需要优化的粒子系统后，对于低端设备尽可能降低它们的复杂程度和屏幕覆盖面积，从而降低其渲染方面的开销，提升低端设备的运行流畅性。具体做法如下： （1）在中低端机型上只保留“重要的”的粒子系统，比如对于一个火焰燃烧的特效，只保留火焰本身，而关闭掉周边的烟尘效果。这一部分的分级设计上文进行过讨论，此处不再赘述。 （2）在中低端机型上对粒子系统的Max Particles最大粒子数量进行限制。 修改前： 限制Max Particles为10后： 还有一种项目中常见的情况是，美术同学尝试在同一个位置叠加多个粒子同时播放来使效果更加显著明亮，但造成了大量Overdraw。实际上可以考虑只保留2-3个，但调整材质中颜色、亮度相关参数来达到类似的效果。 （3） 尽可能降低粒子特效在屏幕中的覆盖面积，覆盖面积越大，越容易产生重叠遮盖，从而造成更高的Overdraw。一个在很多项目中常见的情况是，一个粒子发射中间实心、向四周渐变透明效果的面片，又或是发射一个长条型效果的面片，但实际上使用的是一张更大面积的方形贴图，其绝大部分面积都是纯透明的。在最终发射的面片中，这些像素部分对表现毫无贡献，但是GPU仍进行全额计算。应予以裁剪。 （4）检查粒子的生命周期，及时Deactivate。有的粒子存在渐变、位移的效果，其生命周期在播放了一段时间后就不再影像表现，但相关的计算仍在进行。因此应控制这些粒子的生命周期，最好是及时Deactivate。 （5）考虑使用动画帧烘焙策略。对于项目中很多并不需要从多个视角观察、随机性也极为有限的粒子特效而言，使用动画帧烘焙是能够极大幅度控制特效性能开销的手段。一个原本复杂、叠了十几层的特效Prefab，在做成动画帧后，就只剩下了薄薄一层Overdraw，使用的Shader也可以极为简单，就几乎对GPU不构成什么压力了。事实证明，在一些对画质要求颇高、开放世界的3D项目中，也几乎可以做到所有特效都由动画帧制作，在移动端是很有效的优化手段。 （6）Offscreen离屏渲染策略。粒子系统绘制的像素多，除了从Overdraw本身出发优化外，也有回过头从渲染分辨率予以优化的思路。事实上，粒子恰好是一种渲染分辨率降低后表现很难明显变糊的美术效果。因此，产生了一种单独调整粒子渲染分辨率的做法，不过也会造成一些拷贝操作的额外开销，因此在粒子系统使用规模较大的项目中可能性价比较高。可以参考：URP下的OffScreen Particle Render。 4.2.3 UI UI作为另一种游戏项目中大量使用的半透明物体，同样需要关注： 当某个全屏UI打开时，可以将被背景遮挡住的其他UI进行关闭。 对于Alpha为0的UI，可以将其Canvas Renderer组件上的CullTransparent Mesh进行勾选，这样既能保证UI事件的响应，又不需要对其进行渲染。 尽可能减少Mask组件的使用，不仅提高绘制的开销，同时会造成DrawCall上升。在Overdraw较高的情况下，可以考虑使用RectMask2D代替。 在URP下需要额外关心是否有没必要的Copy Color或者Copy Depth存在。尤其是在UI和战斗场景中的相机使用同一个RendererPipelineAsset的情况下，容易出现不必要的渲染耗时和带宽浪费，这样会对GPU造成不必要的开销。通常建议UI相机和场景相机使用不同的RendererData。 5. 着色器复杂度 在聊完计算量的问题后，我们再讨论一下计算复杂度的问题。诚然，无论是对于顶点还是像素，计算的复杂度其实包含很多因素，但一般开放给开发者修改并相对能够定量分析的就是相应阶段的着色器的复杂度，也体现了着色器计算对GPU Clocks的贡献。在GPU模式报告中，Shader Cycles参数体现了GPU花费在Shader处理上的时钟周期有多少，作为排查相关压力尤其大的场景的参考。但事实上，该参数还会受到绘制像素数量的影响，参考性有限。在实际优化过程中，开发者也倾向于希望直接观察和分析单个Shader的复杂度情况。 使用Mali Offline Compiler工具可以获得Shader的指令数和时钟周期数。一般建议优先关注其中屏占比较高、使用率较高的Shader，从而定位需要着重优化的Shader资源。 很多情况下会发现，某个游戏角色身上用到的PBR Shader复杂度非常高，但实际上在具体的GPU耗时DrawCall分析中却发现它对性能的贡献很小，其实就是由于人物往往屏占比很低、参与绘制的像素很少，所以即便计算复杂压力也不大。而往往是一些如地表、建筑这种屏占比比较高的不透明物体、或容易造成Overdraw的半透明物体，它们的Shader复杂度仅次于人物的Shader、绘制的像素数却远远超过人物，才是真正的瓶颈。 除了控制绘制的像素数外，也要对Shader进行合理的分级和精简。 举例来说，在上文讨论的粒子系统使用的Shader中，常发现一个现象：即项目组为了开发便捷，提供了一个通用性非常强的Shader给美术同学使用。在实际开发过程中，绝大多数的特效只用了个别的Feature实现，而这个通用Shader中的法线纹理采样、光照计算等部分，即便没有给纹理，但GPU仍然会进行相关计算，造成严重的浪费。对于这种情况，应该通过关键字开关或直接拆分Shader的方式予以优化。 当然，有些使得表现效果更好但贡献其实相对有限的Shader计算，应该在低端机上予以直接关闭。制作不同的Shader或通过Shader Lod的方法就可以达到目的。 6. 后处理 后处理也是最为常见的增加GPU压力的原因之一。但它本质上也还是一批绘制的像素又多、使用的Shader还很复杂的渲染对象。这里选取几个较为常见的后处理和相应的优化、分级问题予以讨论： Tonemapping：该后处理会大幅增加Uberpost的复杂度，一般不建议在LDR模式下使用ACES模式。而HDR模式下它会在ColorGradingLut中就烘焙到内存的Lut途中，从而避免后续Uberpost中的高开销计算。所以，如果是在LDR模式下，还尽量考虑用一些无开销低开销的后处理来达到类似的效果。 Bloom：Bloom几乎是最受开发者喜爱、最为常见的后处理效果。它不但导致Uberpost Shader复杂度高，也会根据采样次数产生很多DrawCall。它常见的一个问题是，Bloom默认是从1/2渲染分辨率开始进行下采样的。对此，可以考虑在中低端机型上从1/4分辨率开始进行下采样，将大幅度降低Bloom造成的带宽开销，并减少下采样次数来减少DrawCall数。 Depth Of Field：景深在移动端上开销较大，一般不建议长时间使用，尤其Bokeh算法的开销更高，推荐考虑使用UWA学堂中优化过的版本；高斯算法的开销小，但效果要差一些。 SMAA：是一种常见的抗锯齿算法，会使用三次Pass完成边缘检测、权重计算与混合，开销相对较高，一般不建议在中低端机上使用。 总的来说，对于后处理效果，应尽量减少使用开销较高的效果，有必要的话尝试用低耗无耗的方式进行近似替代。不要采用Global Volume一直开启，而是使用Local Volume或者在不同逻辑下开启。如果一定要开启后处理，则应做取舍，如哪种后处理优先级更高、或者通过节省其他模块的开销来换取后处理开销。对于某一些后处理效果使用额外的、特定的优化手段，如上文提到的对Bloom采样分率和采样次数的控制。 由于各种后处理效果的性能开销和实际使用场景并不相同，在实际项目中遇到的问题也往往各不相同。相比Unity原生提供的后处理方案，很多开发者摸索出的一些开源方案或优化思路可能更适合移动端游戏项目。UWA社区中也有大量后处理相关的文章和资源，比如在《Unity移动游戏性能优化案例分析-2021》中就有过相关的讨论，以供参考。 7. 带宽 前文主要围绕GPU Clocks和影响它的一系列其他参数和因素展开讨论。但GOT Online GPU模式中，还采集了一个重要参数GPU带宽，值得予以关注。 我们在讨论GPU Clocks时不难看出，该参数主要影响帧率。而该参数较高迫使GPU以高频运作时，显然也会对能耗发热造成影响。但GPU带宽目前看来是一个主要影响能耗发热、但和GPU Clocks处于不同维度的参数。 而事实上，在很多项目中，发热的重要原因就来自于GPU压力，证据就在于，在UWA检测到的硬件设备温度中，同时采集了移动硬件CPU、GPU、电池的温度情况。由于移动端设备规模较小，SOC中各个芯片物理距离非常近、且散热不佳，就会导致其中一个芯片高压发热时，带动另一个芯片一起升温，不但可能对玩家手感造成影响，还能触发硬件的发热降频，从而影响游戏的流畅度。 因此在如图曲线中发现蓝色的GPU温度曲线始终相对较高、对温度变化趋势呈主导作用时，就可以认为发热的主因是GPU压力。反之则为CPU压力。 按照以往的游戏项目发热的经验来看，CPU主线程各模块高压，或CPU子线程高压、网络传输量大、I/O压力大等因素，最终都呈现在CPU芯片发热明显。但我们却发现很多项目中是GPU芯片的温度更高。 在这种情况下，除了通过上文提到的一系列手段对GPU Clocks进行控制来减少发热外，优化GPU带宽也是极为有效的手段和方向。 在以往，我们从经验出发结合跟芯片厂家的专业硬件工程师沟通后，定性地得出GPU带宽直接影响功耗、并同时对发热产生贡献这一结论。不过，实际上也可以将带宽和功耗发热问题建立定量的关系。比如Mali的官方文档指出，约1GB/s的带宽会造成80-100mW的功率开销。而这是怎样的水平呢？ 手机在自然状况或运行一些低压的应用时，整体功率开销一般在1000mW-2000mW的水平，也是一般认为硬件处于能耗较低的健康状态的数值。但是游戏作为一种高压应用，精致的渲染复合复杂的逻辑运算，需要大幅度激发CPU和GPU的算力，按照UWA经验，许多项目的功率达到4000mW-5000mW是非常常见的现象，甚至于部分项目达到更高。这种情况下耗电和发热几乎肯定会较为显著了。 我们按高一些算，部分项目中渲染开销大、在高端机上追求高帧率，从而达到了10GB/s的带宽，就造成1000mW的功率开销，就要占总功率的20%。看似这个比例还不算很夸张，但功率数值包含游戏进程以外的操作系统本体、其他进程的开销，包括游戏进程本身造成的CPU、GPU和其他硬件的运作开销。而若仅是GPU带宽传输这一项就占到了20%，哪怕只有10%之多，也已经对能耗发热的贡献非常之大了。 根据UWA对市面上大量游戏的测试，低端设备上或较为休闲简单的游戏主体带宽一般控制在2-3G/s；而在相对高端机型的高帧率高画质的测试也一般最高不超过5-6G/s。当然，带宽数值受到不同硬件厂商和具体机型的影响较大，无法给出特别明确的定值推荐，但当游戏明显存在耗电发热问题时，排查带宽是一种可行的思路。 在分析带宽时，适当地拆解同样是帮助我们理清优化思路的手段。其中读带宽一般占带宽开销的大头，而其中读纹理带宽又一般要远比读顶点带宽的占比要高。 读顶点带宽主要受传输的网格信息影响，所以其实前文针对顶点计算压力的一系列优化手段也能降低顶点带宽。很多时候不需要针对性作优化。 读纹理带宽一般主要受纹理采样和RenderTexture使用情况这两大因素影响。一些RenderTexture的相关操作还会导致大量写带宽。 为此，常见的优化手段有： （1）采样纹理数量 当GPU需要同时采样的纹理数量过多，显然会对纹理带宽有显著影响。但参与渲染采样的纹理数量往往直接和表现需求挂钩，没有特别好的大幅度降低该数量的方法，这里只提出该影响因素，开发者也可以排查是否存在一些浪费的情况。 （2）压缩格式 在内存的章节中已经或多或少地讨论过，使用合理的压缩格式，本质能够降低传输单个像素时要传输的信息量，从而有效降低读纹理带宽。 （3）纹理Mipmap 内存章节中已经详细阐述了GPU采样开启Mipmap纹理的原理，以及建议哪些纹理开启Mipmap能够降低带宽。此处不再赘述。 （4）合理的纹理采样方式 除了合理使用Mipmap非0层采样外，还应关注项目中各向异性采样和三线性插值采样。这些采样方式为了能使纹理采样的表现更加正确美观，会增加绘制一个像素时采样点的数量，而采样点增多的时候，Cache Miss的概率就会变大，造成带宽上升。应在Project Settings的Quality设置中避免开启Anisotropic Textures的Forced On，而使用Per Texture单独地对个别确实要使用到的纹理进行设置，并尽量使用较低的等级，或索性改为Disabled。三线性采样采8个顶点，相对于双线性采样是翻倍的。 UWA的本地资源检测服务能够统计并列出开启各向异性或三线性采样的纹理资源。 （5）修改渲染分辨率 降低渲染分辨率，减少参与纹理采样的像素，能够更加简单粗暴地降低带宽。 （6）减少Copy操作、减少后处理采样 Render Texture的Copy操作的带宽开销一般也比较高，常见的应排查URP项目中的Copy Color、Copy Depth是否有必要。此外，AA、Bloom、景深、Blur等一些常见的后处理效果，会进行多次、高分辨率的采样，从而造成较高的带宽开销，应予以分级地取舍。 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。
Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（上）
《Unity移动端游戏性能优化简谱》从Unity移动端游戏优化的一些基础讨论出发，例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题，并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题，希望能提供给Unity开发者更多高效的研发方法和实战经验。 今天向大家介绍文章第三部分：以引擎模块为划分的CPU耗时调优，共10小节，包含了渲染模块、UI模块、物理模块、动画模块、粒子系统、加载模块、逻辑代码、Lua等多个模块等常见的游戏CPU耗时调优讲解。 本章包含1-5小节，6-10小节请点击《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（下）》。 （全文长约14115字，预计阅读时间约30分钟） 文章第一部分《Unity移动端游戏性能优化简谱之 前言》、第二部分《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（上）》、《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（下）》可戳此回顾，完整内容可前往UWA学堂查看。 1. 总览 1.1 模块划分 UWA将CPU中工作内容明确、耗时占比一般较高的函数整理划分为：渲染、UI、物理、动画、粒子、加载、逻辑等模块。但这并不意味着模块之间的工作互相独立毫无关联。举例而言，渲染模块的性能压力势必受到复杂的UI和粒子影响，而加载模块的很多操作实际上都是在逻辑中调用并完成的。 划分模块有利于我们确认问题、找到重点。与此同时，也要建立起模块之间的关联，有助于更高效地解决问题。 1.2 耗时瓶颈 当一个项目由于CPU端性能瓶颈而产生帧率偏低、卡顿明显的现象时，如何提炼出哪个模块的哪个问题是造成性能瓶颈的主要问题就成了关键。尽管我们已经对引擎中主要模块做了整理，各个模块间会出现的问题还是会千奇百怪不可一以概之，而且它们对CPU性能压力的贡献也不尽相同。那么我们就需要对什么样的耗时可以认为是潜在的性能瓶颈有准确的认知。 在移动端项目中，我们CPU端性能优化的目标是能够在中低端机型上大部分时间跑满30帧的流畅游戏过程。为了达成这一目标，简单做一下除法就得到我们的CPU耗时均值应控制在33ms以下。当然，这并不意味着CPU均值已经在33ms以下的项目就已经把CPU耗时控制的很好了。游戏运行过程中性能压力点是不同的，可能一系列UI界面中压力很小、但反过来游戏中最重要的战斗场景中帧率很低、又或者是存在大量几百毫秒甚至几秒的卡顿，而最终平均下来仍然低于33ms。 为此，UWA认为，在一次测试中，当33ms及以上耗时的帧数占总帧数的10%以下时，可以认为项目CPU性能整体控制在正常范围内。而这个占比越高，说明当前项目的CPU性能瓶颈越严重。 以上的讨论内容主要是围绕着我们对CPU性能的宏观的优化目标，和内存一样，我们仍要结合具体模块的具体数据来排查和解决项目中实际存在的问题。 2. 渲染模块 围绕渲染模块相关优化更全面的内容可以参考《Unity性能优化系列—渲染模块》。 2.1 多线程渲染 一般情况下，在单线程渲染的流程中，在游戏每一帧运行过程中，主线程（CPU1）先执行Update，在这里做大量的逻辑更新，例如游戏AI、碰撞检测和动画更新等；然后执行Render，在这里做渲染相关的指令调用。在渲染时，主线程需要调用图形API更新渲染状态，例如设置Shader、纹理、矩阵和Alpha融合等，然后再执行DrawCall，所有的这些图形API调用都是与驱动层交互的，而驱动层维护着所有的渲染状态，这些API的调用有可能会触发驱动层的渲染状态地改变，从而发生卡顿。由于驱动层的状态对于上层调用是透明的，因此卡顿是否会发生以及卡顿发生的时间长短对于API的调用者（CPU1）来说都是未知的。而此时其它CPU有可能处于空闲等待的状态，从而造成浪费。因此可以将渲染部分抽离出来，放到其它的CPU中，形成单独的渲染线程，与逻辑线程同时进行，以减少主线程卡顿。 其大致的实现流程是，在主线程中调用的图形API被封装成命令，提交到渲染队列，这样就可以节省在主线程中调用图形API的开销，从而提高帧率；渲染线程从渲染队列获取渲染指令并执行调用图形API与驱动层交互，这部分交互耗时从主线程转到渲染线程。 而Unity在Project Settings中支持且默认开启了Multithreaded Rendering，一般建议保持开启。在UWA的大量测试数据中，还是发现有部分项目关闭了多线程渲染。开启多线程渲染时，CPU等待GPU完成工作的耗时会被统计到Gfx.WaitForPresent函数中，而关闭多线程渲染时这一部分耗时则被主要统计到Graphics.PresentAndSync中。所以，项目中是否统计到Gfx.WaitForPresent函数耗时是判断是否开启了多线程渲染的一个依据。特别地，在项目开发和测试阶段可以考虑暂时性地关闭多线程渲染并打包测试，从而更直观地反映出渲染模块存在的性能瓶颈。 对于正常开启了多线程渲染的项目，Gfx.WaitForPresent的耗时走向也有相当的参考意义。测试中局部的GPU压力越大，CPU等待GPU完成工作的时间也就越长，Gfx.WaitForPresent的耗时也就越高。所以，当Gfx.WaitForPresent存在数十甚至上百毫秒地持续耗时时，说明对应场景的GPU压力较大。 另外，根据UWA的大量项目和测试经验，GPU压力过大也会使得渲染模块CPU端的主函数耗时（Camera.Render和RenderPipelineManager.DoRenderLoop_Internal）整体相应上升。我们会在最后专门讨论GPU部分的优化。 2.2 同屏渲染面片数 影响渲染效率的两个最基本的参数无疑就是Triangle和DrawCall。 通常情况下，Triangle面片数和GPU渲染耗时是成正比的，而对于大部分项目来说，不透明Triangle数量又往往远比半透明Triangle要多，尤其需要关注。UWA一般建议在低端机型（4G内存的）上将同屏渲染面片数控制在25万面以内，即便是高端机也不建议超过60万面。当使用工具发现局部同屏渲染面片数过高后，可以结合Frame Debugger对重点帧的渲染物体进行排查。 常见的优化方案是，在制作上需要严格控制网格资源的面片数，尤其是一些角色和地形的模型，应严格警惕数万面及以上的网格；另外，一个很好的方法是一通过LOD工具减少场景中的面片数——比如在低端机上使用低模、减少场景中相对不重要的小物件的展示——进而降低渲染的开销。 需要指出的是，UWA工具所关注和统计的面片数量并不是当前帧场景模型的面片数，而是当前帧所渲染的面片数，其数值不仅与模型面片数有关，也和渲染次数相关，更加直观地反映出同屏渲染面片数造成的渲染压力。例如：场景中的网格模型面片数为1万，而其使用的Shader拥有2个渲染Pass，或者有2个相机对其同时渲染；又或者使用了SSAO、Reflection等后处理效果中的一个，那么此处所显示的Triangle数值将为2万。所以，在低端机上应严格警惕这些一下就会使同屏渲染面片数加倍的操作，即便对于高端机也应做好权衡，三思而后用。 2.3 Batch（DrawCall） 在Unity中，我们需要区分DrawCall和Batch。在一个Batch中会存在有多个DrawCall，出现这种情况时我们往往更关心Batch的数量，因为它才是把渲染数据提交给GPU的单位，也是我们需要优化和控制数量的真正对象。 降低Batch的方式通常有动态合批、静态合批、SRP Batcher和GPU Instancing这四种，围绕Batch优化的讨论较为复杂，再写一篇文章也不为过，所以本文不再展开来讨论，但在UWA DAY 2020中我们详细讨论和分享了DrawCall与Batch的关系以及这4种Batching的使用详解，供大家参考：《Unity移动游戏项目优化案例分析（上）》。 下面简单总结静态合批、SRP Batcher和GPU Instancing的合批条件和优缺点。 （1）静态合批 条件：不同Mesh，只要使用相同的材质球即可。 优点：节省顶点信息地绑定；节省几何信息地传递；相邻材质相同时, ，节省材质地传递。 缺点：离线合并时，若合并的Mesh中存在重复资源，则容易使得合并后包体变大；运行时合并，则生成Combine Mesh的过程会造成CPU短时间峰值；同样的，若合并的Mesh中存在重复资源，则会使得合并后内存占用变大。 （2）SRP Batcher 条件：不同Mesh，只要使用相同的Shader且变体一样即可。 优点：节省Uniform Buffer的写入操作；按Shader分Batch，预先生成Uniform Buffer，Batch内部无CPU Write。 缺点：Constant Buffer（CBuffer）的显存固定开销；不支持MaterialPropertyBlock。 （3）GPU Instancing 条件：相同的Mesh，且使用相同的材质球。 优点：适用于渲染同种大量怪物的需求，合批的同时能够降低动画模块的耗时。 缺点：可能存在负优化，反而使DrawCall上升；Instancing有时候被打乱，可以自己分组用API渲染。 2.4 粒子系统渲染开销 一般来说，在渲染模块的堆栈中出现的ParticleSystem.Draw，其调用次数代表了测试过程中粒子系统DrawCall的变化情况。粒子系统DrawCall高，会造成较高的半透明渲染耗时。 针对粒子系统DrawCall的常见优化手段如下： 1.从策略上分级减少粒子在中低端机型上的播放量本身就是一种思路。就像MMO游戏作最大同屏人数上限一样，也可以为粒子系统设置不同机型上的最大播放上限。比如多人游戏中，场景中的粒子用缓存池管理，非玩家主视角的技能产生的粒子系统到达一定数量后就不允许实例化和入池、同种粒子也设置缓存池数量上限，从而控制整体数量； 2.使用TextureSheetAnimation来降低粒子系统的DrawCall，从TextureSheetAnimation设置中Mode下拉选单中选择Sprites选项，可以定义要为每个粒子显示的精灵列表，而不是使用纹理上的一组常规帧。使用此模式可以利用精灵的许多功能，例如精灵打包器（Sprite Packer）、自定义轴心和每个精灵帧的不同大小。Sprite Packer可帮助在不同粒子系统之间共享材质，方法是将纹理整理成图集，从而通过动态批处理（Dynamic Batching）提高性能。 3.还有一种非常常见的粒子DrawCall过高的原因是穿插，比如某个预制体下有1-5，5个不同的粒子构成一整个特效，场景中有100个这个预制体。默认情况下总计500个粒子渲染顺序是相同的，此时在Frame Debugger中很有可能会发现此时有500个DrawCall，且呈现为123451234512345......但显然，理想状况下只需要5个DrawCall就够了。可以考虑通过控制Render Queue、Sorting Layer、Order in Layer和Sorting Fudge来重新排布它们的渲染顺序，从而增加粒子系统的DrawCall合批，从而降低粒子系统的渲染Batch数量（具体需要结合项目中粒子的具体制作情况）。 2.5 UGUI UI 渲染开销 通常战斗场景中其他模块耗时压力大，此时UI模块更要仔细控制性能开销。其中，不仅是UI更新的开销，还有渲染层面的开销。一般而言，战斗场景中的UI DrawCall控制到40-50左右为最佳。 在不减少UI元素的前提下，控制DrawCall的问题，其实也就是如何使得UI元素尽量合批的问题。一般的合批要求材质相同，而在UI中却常常会发生明明是使用同一材质、同一图集制作的UI元素却无法合批的现象。这其实和UGUI DrawCall的计算原理有关。详细的原理介绍可以在UWA学堂中学习有关课程《详解UGUI DrawCall计算和Rebuild操作优化》。 在UGUI的制作过程中，建议关注以下几点： 1.同一Canvas下的UI元素才能合批。不同Canvas即使Order in Layer相同也不合批，所以UI的合理规划和制作非常重要； 2.尽量整合并制作图集，从而使得不同UI元素的材质图集一致。图集中的按钮、图标等需要使用图片的比较小的UI元素，完全可以整合并制作图集。当它们密集地同时出现时，就有效降低了DrawCall； 3.在同一Canvas下、且材质和图集一致的前提下，避免层级穿插。笼统地说，应使得符合合批条件的UI元素的“层级深度”相同； 4.将相关UI的Pos Z尽量统一设置为0。Z值不为0的UI元素只能与Hierarchy中相邻元素尝试合批，所以容易打断合批； 5.对于Alpha为0的Image，需要勾选其CanvasRender组件上的Cull Transparent Mesh选项，否则依然会产生DrawCall且容易打断合批。 还有一部分的渲染开销是UI可能对GPU层面Overdraw产生的压力，这里见最后的GPU章节中相关讨论。 2.6 Shader.CreateGPUProgram 该API常常在渲染模块主函数的堆栈中出现，并造成渲染模块中的大多数函数峰值。它是Shader第一次渲染时产生的耗时，其耗时与渲染Shader的复杂程度相关。当它在游戏过程中被调用并且造成较高的耗时峰值时应引起注意。 对此，我们可以将Shader通过ShaderVariantCollection收集要用到的变体并进行AssetBundle打包。在将该ShaderVariantCollection资源加载进内存后，通过在游戏前期场景调用ShaderVariantCollection.WarmUp来触发Shader.CreateGPUProgram，并将此SVC进行缓存，从而避免在游戏运行时触发此API的调用、避免局部的CPU高耗时。 然而即便是已经做过以上操作的项目也常会检测到运行时偶尔的该API耗时峰值，说明存在一些“漏网之鱼”。开发者可以结合Profiler的Timeline模式，选中触发调用Shader.CreateGPUProgram的帧来查看具体是哪些Shader触发了该API，可以参考《一种Shader变体收集和打包编译优化的思路》。 2.7 Culling 绝大多数情况下，Culling本身耗时并不显眼，它的意义在于反映一些与渲染相关的问题。 （1）相机数量多 当渲染模块主函数的堆栈中Culling耗时的占比比较高（一般项目中在10%-20%左右）。 （2）场景中小物件多 Culling耗时与场景中的GameObject小物件数量的相关性比较大。这种情况建议研发团队优化场景制作方式 ，关注场景中是否存在过多小物件，导致Culling耗时增高。可以考虑采用动态加载、分块显示，或者Culling Group、Culling Distance等方法优化Culling的耗时。 （3）Occlusion Culling 如果项目使用了多线程渲染且开启了Occlusion Culling，通常会导致子线程的压力过大而使整体Culling过高。 由于Occlusion Culling需要根据场景中的物体计算遮挡关系，因此开启Occlusion Culling虽然降低了渲染消耗，其本身的性能开销却也是值得注意的，往往不适用于绝大部分移动端游戏场景。这种情况建议开发者选择性地关闭一部分Occlusion Culling去测试一下渲染数据的整体消耗进行对比，再决定是否需要开启这个功能。 （4）包围盒更新 Culling的堆栈中有时出现的FinalizeUpdateRendererBoundingVolumes为包围盒更新耗时。一般常见于Skinned Mesh和粒子系统的包围盒更新上。如果该API出现很频繁，则要通过截图去排查此时是否有较大量的Skinned Mesh更新，或者较为复杂的粒子系统更新。 （5）PostProcessingLayer.OnPreCull/WaterReflection.OnWillRenderObject PostProcessLayer.OnPreCull这一方法和项目中使用的PostProcessing Stack相关。可以在PostProcessManager.cs中添加静态变量GlobalNeedUpdateSettings，在切场景的时候通过设置PostProcessManager.GlobalNeedUpdateSettings为true来UpdateSettings。这样就可以避免每帧都做UpdateSettings操作，从而减少一部分耗时。 2.8 阴影 阴影是提升游戏画面层次感与真实感的核心渲染效果，但移动端硬件算力有限，不合理的阴影设置会导致同屏渲染面片数翻倍、GPU带宽占用激增，同时增加CPU、GPU端的计算开销，成为性能瓶颈。因此，移动端项目需围绕“分级策略+精准控制”制定阴影优化方案，在表现与性能间找到平衡。 首先，阴影的性能损耗贯穿CPU与GPU两端，主要集中在三个环节： CPU端开销。阴影投射物的边界计算（用于生成Shadowmap）、视锥体裁剪、多光源阴影的叠加计算，以及Shadowmap的内存分配与管理； GPU端开销。Shadowmap的渲染（本质是额外的全屏绘制）、阴影采样（增加纹理带宽占用）、阴影过滤（如PCF软阴影的多采样计算）； 资源开销。Shadowmap作为Render Texture，其分辨率直接决定内存占用，例如1024*1024分辨率的Shadowmap，在RGBA32格式下内存占用约4MB，若开启级联阴影（Cascade），内存开销会按级叠加。 基于此，常见问题或关键优化策略如下： 分级策略 阴影是分级优化的核心场景之一，需根据目标机型的GPU算力、内存大小，制定差异化方案： 低端机型上，考虑直接关闭所有动态阴影或仅保留主角等核心对象的简单阴影，或者使用烘焙、平面阴影等低开销策略。优先保证帧率流畅，牺牲非必要的表现细节； 中端机型上，启用较为基础的阴影设置，限制Shadowmap分辨率，关闭软阴影，仅保留主光源的阴影，且Shadow Distance（阴影绘制距离）控制在20-30米； 高端机型上，仍然需要警惕使用软阴影与级联阴影，即便要用也要严格限制相关设置，并可适当提升Shadowmap、阴影距离等设置的规格。 控制阴影投射与接收范围 并非所有对象都需要投射或接收阴影，精准筛选能大幅降低开销： 关闭非关键对象的阴影，场景中的小物件（如道具、装饰植物）、远距离对象、半透明物体（如粒子特效），可在Inspector面板中取消Cast Shadows和Receive Shadows； 分层控制阴影交互，通过Project Settings→Graphics→Layer Collision Matrix，配置不同层级对象间的阴影投射规则，减少无效计算； 动态调整阴影距离，结合场景相机的视野范围，在战斗、跑图等不同场景动态修改Shadow Distance。例如，近战场景将阴影距离缩短至15米，大世界跑图时延长至30米，避免远距离对象的阴影浪费性能。 优化Shadowmap设置 Shadowmap的参数直接决定阴影的性能开销，需重点把控以下几点： 分辨率控制，移动端Shadowmap分辨率建议控制在10241024，最高不超过20482048； 减少阴影级联数，Unity的级联阴影会将视锥体分为多个层级，每个层级生成独立的Shadowmap，级联数越多开销越大。移动端建议级联数≤2，低端机直接使用1级； 关闭不必要的阴影过滤，软阴影（如PCF4x4过滤）的采样计算量是硬阴影的4至8倍，中端以下机型建议使用硬阴影； 限制阴影投射光源数量，单场景中开启阴影的光源数量建议≤1，优先保留主光源的阴影，点光源、聚光灯等辅助光源直接关闭阴影，或用烘焙阴影替代。 烘焙阴影，这里可以说是又一处用内存换性能的范例 对于静态场景（如地形、建筑、静态装饰），烘焙阴影是移动端的最优选择——将阴影信息预先烘焙到光照贴图中，运行时无需实时计算，大幅降低阴影的CPU/GPU开销，其中还可关注： 静态对象标记。将场景中不移动的对象标记为Contribute GI和Receive GI，确保烘焙时包含阴影信息； 光照贴图优化。降低光照贴图的分辨率，启用压缩格式，减少内存占用； 混合烘焙策略。静态场景用烘焙阴影，动态对象（如角色、怪物）用实时阴影，通过光照探针让动态对象与烘焙阴影自然融合，避免画面割裂。 技术替代方案 若部分场景必须保留动态阴影，但硬件性能不足，可采用轻量化替代方案，降低开销。 比如平面阴影（Planar Shadows），仅在地面等平面上绘制阴影，通过简单的矩阵计算模拟阴影投射，无需生成Shadowmap，CPU/GPU开销极低，适合风格上偏2.5D的游戏或角色阴影； 又比如投射阴影简化，为动态对象创建低模（LOD 0的1/5面片数），仅用低模投射阴影，不影响视觉效果但减少阴影渲染的面片数； 阴影距离衰减：通过Shader控制阴影的透明度，距离越远阴影越淡，最终在Shadow Distance边界处完全消失，避免远距离阴影的无效渲染。 综上，移动端阴影优化的核心很多时候是“取舍”——通过分级策略放弃低端机的非必要阴影效果，用烘焙阴影等低开销方案替代高开销的实时阴影和精良的阴影效果（结合实际项目需要，可能的方案远不止上文讨论的内容，可参考UWA社区中更多文章），在保证画面可接受的前提下，将性能开销降至最低。 3. UI模块 在Unity引擎中，主流的UI框架有UGUI、NGUI以及使用越来越多的FairyGUI。本文主要从使用最多的UGUI来进行说明。围绕UGUI相关优化更全面的内容可以参考《Unity性能优化 — UI模块》。 3.1 UGUI EventSystem.Update EventSystem.Update函数为UGUI的事件系统耗时，其耗时偏高时主要关注以下两个因素： （1）触发调用耗时高 作为UGUI事件系统的主函数，该函数主要是在触摸释放时触发，当本身有较高的CPU开销时，通常都是因为调用了其它较为耗时的函数引起。因此需要通过添加Profiler.BeginSample/EndSample打点或者GOT Online服务+UWA API打点来对所触发的逻辑进行进一步地检测，从而排查出具体是哪一个子函数或者代码段造成的高耗时。 （2）轮询耗时高 所有UGUI组件在创建时都默认开启了Raycast Target这一选项，实际上是为接受事件响应做好了准备。事实上，大部分比如Image、Text类型的UI组件是不会参与事件响应的，但仍然会在鼠标/手指划过或悬停时参与轮询，所以通过模拟射线检测判断UI组件是否被划过或悬停，造成不必要的耗时。尤其在项目中UI组件比较多时，关闭不参与事件响应的组件的Raycast Target设置，可以有效降低EventSystem.Update()耗时。 3.2 UGUI Canvas.SendWillRenderCanvases Canvas.SendWillRenderCanvases函数的耗时代表的是UI元素自身变化带来的更新耗时，这是需要和Canvas.BuildBatch（见下文）的网格重建的耗时所区分的。 持续的高耗时往往是由于UI元素过于复杂且更新过于频繁造成。UI元素的自身更新包括：替换图片、文本或颜色发生变化等等。UI元素发生位移、旋转或者缩放并不会引起该函数有开销。该函数的耗时取决于UI元素发生更新的数量以及UI元素的复杂度，因此要优化此函数的开销通常可以从如下几点着手： （1）降低频繁更新的UI元素的频率 比如小地图的怪物标记、角色或者怪物的血条等，可以控制逻辑在变动超过某个阈值时才更新UI的显示，再比如技能CD效果，伤害飘字等控制隔帧更新。 那么，如何定位哪些UI元素在进行频繁更新？这里推荐在Canvas.SendWillRenderCanvases源码中插桩的方式，就可以在运行时输出再进行更新的UI元素有哪些。可以参考雨松大佬的博客的做法：《UGUI研究院之找到具体某个引起了网格重建的UI元素（三十二）》。 （2）尽量让复杂的UI不要发生变动 如某些字符串特别多且又使用了Rich Text、Outline或者Shadow效果的Text，Image Type为Tiled的Image等。这些UI元素因为顶点数量非常多，一旦更新便会有较高的耗时。如果某些效果需要使用Outline或者Shadow，但是却又频繁的变动，如飘动的伤害数字，可以考虑将其做成固定的美术字，这样顶点数量就不会翻N倍。 判断UI元素是否复杂，其实就是看它的UI Vertex数量。使用Unity Profiler自带的UI模块，可以看到相应帧中相应Canvas路径下各个UI Batch绘制的UI元素数量和Vertex数量，快速定位到复杂元素。 （3）关注Font.CacheFontForText 该函数往往会造成一些耗时峰值。该API主要是生成动态字体Font Texture的开销，在运行时突发高耗时，很有可能是一次性写入很多新的字符，导致Font Texture纹理扩容。可以从减少字体种类、减少字体字号、提前显示常用字以扩充动态字体FontTexture等方式去优化这一项的耗时。 （4）使用TMP的情况 使用TMP的项目在出现如过场剧情、聊天框等存在大量文本的功能时都可能出现Canvas.SendWillRenderCanvases的持续高耗时或卡顿耗时。此时在堆栈中往往会出现TMP.Generate等相关节点。这是因为TMP在高频接受大量新的字符并填入动态字体图集时会产生较高的耗时。经UWA实验发现，在调换为静态字体后，相关UI耗时可能会有近一半的大幅度耗时下降。因此前文在内存中讨论的TMP静态图集方案对于耗时也有一定优化效果。 （5）使用UI Particle的情况 在UWA遇到的多数大面积使用UI Particle的项目中，其性能表现都比较糟糕。这是因为UI Particle是继承于UGUI的MaskableGraphic类写的，因此除了会有本身的粒子更新开销外，还会作为UGUI的元素进行更新，而往往其UI Vertex复杂且更新非常频繁，因此容易造成很高的耗时。因此一般建议尽量少用、简化或改为其他方案来呈现类似的效果。 3.3 UGUI Canvas.BuildBatch Canvas.BuildBatch为UI元素合并的Mesh需要改变时所产生的调用。通常之前所提到的Canvas.SendWillRenderCanvases()的调用都会引起Canvas.BuildBatch的调用。另外，Canvas中的UI元素发生移动也会引起Canvas.BuildBatch的调用。 Canvas.BuildBatch是在主线程发起UI网格合并，具体的合并过程是在子线程中处理的，当子线程压力过大，或者合并的UI网格过于复杂的时候，会在主线程产生等待，等待的耗时会被统计到EmitWorldScreenspaceCameraGeometry中。 这两个函数产生高耗时，说明发生重建的Canvas非常复杂，此时需要将Canvas进行细分处理，通常是将静态的元素放在一个Canvas中，将发生更新的UI元素放入一个Canvas中，这样静态的Canvas由于缓存不会发生网格更新，从而降低网格更新的复杂度，减少网格重建的耗时。 3.4 UGUI CanvasRenderer.SyncTransform 我们常注意到有些项目的部分帧中CanvasRenderer.SyncTransform调用频繁。如下图，CanvasRenderer.SyncTransform调用次数多达1017次。当Canvas.SyncTransform触发次数非常频繁时，会导致它的父节点UGUI.Rendering.UpdateBatches产生非常高的耗时。 而该节点调用次数过高时，应考虑以下四种可能，并采取相应的策略控制调用次数、降低开销： 1.任何UI元素Transform信息变化（如位移、旋转、拉伸，例如飘字这类UI动画）都会导致自身触发一次CanvasRenderer.SyncTransform，若同时发生Transform信息变化的UI元素多，则显然会导致调用次数高（如多人游戏中频繁移动的其他玩家的HUD、战斗场景中频繁弹跳而得出的伤害数字位移动画等）。此时，可以考虑适当限制降低相关变化的更新频率。 2.调用SetActive(True)激活UI元素时，会使当前Canvas下和其父Canvas下所有UI元素都触发CanvasRenderer.SyncTransform，特别地，调用SetActive(False)隐藏UI元素时，不会触发。因此，应考虑将需要频繁激活隐藏的UI元素与其他静态元素进行动静分离。 3.Instantiate实例化UI元素时，会使当前Canvas下和其父Canvas下所有UI元素都触发CanvasRenderer.SyncTransform。 4.Destroy销毁UI元素时，会仅使当前Canvas下所有UI元素都触发CanvasRenderer.SyncTransform，综合第3、4点，应考虑将需要频繁实例化和销毁的UI元素与其他静态元素进行动静分离。 PS：以上API未实际生效时（如对一个已经被激活的对象调用SetActive(True)），则不会触发CanvasRenderer.SyncTransform。 3.5 UI界面切换卡顿 UI界面切换耗时过长也是很多开发者都头疼的问题之一。其实从玩家视角来看UI切换时画面变化幅度大，因此体感上200ms以下的耗时往往并不会造成明显的卡顿感。但尽管如此，实际项目中，如通行证、背包、角色展示界面等较为复杂的UI界面的切换时间超过200ms甚至超过1s的情况并不少见，这就会使得玩家的体验出现明显的打断和不适。 当然，实际上UI界面切换的耗时大多来源于UI元素的实例化和显隐开销，以及一些相应的逻辑耗时。这些本身并不归类到UI模块的函数耗时中，但一些策略就可以很大程度解决这方面的问题。 一种思路是对UI的归类和缓存。一些游戏过程中最常用的UI界面是可以考虑予以缓存的，具体哪些UI值得缓存则需要根据游戏类型或者热点统计数据判断。比如一个养成游戏的角色展示页面、一个Moba游戏的商店页面等等。 还有则是UI的分帧加载。很多项目的通行证页面或者背包页面打开速度很慢，因为其中的图标非常之多。但实际上，可以对其中的UI元素进行重要性梳理后作分帧加载，比如优先加载按钮、重要数值、物品名称等元素，而装饰、图标、分页中的内容都可以放到之后的帧再加载。这样呈现出的效果是，玩家点击时，界面立刻产生了相应，并且重要的交互和信息都可以直接操作，体验将大幅上升。 4. 物理模块 围绕物理模块相关优化更全面的内容可以参考《Unity性能优化 — 物理模块》。 4.1 Auto Simulation 在Unity 2017.4版本之后，物理模拟的设置选项Auto Simulation被开放并且默认开启，即项目过程中总是默认进行着物理模拟。但在一些情况下，这部分的耗时是浪费的。 判断物理模拟耗时是否被浪费的一个标准就是Contacts数量，即游戏运行时碰撞对数量。一般来说，碰撞对的数量越多，则物理系统的CPU耗时越大。但在很多移动端项目中，我们都检测到在整个游戏过程中Contacts数量始终为0。 在这种情况下，开发者可以关闭物理的自动模拟来进行测试。如果关闭Auto Simulation并不会对游戏逻辑产生任何影响，在游戏过程中依然可以进行很好地对话、战斗等，则说明可以节省这方面的耗时。同时也需要说明的是，如果项目需要使用射线检测，那么在关闭Auto Simulation后需要开启Auto Sync Transforms，来保证射线检测可以正常作用。 4.2 Contacts 就像上面提到的，若Contacts始终为0，一定程度上说明项目大概率用不到物理模拟，可以考虑关闭。 而如果我们确实用到物理模拟，则一般碰撞对的数量越多，物理系统的CPU耗时也就越大。所以，严格控制碰撞对数量对于降低物理模块耗时非常重要。 在GOT Online Overview的物理模块中，统计了测试过程中Overlap事件的数量，本质上就是Contacts碰撞次数。 很多项目中可能存在一些不必要的Rigidbody组件，在开发者不知情的地方造成了不必要的碰撞，从而产生了耗时浪费；另外，可以检查修改Project Settings的Physics设置中的Layer Collision Matrix，取消不必要的层之间的碰撞检测，将Contacts数量尽可能降低。 报告中也会有Rigidbody、静态碰撞体数量曲线，以辅助开发者判断物理模块的制作导致的性能问题。 4.3 OnTrigger替换 还一种需要物理模拟的情况，即需要触发的回调逻辑（如OnTrigger，OnCollision等）。但多数情况下，如用来判断角色和场景坐标、人物的交互等，是可以考虑通过其它方式实现的，如Bounds.Contain、Physics.OverlapSphere等API。它们本质也是计算某一点是否在某包围盒范围内，属于较为简单的几何运算，不需要走物理模拟，因此完全替换后最终也可以考虑关闭物理的自动模拟。以下是将OnTriggerEnter的逻辑改为C#脚本逻辑实现的一种参考方式。 4.4 Collider碰撞体 除了碰撞体的数量外，碰撞体的复杂度也会影响物理模块的耗时。默认为模型生成碰撞体，会是接近于模型本体的MeshCollider。但精细度非常高的MeshCollider，即便是在竞技射击类这种对受击判定有着极高要求的项目也可能是完全没有必要的。 多数项目中利用Collider进行相关逻辑判定时，使用复杂MeshCollider并不会有什么实际区别，还会造成非常高的额外开销。 因此一般要尽量少使用MeshCollider，可以用简单Collider代替，即使用多个简单Collider组合代替也要比复杂的MeshCollider来的高效。 MeshCollider是基于三角形面的碰撞，三角形面数越多，运算成本越高；而球体、箱体、胶囊这类简单碰撞体本身形状单一，Unity内部会用更快捷的几何算法进行碰撞运算。 MeshCollider生成的碰撞体网格占用内存也较高；而简单碰撞体重复度高，因而内存中只有几个很小的元碰撞体。 4.5 物理更新次数 Unity物理模拟过程的主要耗时函数是在FixedUpdate中的，也就是说，当每帧该函数调用次数越高、物理更新次数也就越频繁，每帧的耗时也就相应地高。 物理更新次数，或者说FixedUpdate的每帧调用次数，是和Unity Project Settings的Time设置中最小更新间隔（Fixed Timestep）以及最大允许时间（Maximum Allowed Timestep）相关的。这里我们需要先知道物理系统本身的特性，即当游戏上一帧卡顿时，Unity会在当前帧非常靠前的阶段连续调用N次FixedUpdate.PhysicsFixedUpdate，Maximum Allowed Timestep的意义就在于限制物理更新的次数。它决定了单帧物理最大调用次数，该值越小，单帧物理最大调用次数越少。现在设置这两个值分别为20ms和100ms，那么当某一帧耗时30ms时，物理更新只会执行1次；耗时200ms时也只会执行5次。 所以一个行之有效的方法是调整这两个参数的设置，尤其是控制更新次数的上限（默认为17次，最好控制到5次以下），物理模块的耗时就不会过高；另一方面则是先优化其它模块的CPU耗时，当项目运行过程中耗时过高的帧很少，则FixedUpdate也不会总是达到每帧更新次数的上限。这对于其它FixedUpdate中的函数是同理的，也是基于这种原因，我们一般不建议在FixedUpdate中写过多游戏逻辑。 5. 动画模块 围绕动画模块相关优化更全面的内容可以参考《Unity性能优化 — 动画模块》。 5.1 Mecanim动画系统 Mechanic动画系统是Unity公司从Unity 4.0之后开始引入的新版动画系统（使用Animator控制动画），相比于Legacy的Animation控制系统，在功能上，Mecanim动画系统主要有以下几点优势： （1）针对人形角色提供了一套特殊的工作流，包括Avatar的创建以及Muscles肌肉的调节； （2）动画重定向（Retarting）的能力，可以非常方便地把一个动画从一个角色模型应用到其他角色模型上； （3）提供了可视化的Animator编辑器，可以快捷预览和创建动画片段； （4）更加方便地创建状态机以及状态之间Transition的转换； （5）便于操作的混合树功能。 在性能上，对于骨骼动画且曲线较多的动画，使用Animator的性能是要比Animation要好的，因为Animator是支持多线程计算的，而且Animator可以通过开启Optimized GameObjects进行优化，具体细节可以参考UWA学堂的课程《Unity移动游戏中动画系统的性能优化》。相反，对于比较简单的类似于移动旋转这样的动画，使用Animation控制则比Animator要高效一些。 5.2 BakeMesh 对于一两千面这样面数较少且动画时长较短的对象，如MOBA、SLG中的小兵等，可考虑用SkinnedMeshRenderer.BakeMesh的方案，用内存换CPU耗时。其原理是将一个蒙皮动画的某个时间点上的动作，Bake成一个不带蒙皮的Mesh，从而可以通过自定义的采样间隔，将一段动画转成一组Mesh序列帧。而后在播放动画时只需选择最近的采样点（即一个Mesh）进行赋值即可，从而省去了骨骼更新与蒙皮计算的时间（几乎没有动画，只是赋值的动作）。整个操作比较适合于面片数小的人物，因为此举省去了蒙皮计算。其作用在于：用内存换取计算时间，在场景中大量出现同一个带动画的模型时，效果会非常明显。该方法的缺点是内存的占用极大地受到模型顶点数、动画总时长及采样间隔的限制。因此，该方法只适用于顶点数较少，且动画总时长较短的模型。同时，Bake的时间较长，需要在加载场景时完成。 5.3 Active Animator数量 Active状态的Animator个数会极大地影响动画模块的耗时，而且是一个可量化的重要标准，控制其数量到一个相对合理的值是我们优化动画模块的重要手段。需要开发者结合画面排查对应的数量是否合理。 （1）Animator Culling Mode 控制Active Animator的一个方法是针对每个动画组件调整合理的Animator.CullingMode设置。该项设置一共有三个选项：AlwaysAnimate、CullUpdateTransforms和CullComplete。 默认的AlwaysAnimate使得当前物体不管是不是在视域体内，或者在视域体被LOD Culling掉了，Animator的所有东西都仍然更新；其中，UI动画一定要选AlwaysAnimate，不然会出现异常表现。 而设置为CullUpdateTransforms时，当物体不在视域体内，或者被LOD Culling掉后，逻辑继续更新，就表示状态机是更新的，动画资源中连线的条件等等也都是会更新和判断的；但是Retarget、IK和从C++回传Transform这些显示层的更新就不做了。所以，在不影响表现的前提下把部分动画组件尝试设置成CullUpdateTransforms可以节省物体不可见时动画模块的显示层耗时。 最后，CullComplete就是完全不更新了，适用于场景中相对不重要的动画效果，在低端机上需要保留显示但可以考虑让其静止的物体，分级地选用该设置。 （2）DOTween插件 很多时候，UI动画也会贡献大量的Active Animator。针对一些简单的UI动画，如改变颜色、缩放、移动等效果，UWA建议改用DOTween制作。经测试，性能比原生的UI动画要好得多。 5.4 开启Apply Root Motion的Animator数量 在Animators.Update的堆栈中，有时会看到Animator.ApplyBuiltinRootMotion占比过高，这一项通常和项目中开启了Apply Root Motion的模型动画相关。如果其动画不需要产生位移，则不必开启此选项。 5.5 Animator.Initialize Animator.Initialize API会在含有Animator组件的GameObject被Active和Instantiate时触发，耗时较高。因此尤其是在战斗场景中不建议过于频繁地对含有Animator的GameObject进行Deactive/Active GameObject操作。对于频繁实例化的角色，可尝试通过缓冲池的方式进行处理，在需要隐藏角色时，不直接Deactive角色的GameObject，而是Disable Animator组件，并把GameObject移到屏幕外。 5.6 Meshskinning.Update和Animators.WriteJob 网格资源对于动画模块耗时的影响是十分显著的。 一方面，Meshskinning.Update耗时较高时。主要因素为蒙皮网格的骨骼数和面片数偏高，所以可以针对网格资源进行减面和LOD分级。 另一方面，默认设置下，我们经常发现很多项目中角色的骨骼节点的Transform一直都是在场景中存在的，这样在Native层计算完它们的Transform后，会回传给C#层，从而产生一定的耗时。 在场景中角色数量较多，骨骼节点的回传会产生一定的开销，体现在动画模块的主函数之一PreLateUpdate.DirectorUpdateAnimationEnd的Animators.WriteJob子函数上。 对此开发者可以考虑勾选FBX资源中Rig页签下的Optimize Game Objects设置项，将骨骼节点“隐藏”，从而减少这部分的耗时。 5.7 GPU Skinning/Compute Skinning 特别地，对于Unity引擎原生的GPU Skinning设置项（新版Unity中为Compute Skinning），理论上会在一定程度上改变网格和动画的更新方法以优化对骨骼动画的处理，但从针对移动平台的多项测试结果来看，无论是在iOS还是安卓平台上，多个Unity版本提供的GPU Skinning对性能的提升效果都不明显，甚至存在负优化的现象。在Unity的迭代中已对其逐步优化，将相关操作放到渲染线程中进行，但其实用性还需要进一步考察。 对于大量同种怪物的需求，可以考虑使用自己实现的《GPU Skinning 加速骨骼动画》，和UWA开源库中的GPU Instancing来进行渲染，这样既可以降低Animator.Update耗时，又能达到合批的效果。 5.8 Spine Spine作为一种动画方案，在移动游戏中使用率也相当高。但它本身的性能表现比较一般，在实际使用过程中可能会造成意想不到的极高开销。 在运行时，Spine的耗时主要体现在SkeletonAnimation.Update和SkeletonAnimation.LateUpdate、或SkeletonGraphic.Update和SkeletonGraphic.LateUpdate这几个函数中。差别是用一般的组件则是前者，用UI上的Spine组件则是后者。Spine作为频繁更新的复杂动画方案，在UI上可能造成额外的更新耗时，因此一般能用一般组件制作的效果尽量不要用UI组件。Spine的耗时高，一般是上述函数单次耗时高的同时调用次数还高导致的，而这两个因素受到本身美术资源制作的复杂度、和运行时同时播放更新的Spine组件数量影响。此外，Spine还可能对渲染压力、堆内存占用等性能问题也有影响。 以下讨论几个常见的Spine性能问题和优化方法： （1）Spine的美术制作。Spine作为一种动画方案，同样会因为本身Timeline复杂、骨骼节点多而导致在Runtime时更新耗时高。因此，在编辑器阶段，应请美术同学清理用不到的Timeline信息，移除没有用的骨骼、插槽，对每个动作尝试用Spine引擎自带的Clear进行清理，尽量少用Clipping等等。当然，一定要使用二进制方式导出Spine资源，其耗时和堆内存表现要远比Json好的多。 （2）Spine组件数量。也需要监控和控制处于更新状态的Spine数量，关注高压场景的Spine数量是否符合设计预期。一些简单的动画不应用Spine制作，应尽量考虑其他方案。 （3）Update When Invisible设置。SkeletonAnimation组件上存在和Animator的Culling Mode类似的设置Advanced-Update When Invisible。设置为Nothing后，视域体外的Spine动画都停止更新，耗时将得到大幅降低。SkeletonGraphic组件上也有该设置，但需要配合父结点上的RectMask2D才能生效。 （4）Freezing设置。SkeletonGraphic组件上还有Freeze属性，勾选后相关更新会直接Return，不造成任何开销的同时Spine动画也静止了。这个属性适用于某些Spine动画需要条件才触发，其他时候是静止的。则可以在代码中设计为平时是Freeze的，条件出发后才更新，能够节省平时的耗时。该设置在SkeletonAnimation中没有，但完全可以自己调整Spine的Runtime源码实现类似效果。 （5）Spine通过SRP Batcher合批。只要在官网下载替换支持SRP Batcher的Shader，Spine在URP项目中也能通过SRP Batcher合批，降低渲染开销。具体可参考：https://answer.uwa4d.com/question/6100d3094f8c177460171597。 （6）Overdraw。Spine一般作为一种半透明渲染物体，当制作较为复杂时，也可能使得叠层过多，对GPU渲染也造成压力，同样需要在美术层面就予以关注。 （7）通过按需加载降低Spine堆内存占用。一些游戏中，随着不断迭代，部分角色的Spine资源可能包含了大量的皮肤、动作等信息，若这些信息进入游戏则一次性全部加载，则会对堆内存产生严重负担，相关的加载耗时也会很高。做拆分确实可以一定程度上解决该问题，但是需要重新导出资源、调整相关代码，且增加了管理维护这些资源的成本。其实也可以考虑一种按需加载的方式，即游戏过程中需要哪些信息则自动加载相应部分进内存，具体做法可参考：《Spine动画加载优化思路》。 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。
Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（上）
《Unity移动端游戏性能优化简谱》从Unity移动端游戏优化的一些基础讨论出发，例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题，并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题，希望能提供给Unity开发者更多高效的研发方法和实战经验。 今天向大家介绍文章第二部分：资源内存、Mono堆内存等常见游戏内存控制，共13小节，包含了纹理资源、网格资源、动画资源、音频资源、材质资源等多个资源内存以及Mono堆内存等常见的游戏内存控制讲解。 本章包含1-7小节，8-13小节请点击《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（下）》。 （全文长约11400字，预计阅读时间约20分钟） 文章第一部分《Unity移动端游戏性能优化简谱之 前言》可戳此回顾，完整内容可前往UWA学堂查看。 1. 总览 1.1 运行时内存占用 首先，在讨论内存相关的各项参数和制定标准之前，我们需要先理清在各种性能工具的统计数据中常出现的各种内存参数的实际含义。 在包括UWA在内的多种性能工具中，我们最常见到和关心的PSS（Proportional Set Size）Total内存，其含义为一个进程在RAM中实际使用的空间地址大小，即实际使用的物理内存（比例分配共享库占用的内存，按照进程数等比例划分），也即我们游戏进程的实际运行时内存占用。 不过，有的时候也会出现使用RSS Total参数的工具。它和PSS同样代表实际使用的物理内存，不过包含了共享库占用的全部内存，有时会偏大，参考性相对下降；而共享库内存是不会导致杀进程的。在下文它出现的时候，会额外标记。 当然，在实践过程中这两个值还会受到安卓系统版本、具体硬件机型等因素的影响。 无论如何，游戏运行过程中闪退，或暂时切出（如玩家游戏过程中切到社交软件回复信息）就被杀进程，对玩家体验显然是一种严重的破坏。而在UWA经验中，上述两个现象绝大多数情况下都是过高的运行时内存导致的。 当系统内存触发高占用告警时，系统会根据进程优先级自动终止部分进程以释放内存。这也是许多开发者在闪退日志中看到的Out of Memory（OOM）类型闪退的主要原因。 在系统的进程优先级体系中，包括游戏进程在内的玩家应用通常处于较低的一档；而在同一优先级层级中，内存占用越高的进程，被系统终止的概率也越大。因此，游戏进程往往成为被系统杀死的高风险对象。 由于我们几乎无法在引擎层面对系统进程优先级进行重新调度或调整，因此在实际项目中，控制PSS Total的整体占用，仍然是最常见且最有效的优化手段。 有了这样一个总值，当我们发现总值过高时，就需要予以拆解才能定位到问题。接下来，我们从两个视角来拆解PSS Total： 首先是很多开发者都有在用、相对熟悉的Memory Profiler。该工具对内存的划分和命名也一直在随版本调整，因此本文就比较常用的Memory Profiler 0.7.1和1.1.0版本进行分析，在笔者看来，两者各有千秋，实际使用时甚至经常两个一起用。以下是同一个Snapshot结果在两个不同版本上的解析结果： 0.7.1 老版的Summary界面把内存以三个维度进行拆分，比较清晰。其中还能看到In use和Reserved部分分别占了多少，体现出了预留内存。 但老版的细节分析页整合地较差，甚至Take Example时都能清晰呈现的Manager、SerializedFile等类型都没有区分出来。且还需要自己点击分类、隐藏不需要的字段、按大小排序，才能看到上图这样相对有价值的画面。 1.1.0 新版的Summary将原本最上层相对价值较低的维度删除，反之对五种资源内存做了拆分。但主要问题在于移除了预留值的呈现，全部（甚至包括堆内存的预留值）都被计入了Native。 新版的细节整合页明显要好的多，分类更全面清晰，且已经做好了排序、还有简单的可视化。但上面提到的预留值，包括IL2CPP虚拟机的内存都被算进了Unknown里。在UWA的一些排查经验中，还发现会有个别特殊情况会导致这里的Unknown特别高，比如项目的AssetBundle使用LZMA格式进行打包时。尽管知道是不怎么需要针对性优化的部分，但看到这几百MB的占用总还是比较糟心的；更别说很多开发者不理解这部分占用时产生的困惑了。所以，有时候两个版本都看一下作对比就能发现问题。 接下来我们还是取0.7.1的Summary进行详细介绍。可以看到，在图中的第一部分，Unity把统计到的运行时内存总值（此处Unity抓取了RSS内存）分成了两大部分：Tracked Memory和Untracked Memory。 Tracked Memory为Unity引擎分配的、引擎自身可捕捉定位的内存，其实也就等同于我们一直以来讨论的Reserved Total内存，也往往是游戏进程内存占用的大头；而Untracked Memory则恰恰相反，可能是系统层分配的、引擎无法捕捉定位的内存，甚至包括一些共享库内存。前者为Memory Profiler主要支持分析的部分。 而Tracked Memory还有In use和Reserved两种数据， 一般来说，引擎在分配内存时并不是向操作系统 “即拿即用”，而是首先获取一定量的连续内存，然后供自己内部使用，待空余内存不够时，引擎才会向系统再次申请一定量的连续内存进行使用。这表示除了实际使用的内存外，还会有一部分预留，两者加起来才是最终占用的内存，这种情况后面还会出现，不再赘述。一般来说，In use的部分越高，预留的部分也会越高，因此还是需要优先优化实际In use的部分。 在图中的第二部分，Unity把Tracked Memory进一步拆解。其中，需要我们关注的是以下四个部分：Managed Heap、Graphics&Graphics Driver、Audio和Other Native Memory。而其他的如虚拟机、Profiler、DLLs内存在最终IL2CPP的Release包中可能就不存在或占用很低了，一般不需要予以关注。 其中需要解释的是Graphics&Graphics Driver主要包含纹理、网格、RenderTexture等需要提交给GPU渲染的相关资源；而Other Native Memory则包含除了上述渲染资源和音频外其他的资源内存，如字体、粒子系统等，还包含了Scene Object、序列化文件等其它类型的资源。 上述所有内存，都可以在Tree Map\Object and Allocations中进行更详细地分类和分析。但Memory Profiler虽然信息较为完整，但局限性也在于只能截取单帧、最多能做到两帧比较。而实际分析内存问题的时候，内存对象的生命周期、内存统计量的变化情况都是极为重要的信息，而UWA GOT Online Resource模式提供的自动采样功能和衍生出的一系列资源列表的交互设计就要更有优势。 不过，我们在越来越多的项目中发现，Untracked Memory的部分占用出乎意料的高，甚至达到总内存50%左右的水平，这就需要我们通过第二个视角来分析。 虽然用Android Studio也能完成类似分析，但我们还是以adb指令获取的最直接的数据为例。连接真机后，输入“adb shell dumpsys meminfo 项目包名/进程号”指令，就能采样一次内存拆分如图例： 显然地，图中就主要展示了当前采样点下，前文提及的PSS Total及其组成部分。一般而言，需要开发者关注的有三个部分：Native Heap、Gfx dev/EGL mtrack/GL mtrack和Unknown。 其中，前文讨论的Unity Tracked Memory部分仅渲染资源能被系统层识别，所以其内存占用统计分散在了Gfx dev/EGL mtrack/GL mtrack和Unknown这两部分中。而前文讨论的Unity Untracked Memory部分则往往体现在一些外部代码、第三方库、插件等占用的内存上，他们则会分散在Native Heap和Unknown这两部分中。 因此，当我们发现项目中内存高压点存在Gfx dev/EGL mtrack/GL mtrack+Unknown远大于Tracked Memory，或Native Heap过高时，就有必要作进一步的排查了。 若研发团队对这些外部代码和插件的管理较为明确，则可以用对比测试的方式、开关一些插件后打包来看内存的变化；或者直接用一些插件自带的性能分析工具，如Wwise；若Lua使用比较重度，则使用UWA的GOT Online Lua模式进行针对性分析。 不过，在越来越多项目中会发现Native Heap这一部分的内存较高且有升高趋势，这就需要尝试用Perfetto工具协查。 综上，我们就详细解剖了一个移动端Unity项目的运行时内存，以此基础出发再去治疗病灶，就能做到对症下药。而通过以上排查流程在项目中发现、定位和解决内存问题的案例，会在后文相应小节中更加详细地呈现。 1.2 内存参数标准 在我们了解了内存相关的各项参数的含义之后，知道了避免游戏闪退的重点在于控制PSS内存峰值。而PSS内存的大头又在于Reserved Total中的资源内存和Mono堆内存。对于使用Lua的项目来说，还应关注Lua内存。 根据UWA的经验，只有当PSS内存峰值控制在硬件总内存的0.5-0.6倍以下的时候，闪退风险才较低。举例而言，对于2GB的设备而言，PSS内存应控制在1GB以下为最佳，3GB的设备则应控制在1.5GB以下。 特别的，UWA还认为Mono堆内存需要予以关注，因为在很多项目中，Mono堆内存除了存在本身驻留偏高或存在泄露风险的问题外，其大小还会影响GC耗时。 下表为UWA提供的细化到每一种资源内存的推荐标准，制定较为严格。不过，仍需要开发者根据自身项目的实际情况予以调整。比如某个2D项目节省了几乎所有网格资源的使用，那么其他资源的标准就可以放宽很多。 关于更多的细化标准，大家可以直接在UWA线上产品中进行对应查看。 基于项目实情制定内存标准后，一般需进一步与美术、策划协商，给出合理的美术规范参数，并撰写成文档。 定好规范后，定时检查项目里的所有美术资源是否符合规范，及时修改和更新。检查美术是否合规的过程，可以请程序同学帮忙在引擎中编写自动化工具，提高效率；也可以利用下文提到的UWA本地资源检测功能筛查存在设置问题的资源。 如果资源若不能批量处理成高中低配版本，就需要美术为各个画质等级制作不同的资源。 1.3 本地资源检测服务-项目资源检测 各项资源内存的引擎设置项繁琐且并不都能在运行时被采集，下文即将提到的内容虽然是众多项目中常见且重要的问题，但实际项目中的情况更加复杂。通过本地资源检测服务的项目资源检测界面，往往能发现更多资源设置项的问题。它们不光影响相关资源的内存占用，还会根据情况对CPU耗时和GPU造成不同程度的压力。 为此UWA根据经验设计了检测规则和阈值，以此为依据采集和统计了存在这些问题的资源，并给出了对应的优化建议，帮助开发者针对资源进行更加深入的排查和优化。 2. 常见的共通性问题 这一部分提到的问题没有特定性，不仅仅出现在一种资源内存中。所以，为了避免赘述，此处统一予以讨论。 2.1 疑似冗余现象 在UWA GOT Online Resource模式报告的具体资源列表（下文简称资源列表）中，我们常能看到某一项资源的数量峰值大于1且被标红。数量峰值同样是资源使用中非常重要的一项指标。所谓 “数量峰值”，是指同一资源在同一帧中出现的最大数量。理论上，数量峰值这一参数不应大于1，当数量峰值大于1时，列表中会将其标红，我们称之为疑似冗余资源。 一般情况下，出现这种问题有几种可能：最常见的冗余是由AssetBundle资源加载导致的，即在制作AssetBundle文件时，部分共享资源（比如Texture、Mesh等）被同时打入到多份不同的AssetBundle文件中但没有进行依赖打包，从而当加载这些AssetBundle时，内存中出现了多份同样的资源，即资源冗余，建议对其进行严格的检测和完善。 针对排查出的疑似冗余现象，可以使用UWA在线AssetBundle检测工具排查是否确实存在AssetBundle冗余的问题，尽量减少AssetBundle的冗余。建议根据冗余资源的内存大小来决定对冗余问题的优化优先级。 值得一提的是，所谓 “疑似冗余资源”，是指在检测过程中，我们尝试搜索项目运行时的冗余资源并将其反馈给用户。但是，我们并无法保证该项检测的100%正确性。这是因为，我们判断的标准是当两个资源的名称、内存占用等属性均一致时，我们认为这两个资源可能为同一资源，即其中一个为 “冗余” 资源。但项目中确实也存在资源不同但各项属性都相同的情况。因此，我们将通过以上规则提取出的资源归为 “疑似冗余资源”。所以，是否确实为冗余资源，还需要结合项目实情和在线AssetBundle检测报告才能下结论。 GOT Online为此问题还做了进一步的功能更新。现在，Resource报告和Overview报告都新增了关联AssetBundle报告的功能，以便开发者在Resource数据中发现资源冗余时，更便利地排查冗余是否与AssetBundle打包有关。 在利用包体版本号关联成功后，具体资源使用情况表中的资源会和AssetBundle报告中的资源进行匹配。对于匹配成功的参数，系统将以醒目的蓝色链接形式进行标识，方便开发者快速查看和追踪相关资源的详细信息。 通过资源名称旁的AssetBundle打包冗余Tag，开发者可以快速判断Resource资源冗余和AssetBundle打包冗余的情况。若发现异常，可以点击资源名称上的链接查看资源详情，查阅资源的详细信息，包括其名称、涉及的AssetBundle文件数量以及相应的AssetBundle文件名等，从而更全面地了解资源的状态和使用情况。 还有一种常见的冗余是加载和缓存策略导致的。举例而言，当一个资源被加载后，被某个容器引用着，而其相应的AssetBundle包通过Unload(False)卸载了，但这个资源并没有被卸载。当该AssetBundle包重新加载时，显然该资源就会存在多份。 当然，还有一些其他的冗余问题，比如首场景引用的资源又通过AssetBundle加载了一份；Always Include Shader引入后又通过AssetBundle加载的Shader等等，需要具体问题具体分析。 2.2 未命名资源 在资源列表中，有时发现存在资源名称为N/A的资源。一般来说名为N/A的资源都是在代码中new出来但是没有予以命名的。建议通过.name方法对这些资源进行命名，方便资源统计和管理，尤其是其中冗余比较严重的或者个别内存占用非常大的N/A资源应予以关注和严格排查。 2.3 常驻资源数量多/占用大 在资源列表中，有时结合资源的生命周期曲线发现，一批本身数量较为可观、同时单个内存占用较大的资源在某一时刻被加载进内存后，驻留在内存中，直到测试流程结束都没有被卸载，可能造成越到游戏后期资源内存占用越大、峰值越高。建议排查这些资源是否有常驻在内存中的必要。如果不再需要被使用，则应检查为什么场景切换时没有卸载；对于持续时间久的单场景中持续驻留的资源，则可以考虑手动卸载。 对于资源是否常驻的考量涉及内存压力和CPU耗时压力之间的取舍。简单来说，如果当前项目内存压力较大，而场景切换时的CPU耗时压力较小，则可以考虑改变缓存策略，在场景切换时及时卸载下一个场景用不到的资源，在需要时再重新加载。 特别地，针对纹理和网格这两种在多数项目中非常重要、内存占比也很高的渲染资源，UWA提供了一种更科学的方式来判断是否存在这类浪费。 在GOT Online GPU模式中，“渲染资源分析”这一功能通过GPU底层接口，实时采样获取了纹理和网格这两种资源在测试流程中参与渲染的情况。 首先，UWA提出了“渲染利用率”的概念。即测试采样过程中，捕捉到某一纹理或网格资源参与渲染的采样点数量占其在内存中采样点数量的比例。举例来说，一次测试总共进行了10000次固定间隔有规律的采样，其中6000次采样点中，A纹理被发现在内存中；而仅3000次采样点中，A纹理参与了GPU渲染。则A纹理的渲染利用率被判为50%。 事实上，在实际项目中，尤其现在很多移动端游戏也有3D大地图的设计，一些渲染物体被放置在场景的某个角落，玩家游玩过程中不总是能看到它、自然也就很难参与渲染。所以在测试中，一些渲染资源的渲染利用率即便只有百分之几甚至百分之零点几都是很正常的现象。但若发现整个测试流程、或单个测试场景里存在大量渲染利用率根本为0的资源，则存在浪费的可能性非常大。 这可能是因为：一些跨场景的资源被缓存住，在后续场景实际已经用不到没有及时卸载；打包加载了一些开发中已经被弃用的资源；打包和加载的机制过于激进，粒度太大，没有做好按场景和类型分包；场景设计中存在完全不可见或可见概率极低的不合理的渲染物体等等等等。 总之，导致浪费的原因多种多样，举例来说，UWA就发现很多项目会把一开始登录界面、Loading界面的高精度过场图一直缓存到后面的战斗场景。这就说明有些看似比较低级的错误，往往只有利用好的工具和排查流程才能予以根除。 只要在渲染资源分析页面中勾选“渲染利用率为0的资源数量”这一项，就能在下面的资源列表中筛查出本次测试捕捉到的疑似存在浪费的资源。基于此，开发者再回到工程中进行排查即可。（纹理和网格这一项概念的定义和检测没有本质差别。） 3. 纹理资源 3.1 纹理格式 纹理格式设置不合理通常是造成纹理资源占据较大内存的主要原因之一。即便是对于很多已经建立过美术资源标准并统一修改过纹理格式的项目而言，仍然很容易统计到存在大量的RGBA32、ARGB32、RGBA Half、RGB24等格式的纹理资源。这些格式的纹理不但内存占用较大，还会导致游戏包体较大、加载这些资源的耗时较高、纹理带宽较高等等问题。 出现这类问题的原因主要有以下几种：存在一些“漏网之鱼”，比如美术命名不规范导致没有被回调函数修改，或者是代码中创建的资源没有设置其纹理格式；硬件或纹理资源本身不支持目标格式纹理，导致被解析为未压缩格式的纹理。 对于前一种情况，在资源列表中发现有问题的资源后，需要回到项目中自行排查修改；对于后一种情况，UWA推荐的硬件支持的纹理格式主要有ASTC和ETC2。 其中ETC2格式需要对应的纹理分辨率为4的倍数；而即便是对于ASTC格式的纹理，在对应的纹理开启了Mipmap时更是严格要求其分辨率为2的次幂。否则，该纹理还是会被解析成未压缩格式。 3.2 Mipmap Mipmap设置本身并不降低内存，甚至还会使内存上升。但这里提到它的主要原因在于这个设置将作为后续大量讨论的前提，因此需要先对它有基本的认识。 当一张纹理开启Mipmap时，它的内存占用会上升为原始数据的1.33（4/3）倍。这是因为，引擎将它自动生成了多个Mipmap通道进入内存，实际上是它各个通道所占用的内存之和。举例而言，一个具有原大小为1MB的纹理（10241024分辨率、ASTC44格式），在开启Mipmap后获得了11个通道，其内存占用为1+1/4+1/16+...的11项等比数列之和，即约4/3。等比数列的各项就对应了Mipmap0层、Mipmap1层、Mipmap2层......各个Mipmap通道的内存占用。 其价值在于，移动端GPU在渲染采样纹理时，对于开启了Mipmap设置的纹理，总是会根据渲染物体到相机的距离，在不同的像素采样该纹理合适的Mipmap层级。 反过来距离可能更加好理解：如果在移动设备上，一个渲染物体的贴图高达20482048甚至更高，但实际画面中这个物体离相机非常远、绘制的像素区域很小，而GPU在绘制一个像素时，会同时保存NN范围的像素在OnChip高速缓存中，若在绘制下一个像素时还落在NN范围内，就不用向更远端的内存重新获取，从而降低Cache Miss概率。但此时20482048这一不合适的超高分辨率，使得实际绘制的两个像素在这个纹理中间隔得很远，GPU不得不总是要向更远的内存去取新的N*N，这样显然开销就增大了。于是就有了Mipmap这一机制供GPU自己选择合适的分辨率层级进行采样，这样一来，GPU带宽就能够得到大幅下降。 对于3D对象，比如场景中的地形、物件、人物，乃至3D空间中的粒子系统、Spine对象的贴图，只要是和相机存在不固定距离的，其纹理的Mipmap功能是建议开启的；即便是与相机距离恒定的UI纹理，即便一般不需要开启Mipmap，但也要求美术总是设计合理的分辨率。 回到内存的视角，开启Mipmap尽管会使一部分纹理的内存上升共几十MB，但考虑其到对带宽的优化和基于Mipmap能做的一系列后续优化手段，对于多数项目来说都是利大于弊的。 3.3 分辨率 纹理资源的分辨率（即资源列表中的长度和宽度参数）同样也是造成内存占用过大的主要原因。一般来说，分辨率越高，其内存占用则越大。其中最为需要关注的是占据较大分辨率（一般为 ≥ 1024）的纹理。对于移动平台来说，过于精细的表现通过玩家的肉眼很难分辨出差异，而过大的分辨率往往意味着不必要的浪费。 但是，降低纹理分辨率这种优化方式究竟是减少了浪费还是其实是牺牲了表现？我们的程序同学如何去说服美术同学调整纹理的分辨率呢？这里我们就要提到UWA在GOT Online GPU模式的渲染资源分析功能中提到的第二个概念：Mipmap0层采样率过低的资源数量。 与前文提到的渲染利用率概念类似地，工具在测试过程中进行频繁地采样，在渲染该纹理的采样点中，统计各Desired Mip层级所占的比例。举例来说，一次测试总共进行了10000次固定间隔有规律的采样，其中3000次采样点中，A纹理被发现参与了GPU渲染；而仅300次采样点的Desired Mip为0，也就是这些采样点中GPU尝试选择第0层Mipmap进行采样（因为对于未开启Mipmap的纹理同样能采集该值，所以称之为“尝试选择”）；另外2700次采样点的Desired Mip为1。则A纹理的Mipmap0层采样率判为10%。 事实上，在实际项目中，在3D空间下，玩家游玩过程中相机和一个渲染物体的距离可能时而远时而近。但若发现整个测试流程、或单个测试场景里，某一个纹理的Mipmap0层采样率小于5%，则说明GPU极少有必要用到分辨率最高层级的纹理，在多数时候这部分纹理造成了内存占用但根本不参与渲染，可能是一种浪费。更极端来说，一个分辨率过高的纹理可能实际上0层、1层、2层......加起来的利用率也不足5%，浪费更加严重。 只要在渲染资源分析页面中勾选“Mipmap0层采样率过低的资源数量”这一项，就能在下面的资源列表中筛查出本次测试捕捉到的疑似存在浪费的资源。基于此，开发者再回到工程中进行排查即可。举例而言，如下图红框圈出的Tex_B3_Songshu这一资源，从名称上来看大概率是一个松鼠模型的贴图资源，也确实开启了Mipmap。但作为一张10241024的高分辨率纹理，其实际渲染过程中Desired Mips0和1层的占比都为0，直到2层才有85.71%。这也就意味着该纹理直接改为256256也不会对表现有任何影响，同时还能节省绝大部分的内存占用。 当然，在不同档位的机型上使用不同分辨率大小的纹理资源本身是非常实用且易操作的分级策略。 这一点即便对于图集纹理也同样适用，特别地，Unity针对SpriteAltas提供了Variant功能，可以快捷的复制一份原图集并根据Scale参数降低该变体图集的分辨率，以供较低的分级使用。 3.4 Global Mipmap Limit & Texture Streaming 除了通过上述方式筛查存在浪费的纹理并一一修改外，对于纹理大量开启Mipmap并且纹理内存压力较大的项目来说，还有两种自动控制纹理总内存的方式。即Project Settings-Quality设置下的Texture Quality/Global Mipmap Limit和Texture Streaming。 Texture Quality/Global Mipmap Limit 在一直以来的很多Unity版本中提供的Texture Quality设置，实际上是一种颇为“暴力”的手段。开启后，引擎会在相应分档上强制移除所有开启Mipmap的纹理的相应层级。比如若设置为Half Res，则所有Mipmap 0层的纹理都不会进入内存。这种一刀切的做法一半在内存压力极大的低端机上出现、降低内存的效果非常好，但很多项目并不会选择这个做法，因为可能对表现的影响较大。 不过，在较新的Unity版本中（图中为2022.3.60版本），该功能被重置为Global Mipmap Limit，其按Mipmap层级强制移除的逻辑和之前是一样的。不过除了这种全局影响所有Mipmap纹理的设置外，多出了按照“Mipmap Limit Groups”分组设置移除层级的方式。在Quality中设置不同组别移除不同数量的层级后，在纹理的高级设置里勾选应用Mipmap Limits并选择所在分组，即可对不同纹理应用不同的Mipmap层级移除策略。用过UE的开发者可能会比较眼熟，不过Unity这边是单独维护了一套纹理组来专门处理Mipmap的使用。 Texture Streaming 在项目资源量很大且需要针对不同分档应用不同分级策略时，上述功能很可能仍不足以满足开发者的需求，维护成本太高、又显得不够灵活。 Texture Streaming则是基于一种流式加载场景控制资源的思路，由引擎自行判断哪些Mipmap层级该进内存，从而节省一部分纹理占用。 但Texture Streaming的使用过程中是有些坑存在的： 注意点一：在Project Settings-Quality中开启Texture Streaming选项。然而实验发现有时Editor中开启该选项、真机上却会失效，导致所有纹理的Streaming Mipmap设置全部失效。应该在代码中调用API全局地开启这个选项，才能确保Texture Streaming可用。 注意点二：正确的参数才能使该功能起到好的效果。比较重要的参数是Memory Budget参数和Max Level Reduction参数：Memory Budget表示纹理资源的预算，默认值是512MB，但对于比较常规的移动端分档策略来看，中低端机上一般200MB左右就够了。它的数值代表的是所有纹理资源的预算——即，它既包括了非流式的纹理、又包括了我们想要采用流式的纹理——但这个”预算”并不代表纹理资源可占用的上限，只是Unity判断对于一个开启Streaming Mipmap的纹理到底采用它的哪些Mipmap通道的参考值。而Max Level Reduction则是代表Unity至少要保留的Mipmap通道（如设置为2时，尽管内存已经超过了预算，引擎仍最多只能移除0、1层的Mipmap纹理）；同时Max Level Reduction也代表了纹理流式系统在初始加载一张Texture时加载的Mipmap等级（如设置为2时，纹理刚加载时是不包括0和1层的，只有当预算富余时才会加载）。 所以整个判断的流程是，先将所有非流式纹理正常加载；将流式纹理按照Max Level Reduction剔除高分辨率后的部分加载进内存中。然后将此时所有纹理内存之和和Memory Budget比较，如果已经饱和甚至超过，则不再变化；如果距离Memory Budget的值还有盈余空间，则会酌情加入流式纹理中更高分辨率的Mipmap通道，使纹理的表现更好。 也就是说，如果预算值设置的较高，或者项目中纹理占用的内存本来就比较低、远低于预算值，则Texture Streaming实际上几乎完全不起效、所有开启Streaming Mipmap的纹理仍将保留它们的全部Mipmap通道。 注意点三：设置开启Streaming Mipmap的纹理。官方文档只说了该设置会对开启了Streaming Mipmap设置的纹理生效。从实际操作来看，Texture Streaming的生效对象其实更苛刻，需要同时满足： 开启了Streaming Mipmap且开启了Generate Mipmap的纹理（显然开启Generate Mipmap才会生成Mipmap通道供Streaming Mipmap剔除）； 被即时加载的纹理（如通过AssetBundle加载或Resources.Load()加载的纹理）； 传向GPU部分内存（这里指的是纹理资源开启Read/Write选项时，复制到CPU端的那一部分内存是不受Texture Streaming影响的）。 注意点四：勾选Texture Streaming设置后，运行时会出现一个名为TextureStreamingManager.Update的函数持续造成开销。这是因为引擎要持续计算哪些层级的纹理能够被加载进内存，若项目使用的纹理数量多，则该耗时在低端机上颇为可观。因此要确定当前的Texture Streaming设置是否正确、是否正常生效；节省的内存是否值得这额外的耗时开销。 3.5 Read/Write Enabled 上文提到过，纹理资源的内存占用是计算在GFX内存中的，也就是传向GPU端的部分。而开启Read/Write Enabled选项的纹理资源还会保留一份内存在CPU端，从而造成该资源内存占用翻倍。 UWA GOT Online Resource模式报告资源列表或是本地资源检测报告中都直接展示了哪些纹理开启了Read/Write Enabled选项。实际上，不需要在运行时进行修改的资源是不需要开启Read/Write Enabled选项的，开发者应排查并关闭不必要的设置从而降低内存开销。 3.6 图集制作 图集制作不够科学也是项目中常会发生的问题。资源列表中有时会出现数量峰值较高的图集纹理，但不一定是冗余。一种情况是，大量小图被打包到同一图集中，导致该图集纹理资源设置的最大分辨率（比如2048*2048）一张装不下这么多小图，该资源就会生成更多的纹理分页来打包这些小图。因此，只要游戏过程中依赖某一张纹理分页中的某一张小图，就会将该资源、也即该资源下所有的分页都全部加载进内存中，从而造成不必要的浪费。所以一般建议控制到2-3张分页以内较为合理。 即便不出现上述这个较为极端的现象，很多项目中也会出现“牵一发而动全身”的现象。即明明只用图集中的一张或几张小图，却将内存占用颇大的整个纹理都加载进了内存。 为此，在制作打包图集时，严格按照小图的使用场景、分类进行打包是非常重要的策略。选用合适的分辨率从而避免纹理没有被填充满而导致浪费，也是开发者需要注意的点。 3.7 使用TextMeshPro的情况 TextMeshPro能为UI组件提供更好的表现和便利的功能，使得其受到不少开发者的青睐。但使用TMP而产生的TMP字体图集纹理（名称中带有SDF Atlas，格式为Alpha 8的纹理）也有一些坑值得注意。 有时，结合字体资源列表注意到内存中还存在TMP图集纹理对应的.ttf字体文件。说明该TMP字体图集为动态字体。可以考虑在项目开发结束、确保游戏要用到的字符都已添加到动态字体的Altas纹理中后，将动态TMP重新设置为静态TMP，并且解除对.ttf文件的依赖。这样一来，对应的字体资源将不会出现在内存中。不过，如果这种字体还被用作用户输入，则不建议采用此方法。 Atlas字体纹理的分辨率较大。此时建议在引擎中排查字符有没有填满图集纹理，纹理的制作生成是否合理。对于动态TMP，如果没有填满，如只占据了纹理的3/4不到，则可以考虑开启Multi Atlas Textures选项，并设置纹理大小，举例而言就可以使1张40964096的纹理变为3张20482048的纹理，节省32MB-3*8MB=8MB的空间。 资源列表中有TMP相关的资源（LiberationSans SDF Atlas、EmojiOne），它们都是TMP的默认设置，可以在Project Settings-TextMesh Pro Settings中解除对这些默认资源的依赖，就不会出现在内存中了。 由于Multi Atlas Textures是动态TMP的选项，所以第1、2点无法同时使用，可以根据项目实情酌情选用。 4. 网格资源 4.1 顶点和面片数 顶点和三角形面片数过多的网格资源不仅会造成较高的内存占用，同时也不利于裁剪，容易增加渲染面数，在渲染时对GPU和CPU造成压力。针对这些网格，一方面可以简化网格，减少顶点数和面数，制作低模版本，供中低端机型分级使用；设计制作好合理的Level of Details系统；而对于单个顶点数过高的静态网格，比如一些复杂的地形和建筑，可以考虑拆分成若干个重复的小网格重新拼接，能够节省内存。如果能通过SRP Batcher合批，或规划好作运行时静态合批，又或对大量重复物体用GPU Instancing制作，则渲染耗时也还是能得到良好控制。 但是，降低模型面数这种优化方式究竟是减少了浪费还是其实是牺牲了表现？我们的程序同学如何去说服美术同学调整模型的复杂度呢？又或者怎样的LOD设计才是比较合理的呢？对于这个有点眼熟的问题，我们需要引出GOT Online GPU模式的渲染资源分析功能中提到的第三个概念：渲染顶点密度过高的网格。 同理，工具在测试过程中进行频繁地采样，在渲染该网格的采样点中，将该网格每次参与绘制的顶点数量和绘制的像素数量作比值，当这个比值最小的时候都超过每10000像素画1000个顶点这个水平时，认为其渲染密度过高。形象地说，10000像素为100*100的范围，实际为大拇指指甲盖大小的、屏幕中很有限的一个范围内，却至少绘制了超过1000个网格顶点，可以直观地感受到这显然是肉眼难以分辨的精细程度，而在实际的GPU渲染过程中，部分三角形也很可能被GPU进行微小面裁剪剔除，对表现毫无贡献。 这种规范来自于移动端GPU硬件厂商提出的最佳渲染实践概念，认为：一个合理均匀的网格，其顶点数量和三角形数量的比例应在1.5：1最为合理；每个三角形至少由10-20个像素进行绘制最为合理。UWA将这两个因素综合，提出了具有可行性和现实意义的标准。 只要在渲染资源分析页面中勾选“渲染顶点密度过高的资源数量”这一项，就能在下面的资源列表中筛查出本次测试捕捉到的疑似存在浪费的资源。基于此，开发者再回到工程中进行排查即可；也同样适用于排查Level of Distance的各层级模型是否仍精度过高。 4.2 顶点属性 如果没有统一美术资源标准且在导入时没有进行处理，则项目中的网格很有可能包含大量“多余”的顶点数据。这里的“多余”数据是指网格数据中包含了渲染时Shader中所不需要的数据。举例而言，如果网格数据中含有Position、UV、Normal、Color、Tangent等顶点数据，但其渲染所用的Shader中仅需要Position、UV和Normal，则网格数据中的Color和Tangent则为“多余”数据，从而造成不必要的内存浪费。其中，一个小网格资源带有顶点属性，会使所在的Combined Mesh也带有顶点属性，需要予以注意。 针对这个问题，一个比较简单的方法是，尝试开启“Optimize Mesh Data”选项。该选项位于Player Setting的Other Settings中。勾选后，引擎会在发布时遍历所有的网格数据，将其“多余”数据进行去除，从而降低其数据量大小。但是，需要注意的是，对于在Runtime情况下有修改Material需求的网格，建议研发团队对其进行额外的注意。如果Runtime时需要为某一个GameObject修改更为复杂、需要访问更多顶点属性的Material，则建议先将这些Material挂载在相应的Prefab上再进行发布，以免引擎去除Runtime中会进行使用的网格数据。 4.3 Read/Write Enabled 在资源列表中，常常统计到大量顶点属性不显示为-1（或“-”）的网格资源。只有网格资源开启Read/Write时，UWA报告才能采集到顶点属性信息。此时，顶点属性不显示为-1，且会使得网格占用内存上升。一般而言，不需要在CPU端进行修改的网格是不需要开启Read/Write的。可以在编辑器中通过API修改这些网格的Read/Write属性，或者对于FBX中的网格可以直接在Inspector窗口中修改。 5. 动画资源 动画资源的内存占用与运行时性能开销，核心取决于动画类型选择、压缩策略配置及曲线精度控制，不合理的设置易导致内存冗余或CPU/GPU额外消耗，需结合项目场景与机型分级针对性优化。其中： 动画类型选型。现在Unity的主流方案为Generic动画类型（基于Mecanim系统，耗时体现在Animators.Update函数），其多线程计算支持与资源利用率优于Legacy类型（耗时体现在Animation.Update函数），但这种优势尤其体现在骨骼动画上，一些简单的动画（如UI动画），其性能表现可能反而不如Legacy；其中，人形角色可选用Humanoid类型，支持动画重定向但需遵循人形骨骼规范，避免无意义使用高开销类型。 动画压缩策略。将Anim. Compression设置为Optimal，Unity会自动选择曲线优化算法（如关键帧合并、数值量化），在保证视觉效果的前提下最小化内存占用；对精度要求较低的动画（如背景特效、次要角色动作），可选用KeyframeReduction模式，手动调整Reduction Tolerance参数（建议0.01-0.1区间），减少冗余关键帧。一般不建议使用默认的Off模式，否则动画数据无任何压缩，内存占用可能翻倍。 精简动画内容。剔除无效曲线（如未使用的Scale曲线、冗余的骨骼动画通道），删除空白关键帧或合并相似帧；缩短非必要动画时长，移动端单段动画内存占用建议控制在200KB以内，复杂动画可拆分为多个短片段按需加载。 精度与采样率控制。通过API压缩动画精度，例如使用AnimationUtility.SetAnimationClipSettings调整动画帧率（移动端建议15-30fps，一般不建议用60帧动画）；对循环动画（如呼吸、待机动作），可减少循环段的关键帧密度，利用插值计算保证流畅度。参考Unity官方优化方案，可通过代码遍历动画曲线，合并差值小于阈值的关键帧，进一步降低数据量。 还可以结合项目实际情况关注： 动画资源复用。将通用动画（如行走、攻击、待机）提取为公共资源，通过Animator Controller的状态机复用，避免重复创建相似动画片段；利用动画重定向功能，使同一套动画适配不同模型，减少资源冗余。 内存与性能权衡。压缩策略（如Optimal）可能导致轻微精度损失，需通过真机测试验证核心角色动画、关键技能特效的表现是否符合预期；对中低端机型，可关闭次要动画的Root Motion、IK等功能，降低CPU计算压力。 资源检测与筛选。通过UWA本地资源检测工具筛查精度过高、冗余关键帧过多的动画资源，重点关注单帧数据量过大、动画时长与实际需求不匹配的资源；对内存占用超500KB的非核心动画，应考虑要求美术重新优化或拆分。 分档策略。复杂动画（如多骨骼联动、物理驱动动画）建议分档处理，高端机使用完整精度版本，中低端机替换为简化版动画（减少骨骼数量、降低帧率）。 6. 音频资源 音频资源的内存占用与运行时性能开销，核心取决于加载方式、压缩格式、声道类型及采样率配置，不合理的设置易导致内存冗余、加载卡顿或播放时的CPU额外消耗，需结合音频类型（BGM/音效/语音）与机型分级针对性优化。其中： 声道类型优化 优先开启Force To Mono选项，将双声道音频自动混合为单声道。该设置不会丢失核心音效信息，却能使音频内存占用直接减半，是移动端音频优化的 “性价比之王”。仅对核心立体声需求的音频（如3D空间音效、环绕声BGM）保留双声道，其余音频（如UI音效、角色语音）强制单声道。 选择合理加载方式 短音效（时长＜3秒，短促且会反复播放，如点击、碰撞音效）：选用Compressed In Memory加载模式，音频以压缩格式驻留内存，播放时快速解压，平衡内存与耗时； 长音频（时长≥30秒，时间较长，如背景音乐；或10＜时长＜30，时间适中但不怎么会重新播放，如剧情语音）：选用Streaming流式加载模式，仅加载音频头部信息与缓存片段，内存占用可稳定控制在200KB以内，避免一次性加载导致的内存峰值； 一般不建议使用默认的Decompress On Load模式，该模式会在加载时完全解压音频数据，导致内存占用暴涨（如10MB的压缩音频解压后实际占用可能达50MB以上）。 压缩格式与质量调整。 对于Compressed In Memory模式的音频，优先选择高压缩比格式：Android平台推荐Vorbis，iOS平台推荐MP3，两者压缩率均高于Unity默认格式，可降低30%-50%的内存占用； 调整Quality参数进一步优化：非核心音效可将质量设为50%-70%，语音类音频设为60%-80%，BGM设为80%-90%，具体需结合实际项目中声音效果，在音质可接受范围内最大化压缩收益； 尽量避免使用PCM等无压缩格式，其内存占用是压缩格式的5-10倍，仅适用于对音质要求极高的短时长核心音频（如关键剧情语音片段）。 采样率与位深精简。 移动端音频采样率建议按类型分级设置，不过也同样需要结合实际的声音表现进行调整。 还可以结合项目实际情况关注： 音频资源复用与裁剪 提取通用音效（如按钮点击、道具拾取）作为公共资源，避免重复创建相似音频文件； 裁剪音频首尾的空白片段，缩短无效时长，减少内存占用与加载耗时。 分级策略适配 高端机型：保留双声道、高采样率与高压缩质量，优先保证音质表现； 中低端机型：强制单声道、降低采样率、关闭非核心音频（如环境音效），优先保证内存与帧率稳定。 加载缓存策略 集中加载大量音频资源的场景需要结合使用需要调整加载缓存策略。例如，语音类音频建议采用分段加载策略，避免一次性加载完整剧情语音包，减少内存压力。 7. 材质资源 材质资源本身内存占用较小，我们一般更加关注如何优化其数量，因为它的数量过多会影响之后会提到的Resource.UnloadUnusedAssets API的耗时。 材质资源数量过多，往往主要是因为Instance类型的冗余Material资源过多。一般来说，该种情况的出现是因为通过代码访问并修改了meshrender.material的参数，因此Unity引擎会实例一份新的Material来达到效果，进而造成内存上的冗余。对此，建议通过MaterialPropertyBlock的方式来进行优化，具体相关操作和例子见如下文章《使用MaterialPropertyBlock来替换Material属性操作》。不过这种方法在URP下不适用，会打断SRP Batcher。除此之外，则需要关注和优化非Instance的材质资源的疑似冗余现象。 除了数量上的问题外，材质资源往往还涉及到一些纹理采样和Shader使用相关的问题，导致一些额外的内存和GPU性能浪费，如： 对于使用纯色纹理采样的材质，可以将纹理采样替换为一个颜色参数，从而节省一张纹理采样的开销；而对于空纹理采样的材质，Unity会采样内置提供的纹理，但是计算得到的颜色是一个常数，仍然属于浪费。 上述问题也都可以通过UWA本地资源检测筛查静态资源筛查，比起人工排查大量材质资源高效地多。 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。
《Unity移动端游戏性能优化简谱》是从Unity移动端游戏优化的一些基础讨论出发，例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题，并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题，希望能提供给Unity开发者更多高效的研发方法和实战经验。 今天向大家介绍第一部分，共五小节。完整内容请前往UWA学堂查看。 玩家眼中的游戏性能 开发者关注的游戏性能 如何确定问题 如何解决问题 如何持续监控性能 前言 1. 玩家眼中的游戏性能 任何一个玩家在游玩游戏过程中都会碰到类似的疑问：为什么我玩一段时间就会闪退？为什么我的游戏这么卡？为什么我的硬件这么烫？为什么移动设备电量下得这么快？ 对于玩家而言，无关手里这款游戏本身的美术是否精良、玩法是否有趣，当上述这些疑问出现时，其游戏体验会自然而然地变差，并不可避免地导致黏性下降。这是每个游戏开发者都不想看到的结果。 而实情是，对于如今的移动端游戏市场而言，国内市面上各类硬件机型性能跨度很大；如果项目要支持海外市场，则往往项目所要承担的性能压力更大。那么，如何让持有不同硬件设备的玩家，尤其是通过中低端设备接触到我们游戏项目的玩家也能顺利流畅地体验游戏内容，就是我们今天讨论的主要话题。 2. 开发者关注的游戏性能 在游戏项目的开发过程中，一个非常常见的现象是：研发团队在新项目的中前期往往把全部精力投入到实现功能中去，而忽略了性能优化的工作；到了游戏中后期甚至临近上线的时候进行测试，才发现项目充斥着大量严重的性能问题、影响到了游戏的正常体验，但却不知道从何改起、时间非常窘迫；再到上线后，受到大量玩家对性能的负面反馈，留存数据也受到影响。 足以见得，性能问题是一款游戏项目重要的生命线，需要开发者树立良好的优化意识和优化知识体系，才能让项目少走弯路。 和玩家视角不同的是，像闪退、卡顿、发热、耗电这种感官层面的性能问题，UWA认为在开发者的视角中应该将其主要分类为内存、CPU、GPU三个大类的问题。本文的主要内容旨在围绕三个大类的常见典型问题进行例举和讨论，从而形成一份Unity移动端游戏性能优化方法论的简谱。 那么，既然已经发现了若干性能问题，如何排查确定问题和解决问题就成了最关键的两个步骤。 3. 如何确定问题 发现了性能问题后的第一大步骤就是确定问题。 举例而言，我们的游戏项目运行时间一长就会闪退，是因为我们的缓存策略做的不好或者存在冗余现象，导致内存不断上升造成的闪退？还是因为我们的美术资源内存控制的不好，几个较大的资源就轻易把整体内存给撑大了？ 又或者我们发现一个游戏场景卡顿严重、帧率很低，是因为这里的同屏渲染面片数和DrawCall数都太高导致渲染模块压力大？还是因为此处的UI过于复杂且变化频繁造成的更新耗时？ 还或者我们感到手里的手机烫的惊人，是不是因为当前场景GPU压力过大，我们在当前机型上做的分级策略并不合适？ 以上只是众多千奇百怪层出不穷的性能问题中的寥寥几点。后文还将详细讨论一些比较常见的问题。总之，确定问题的工作本身也是一项挑战。 3.1 计算机语言/图形学/游戏引擎知识 知识和经验无疑是一个开发者在优化项目性能时最宝贵的武器，不断地学习和更新也是每一个程序员武装起自己的过程。完善而饱满的知识自然不仅仅有利于确定问题，同样有助于解决问题。放在这里说是因为它是我们有能力着手优化工作的重要前提。 除了查看各类工具源码和文档、跟踪工具更新内容、翻阅大牛撰写的书籍、浏览各类技术论坛和博客等等以外，也可以在UWA的技术社区中找到很多优质内容。 UWA 学堂：持续更新各类优质课程，内容从某些开发工具的入门和使用到某个引擎模块的机制原理详解；从最新的移动端游戏技术实践到精品项目案例讲解等等。还邀请了众多具有多年从业经验的业界大佬和著名游戏公司合作伙伴撰稿，涵盖面广、质量颇高。更有每年侑虎科技UWA主办的UWA DAY游戏开发者大会的优秀内容。 UWA问答：帮助开发者找到更好的答案。是游戏开发者们日常沟通交流技术问题的良好平台。在这里你对游戏制作方方面面的任何问题，都能够得到讨论或解答。通过最直接的技术交流来少走弯路、获得经过实践检验的开发经验，让确定和解决问题的过程变得简单许多。 UWA开源库：帮助开发者发现更好的解决方案。不断更新和整合了各种来源的开源项目，翻译完毕、分类清晰、图文并茂，为开发者提供了更加方便地搜索发现和获取开源资源的渠道。 UWA Blog：包含UWA精选的各类技术文章、提醒学堂优质内容上新、整合问答优质问题、介绍UWA产品和其更新等各类UWA社区动向。 3.2 多种多样的辅助工具 工欲善其事必先利其器。使用工具获得直接精确的性能数据，能更加直观地反映出性能瓶颈，从而达到事半功倍的效果。 随着业界的发展和成熟，各大引擎、IDE和硬件厂家都推出并更新着自己的性能分析辅助工具，也有不少开发者或团队喜欢根据自身项目的情况开发自己的性能监控插件。 以引擎而言，Unity Profiler是很多开发者都会用且在用的工具，可以记录并查看CPU/渲染/UI/内存等各模块的实时消耗参数；此外，Unity还衍生出Frame Debugger、Memory Profiler等越来越成熟有用的编辑器性能分析工具。 以IDE而言，Windows、MAC OS/iOS、Andriod平台都分别有适用的工具，如Visual Studio的性能探查器、XCode的Instruments、Android Studio的Profiler等。 以硬件而言，SnapDragon Profiler、XCode Metal Frame Capture、Mali Graphics Debugger等种类就更多了，不一一赘述。 总而言之，根据开发者的使用熟练度、不同硬件平台的兼容性、工具功能的覆盖面等等因素，开发者在挑选合适的性能优化辅助工具时很容易挑花眼 3.3 UWA工具 那么有没有一款简单易用、兼容性强、功能全面的性能优化辅助工具呢？ 当然有啦—— UWA GOT Online工具可以通过UWA提供的SDK快速集成到测试项目中，在真机上完成测试流程后，在极短的时间内完成数据的上传和解析，从而自动生成一系列可视化的图表。与此同时，还基于UWA丰富的优化经验和庞大的数据库进行评分，根据各个性能模块提供针对性的分析建议和性能参数的变化趋势。 不仅如此，UWA还提供了真人真机测试服务、在线AssetBundle检测、本地资源检测、Pipeline、GPM 2.0、Gears、深度优化驻场等工具或服务，从不同维度满足开发者的优化需求。 4. 如何解决问题 性能的第二大步骤就是解决问题。在有了知识经验和上述工具的加持下，我们的项目越是复杂、越是来到后期，就越是会面临一条长长的优化问题清单。在有限时间里合理地规划并缩短这条清单，就是我们面临的下一项挑战。 4.1 优化工作优先级 在进行具体的优化工作前，合理的规划往往能节省大量总体时间。 规划工作的第一点就是要抓住问题的主要矛盾。在一条长长的优化清单中，把每一项条目并行地、完全地优化完毕往往是不现实的。此时就需要重新整理这份清单，按照优先级重新进行排序，为优化性价比更高的问题投入更多。 对于优先级的判断，UWA认为主要参考两点：一是重要性，如果一个问题对当前场景的性能压力影响远大于其他因素、显然是主要矛盾，如果不予修正很难维持正常的游戏体验，那么就应该投入更多的人力时间集中优先解决；二是易操作性，如果一个问题造成的性能压力相对来说并不大，但只是耗费极少功夫的举手之劳，比如只是在引擎设置中关闭一个开关，就应该优先把它改掉。 4.2 性能和效果的权衡 第二点则是一种取舍，针对资源和渲染等部分的优化往往会影响到游戏的表现效果。为此，程序员往往需要和策划、美工同事加强沟通，在两者之间找到一个平衡点。这种平衡是一种动态平衡，需要结合实际情况操作。比如，一款吃鸡游戏画面变化频繁、且需要流畅的联机战斗操作，那么对表现的要求可以适当低一些；而一款养成类游戏把精美的角色作为卖点，其模型和动画就需要非常精致，而牺牲一部分性能。哪怕在同一款游戏中也可以在过场动画和角色展示界面中注重表现，而在战斗场景中关注性能，需要具体问题具体分析。 总的来说，游戏项目的优化最终目标是能够在玩家硬件上顺利运行，而为此放弃很大一部分表现效果，也是大多数开发团队的实际选择。但，UWA在大量的项目优化实践中发现，往往很多对表现并无贡献、属于完全浪费地性能问题占据了相当大的比例，优先解决这类问题的优先级显然要更高。 4.3 分级策略和制定标准 第三点，在每一类机型上都达成上述的平衡就是我们的分级策略。我们大可以在高端机乃至旗舰机上开启后处理、使用高分辨率高模；但在中低端机型上则使用另外一套标准。 制定分级策略同样需要我们根据项目类型调整。更细节准确的分级还应参考项目发行目标市场的硬件机型和同类游戏的分档经验，具体可以参考文章《基于移动设备算力的UWA性能标准系统上线》。 另外，开发团队还最好制定一套详尽合理的标准来管理约束整个开发流程。最常见的就是制定美术资源标准，从而极大程度避免内存问题的失控。Unity还提供了回调函数在资源导入时就进行归一化设置，具体也可以在UWA学堂中找到相关课程《自动化规范Unity资源的实践》。 4.4 UWA建议 规划完毕后就来到了具体的优化工作。这个工作冗长且需要耐心，而UWA很乐意在这个过程中继续提供帮助： 你当然可以在UWA社区中寻找方法，或者是在UWA工具的自动化报告中找到解决方案。你还可以联系UWA工作人员，从而提供真人真机测试报告1对1讲解服务或者是更进一步地深度优化服务。UWA的引擎技术团队将对游戏项目进行更加详细的诊断和建议。 5. 如何持续监控性能 在迈出了性能优化的前两步后，很多开发团队都想要或者尝试走出第三步，也就是持续监控性能的这一步。 为此，一些团队安排专门的引擎优化人员、开发自己的DevOps工具。当在团队内部逐步形成较为成熟的相关体系后，将节省项目后续开发维护的成本，并可使经验延续到其他新项目中，大大提升开发效率。 而另外一些团队则有在游戏上线后继续监控性能的必要。 5.1 UWA Pipeline 针对这些需求，UWA推出了UWA Pipeline。它是一款面向游戏开发团队的本地协作平台，旨在为游戏开发团队搭建专属的DevOps研发交付流水线。 UWA Pipeline主要包括： 专属的DevOps研发交付流水线 私有云真机远程调试 可视化配制自动测试 Pipeline内快速创建UWA服务 支持远程构建 五大功能，提供了专为游戏设计的DevOps研发交付流水线，且集成了各项UWA工具和服务，构建简单、操作便利。更多详细信息欢迎点击下文了解。 《速成，掌握UWA Pipeline 功能和使用方法》 《UWA Pipeline的使用与自动化流水线的搭建详解》 5.2 UWA GPM 2.0 UWA持续吸取各个开发团队对于性能监控方面的需求，发现游戏上线后玩家端的性能情况在很多时候具有无可替代的参考价值，协助开发者发现许多在研发迭代、Development包阶段难以发现的诸多性能问题，从而指导开发团队后续的性能优化方向。 GPM 2.0是一款基于UWA Pipeline、面向游戏开发团队的游戏运行性能监测平台，旨在帮助游戏开发团队监控和分析游戏项目上线后玩家真实的性能运行情况，及时发现并解决游戏性能问题，进而提升玩家游戏体验。 主要提供： 游戏宏观性能指标的分钟级实时监控 根据游戏版本、游戏场景、硬件设备分级的数据聚合分析与对比 可自由配置的性能异常场景次数统计 具体到玩家的深度性能数据分析 游戏运行的硬件信息统计 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。