UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - 厚积薄发
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学堂进行阅读。
1）持续性字符串拼接：每帧5KB从何而来？ 2）碎片占比超50%：Mono内存碎片化怎么避免？ 这是第475篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q1：GOT Online Mono模式报告显示，我们项目Mono堆内存中存在持续的字符串拼接现象，这是什么原因导致的，该怎么定位和优化？ A：看到在UIPanel.LateUpdate中全程出现持续的String.Concat的分配，出现这种现象常见的原因包括：UI元素的name赋值；Panel取名等逻辑里存在字符串拼接；业务打点日志中频繁做字符串拼接；Shader查找流程中存在名称拼接操作等。优化原则是避免每帧进行字符串拼接操作，将拼接结果缓存复用，减少Mono堆内存临时分配，字符串拼接逻辑本身也不应放在游戏核心循环的热路径中。 从当前报告数据来看，仅仅是持续字符串拼接每帧新增约5KB临时垃圾分配，再加上NGUI中一些其它分配，累计每万帧分配量超90MB。UWA推荐Mono报告中Top10函数平均每10k帧分配堆内存总和小于50MB，目前仅一个UIPanel.LateUpdate就已超出推荐值范围，因此要对以UIPanel.LateUpdate为首的多个分配较高的函数进行排查。 排查方法是在Mono模式报告中，按"分配视角"查看函数堆栈，定位累积分配最多的叶子函数，追溯其调用路径。另外还可以利用报告里的倒序调用分析模块，它能直接定位高分配的底层问题函数，帮我们省去逐层排查的繁琐操作，例如上述的String.Concat问题就能直接定位到。 Q2：另外我们还在报告中发现Mono堆内存出现了一个52MB的大数组，但查不到具体来源，该怎么排查？ A：我们可以从这份Mono模式报告看到，这个byte数组从测试开始就存在，但无法获取其分配堆栈。这是因为在UWA性能采集启动前（游戏初始化阶段），该数组就已经完成分配了，所以追踪不到来源。 需要我们采集数据的时候，在SDK面板上点击Direct Mode进行采集。点击Direct Mode后会在开始采集时杀掉进程并重新启动游戏，从游戏启动开始，Mono分配的整个生命周期就会被采集，这样就可以追踪到之前无法获取堆栈的大数组来源了。 这类大数组常见来源多为大型配置表、场景标记数据等。 若确认是配置表导致，可优化两点： 将配置表数据下沉至Native层存储，不占用Mono堆内存，规避GC压力与回收耗时； 改用更紧凑的数据结构，例如用bit替代int存储标记信息，进一步缩减内存占用。 实战案例 Q：Mono内存碎片化比较严重，怎么避免，有什么优化建议？ A：从报告来看，碎片占比高达112MB/219MB（约51%），在堆内存分配释放较为频繁的项目中，UWA也常观察到对象实际占用的部分和碎片会逐渐趋于相等。Unity的Boehm GC为非压缩回收器，不会主动整理Mono堆碎片，频繁产生临时内存分配，是造成碎片化的主要原因；即便GC回收空闲内存，已形成的碎片也无法合并成连续大块，只能被小对象复用，因此要尽量减缓碎片占比逐渐上升的趋势。 我们可以从以下三个方面进行优化： 第一，减少临时分配，避免在热路径上进行字符串拼接、容器扩容等操作； 第二，对频繁创建销毁的对象使用对象池复用； 第三，对可预期的容器预分配足够容量，避免运行时扩容。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 射线检测接口 游戏业务常见的需求就是做各种射线检测/Overlap检测/Sweep检测，比如Character Movement Component。 下面例子是使用Visibility Channel做Line Trace，StaticMesh对其是Block的。 各种Trace，最终都调用到SceneTrace。 使用的TraceChannel，ObjectType等条件，最终都编码成四个4B的Word，下面是TraceChannel的例子。 物理查询，主要就是对AABB Tree的查询。 SceneTrace ReadLock 要对物理场景做查询，首先加读锁。可以和其他Trace请求同时执行，但和写物理场景操作互斥。 然后获取加速结构Collection，里面有Static AABBTree和Dynamic AABBTree。 最后进到TSpatialAccelerationCollection::RaycastFast。 这里Types就是Buckets，TypeIdx是从0开始的下标，会逐个遍历Buckets，然后对里面的每个AABBTree，调用RaycastFast函数。 注意第一个Bucket存了非常大的AABB，也就是说如果场景内有很多大的物体，是会拖慢射线检测效率的。 TAABBTree::QueryImpl 比较大的函数，做AABBTree的遍历。 AABBTree非叶节点的遍历 经典的BVH检测方式，先检查射线和左右子树AABB是否相交，然后根据相交情况，不断对左右子树做遍历。加速方式为SIMD的应用，以及非递归的遍历AABBTree。 示意图如下： SIMD版本的Slab算法值得一看，判断射线与AABB是否相交，并计算射线在AABB内经过的距离。全程无if...else...。 里面的VectorMax和VectorSwizzle的组合比较巧妙，用于在三个数中取最大的。 Slab算法简介 Slab算法（Slab Method）是计算机图形学和物理引擎中用于检测射线（Ray）与轴对齐包围盒（AABB）是否相交的最经典、最高效的算法。 它的核心思想非常简单直观：将3D的AABB盒子看作是三组无限大的“平板对”（Slabs）的交集。 假设射线的起点为P，方向为Dir，把射线写成时间t的方程： 射线进入每个“平板对”，都会有进入时间t0和离开时间t1，如果要与AABB发生重叠，三组t0和t1必定有都重叠的部分。 示意图如下： 处理叶节点 对于一个Leave节点，射线检测会执行RaycastFastSimd。 每个Leave节点默认最多有8个Element，它们没有位置关系，因此要逐个对这些Element做射线检测了。 对于Leave中的一个Element，计算和射线是否相交，会经过三道判断。第一道是检查Shape的AABB和射线是否相交，第二道是检查Shape的Channel和QueryChannel是否是Overlap或者Block关系，第三道是真正检查射线和Sphere、Box、凸包等几何体是否相交。前两道是轻量的，算BroadPhase，第三道很重，是NarrowPhase。 把Channel Response检查放到中间也是挺有意思的。按照正常人类思考逻辑，应该是射线碰到一个Geometry后，再判断这个Geometry对射线Channel响应是Block还是Overlap，但判断射线和Shape相交的操作比较重，尤其是面对凸包。因此从代码效率逻辑出发，就变成了不管Shape和射线是否真的相交，先判断Geometry对Channel的响应，如果是Ignore，就直接不管了。具体做法如下： 第一道，射线和AABB Bounds检查是否相交。 同样的，用SIMD判断射线和AABB是否相交，不再赘述。 第二道，射线检测用的Channel和Geometry的ResponseChannel & BlockChannel是否有重叠。 ResponseChannel & BlockChannel是两个int32，本身是每个Geometry的属性，但AABBTree为了效率，在Payload里都缓存了一份，为UnionQueryFilterData和UnionSimFilterData，CachedUniqueIdx则是所属UObject的UniqueIdx缓存。 具体的Channel Response过滤方法。 PrePreQueryFilter 首先从Word0中获取射线检测使用的Channel，然后检查Geometry自身的Word1和Word2，分别是BlockingChannel和OverlapChannel，做bit and操作，看是否有交集。 注意这里的函数名是PrePreQueryFilter，说明后面还有PreQueryFilter流程。 射线和各种几何体相交判断 第三道，真正的射线和几何体相交检测。 PreQueryFilter 几何检测前，先执行一些自定义逻辑的Filter做过滤，判断是否要做对Shape的射线检测，可以做游戏业务相关的操作。注意到例子中的射线检测是按照LineTrace BlockChannel做的，但目前OverlapChannel的Shape也还没被排除，排除操作就是在PreQueryFilter阶段做的，实际会调用FCollisionQueryFilterCallback::PreFilterBaseImp函数。 首先对比查询的Channel，和Shape的OverlapChannel & BlockingChannel，判断这次查询是Touch还是Block，称为CollisionQueryHitType。 如果是LineTraceSingle，那么只关心BlockingChannel，TouchChannel的Shape可以忽略。 查询的Touch/Block和Shape的ResponseChannel比对通过后，再判断IgnoreActors和IgnoreComponents是否需要过滤，LineTrace时通常会设置IgnoreActors。 FCollisionQueryFilterCallback::PreFilter检查通过后，正式进入射线和几何体相交计算。 世界空间坐标和Geometry局部空间坐标 此时射线的起点和方向都是世界坐标，在检测前先变换到Geometry的坐标空间，后面计算起来会更加方便。比如对Box检测时，直接用AABB射线检测算法即可。 射线和不同几何体相交计算 这里不仅要判断是否相交，还要得到射线接触的位置，初次接触几何体的距离，以及接触点的法线。每个几何体都实现了Raycast函数，观察不同几何体的实现。 TSphere 球形判断是最简单的。 令直线起点为P，方向为dir，直线方程为： 设圆心为C，半径为R，直线和圆相交，需要满足以下方程： 这是关于t的一元二次方程，用公式求出t即可。 有了时间t，就能快速求出交点坐标和交点法线了。 Tbox 最终执行的AABB，计算开销比球体高些。 对于三组Slab，都分别计算进出时间t0、t1，然后取交集，得到时间t，再得到交点。法线总是XYZ三个方向，计算t0属于哪个面即可。 FCapsule Capsule可以想象成，到线段最短距离为r的点，形成的距离场，线段作为Capsule的轴心。 那么判断射线和胶囊体是否相交，计算这个轴心线段到射线的最短距离即可，具体计算上，需要找出一对距离最近的点。 真正困难的地方在于，线段上的最近点，位于线段中间。直接贴一个已有算法，核心是找出两个平面的相交线，理解起来需要点时间，但计算步骤并不多。 参考： https://johnmayhk.wordpress.com/2012/02/02/shortest-distance-between-two-skew-lines/ 如上所示，最短距离就是BD在MN上投影的长度，而MN同时垂直AB和CD，另外ABXCD得到的向量也同时垂直AB和CD，因此MN与（ABXCD）平行。由此可得到最短距离。 有了距离，就能解出两个点了。 UE也实现了这个算法，封装于SegmentDistToSegmentSafe函数，主要步骤如下： 当射线与Capsule相交后，还需要进一步求交点和normal。 分两种情况： 与上下两个半球相交，normal从球心出发 与中间圆柱体相交，计算圆柱体的normal FConvex凸包 最复杂，使用了GJK算法，不再展开 和Shape发生碰撞后怎么办？ 当前的Hit类型是ChaosInterface::FLocationHit，先填充Hit的这几个属性： 之后再给个机会执行FCollisionQueryFilterCallback::PostFilter，如处理bDiscardInitialOverlaps过滤等。 然后把Time作为Distance记下，表示射线已经打了多远，对后续Shape做射线检测时，将从最新的Distance开始，继续往后打射线。 回到AABB子树的遍历，如果要判断多个子树，但射线在之前已经Block了，就不用判断之后的子树了。 FHitResult的填充 FHitResult展开后有如下属性，其中大部分在之前射线发生碰撞时已经可以得到，只有黄线画出的几个需要看下如何获取。 PhysMat 命中点的物理材质，物理材质可以理解为类似渲染用的材质，只不过描述的是物体表面的摩擦力、密度等属性。当前已经有了FaceIndex，而Geometry中记录了FaceIndex和PhysicsMaterial的对应关系，因此可以得到PhysicsMaterial。 BoneName 命中点所属骨骼的名称。 对于示例中的StaticMesh，是没有骨骼的，因此是None，只有对于SkeletalMesh，这个BoneName才有意义。 观察SkeletalMesh的PhysicsAsset，多个骨骼上会挂接RigidBody，它们有各自对应的BodySetup，BoneName就存在BodySetup里。射线检测时，可以根据命中的GeometryParticle获取到BodyInstance，再获取到BodySetup，最终得到BoneName。 HitItem & ElementIndex 都属于一些Extra数据，不太重要。 HitItem是BodyInstance在整个PhysicsAsset中的序号，针对SkeletalMesh的情况。ElementIndex是Trace中Shape在Geometry的Shape数组中下标。对于示例中的StaticMeshComponent，它们都是0。 完整的LineTrace流程如下： Overlap检测 第二种碰撞检测是Overlap检测，比LineTrace更复杂些，会判断一个体积内有没有和其他物体相交。这里以Box碰撞体为例，需要传入Box的位置和大小，区别于之前的LineTrace使用TraceChannel，这里使用了ObjectTypes，查询Box范围内重叠的其他Actor。但对于物理引擎而言，TraceChannel和ObjectTypes本质都是一样的，都是CollisionChannel。 首先，把查询的Geometry计算一个AABB包围盒，传入AABBTree做BroadPhase的检测。 AABBTree的左右子树Overlap 对左右子树分别做AABB的碰撞检测。 Leaf节点的处理 TAABBTreeLeafArray.OverlapFast 对Leaf中每个Payload Geometry，同样先做AABB碰撞检测。用了SIMD技术，四个Payload为一组。当AABB检测通过后，依然先执行PrePreFilter，做Channel之类的判断，再针对具体的Geometry Shape形状，做Narrow Phase的Overlap检测。 Shape可以有Sphere、Box、Capsule、凸包等各种几何形状，但它们都属于凸包。要判断两个凸包是否Overlap，就要用GJK算法，UE的实现是GJKIntersection函数，自然也是SIMD实现。 GJK算法 关于GJK算法的详细介绍，可以看这里： 看似简单的复杂问题，奇怪而优雅的解决方式（GJK算法） 总体过程是不断通过Support函数，在两个凸包上找一对点，计算闵可夫斯基差，然后让形成的三角形包含原点。GJK算法有点复杂，不展开了，但不同Shape的Support函数实现可以看下，具体就是每个Shape的SupportCore函数。 所谓Support函数，就是给定一个方向，找到Shape在这个方向上最远的一个点，下图就是两个例子。 简单起见只看SupportCore普通版本，SIMD版本思想类似。 TBox 等价于对一个AABB求Support点。不难理解，AABB在某个方向上的最远点必定是8个顶点之一，因此只要依次判断三个轴，做三次方向二分即可，2^3 = 8。 TSphere 最简单，球的中心加上半径*方向即可。 FCapsule 看似复杂，实则和Sphere一样简单。Capsule的Support点必定出现在两个半球上，因此首先根据Direction方向确定是哪个半球，然后类似Sphere处理即可。 FConvex 需要遍历所有凸包的顶点，与方向做点乘，找到最远的点。 这里实现也比较有意思，寻找凸包的Support点，理论上用启发式的“爬山法”更快，即从上个Support点往后找邻接点，不用遍历所有顶点，而且UE是保存了边的连接关系的： 那为什么不用爬山法？个人猜测是以下几个原因： 直接遍历Vertices数组，CPU Cache更友好，在顶点数量不多（也是大部分情况）情况下可能更快。 爬山法需要记录上一次的Support点序号，但碰撞检测可能多个线程并行，这样就需要为每个线程都记录一份上次的Support点了，实现有点麻烦，而且得保证线程安全。 如果要得到Overlap深度，就要使用GJKPenetration算法， 结合了GJK与EPA，这里不展开了。 Sweep检测 最复杂的情况，以Box Sweep为示例，会得到Sweep路径上发生重叠的物体。 BroadPhase 类似Overlap，但把Sweep经过的整个路径，作为AABB，查询与左右子树的碰撞情况。 NarrowPhase 对于Leaf节点中的每个Geometry，同样经过PrePreFilter，AABB检测，PreFilter。 对Geometry中每个Shape的Sweep检测，最后依然使用GJK算法。 如果是TriangleMesh复杂碰撞怎么办？ 首先，Shape就变成了FTriangleMeshImplicitObject，类似Mesh，没有凸包的限制，可以表示任何形状。因此，类似GJK这种精妙的几何碰撞算法都不再适用，只能回归最原始的三角形相交判断。 Raycast & Overlap & Sweep BroadPhase相同，使用AABBTree。 NarrowPhase不使用GJK算法了，而是每个TriangleMesh自己有一个BVH树，用BVH的碰撞检测方式。BVH子节点存储了三角形，在检测子节点内部碰撞时，采样逐三角形检测方式。 比如这个例子，BVH树有379个节点，模型总共有5760个面，还是非常复杂的。 Tips 综合上述碰撞检测流程，可以对物理场景优化做一些指导： 游戏中物体都设置专门的Simple Collision，避免查询复杂碰撞 Shape的碰撞检测效率排序：Sphere > Capsule > Box >> Convex >>>> Triangle Mesh，尽量用效率高的Shape做近似 避免太长的LineTrace，以及范围太大的Overlap和Sweep LineTrace查询的开销最低，玩法上尽量用它 这是侑虎科技第1977篇文章，感谢作者南京周润发供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/xu-chen-71-65 再次感谢南京周润发的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
1）TMP字体Atlas内存优化实战 2）TMP内嵌图集纹理压缩改造 这是第474篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。在某个项目上经过以下两个方法优化后，能将字体内存从64MB下降至5MB以内，效果显著，非常值得其他团队参考借鉴。 实战案例 Q：GOT Online报告中显示，我们项目游戏字体Atlas出现了双实例冗余且开启RW动态图集内存占用过高的情况，是什么原因？该如何优化解决？ A：字体Atlas实例数量为2，通常是由于该字体既在初始包中的初始场景中被引用，又在后续AssetBundle资源中重复引用，导致有2个对应的Atlas实例。建议将初始场景中使用的字符单独创建对应的小字体，从而避免初始场景中有大图集和大字体的引用。 当前字体Atlas是开启了RW（Read/Write Enabled，开启此项会导致 CPU 和 GPU 各存一份，内存瞬间翻倍），属于动态图集机制，因此单个字体是2×4096×4096/1024/1024=32MB。开启RW主要为支持动态补字，实时将未预加载字符填充至图集中，避免游戏出现缺字乱码。从内存优化的角度上来看，可以预先收集游戏的大部分字符集，将Atlas设置为静态图集配置，这样就可以减少16MB的占用，对于不在静态图集中的字符，可以通过TMP的Fallback机制，设定一个小分辨率（比如512×512）的动态Atlas进行字符补充。 实战案例 Q：TMPAsset内嵌Atlas纹理无法压缩是什么原因？有什么脚本改造方案？ A：当前字体Atlas纹理属于TMPAsset内嵌子资源，无法单独进行纹理压缩等参数配置。可通过Editor编辑器脚本对该纹理进行独立复制，将TMPAsset材质球关联替换为复制后的新纹理，并移除原TMPAsset内的内嵌纹理。 剥离后的独立纹理可自由配置压缩格式，如ASTC6×6、ASTC8×8等，进一步降低内存开销。经真机测试，压缩后文字美术表现无明显差异。 Atlas优化前后内存占用对比： 优化前：64MB 优化后合计：4.75MB 初始场景 512×512静态图集：0.25MB 4096×4096静态图集（ASTC8×8）：4MB Fallback兜底纹理512×512（Alpha8）：0.5MB 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
Shader内存为何居高不下？变体管理是关键
1）Shader内存为何居高不下？变体管理是关键 2）字体资源冗余的常见原因与定位方法 这是第473篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们项目在使用UWA GOT Online测试后，发现报告中显示Shader峰值在200MB，远超UWA推荐值（<60MB），且在不同场景下波动较大，这个现象合理吗，后续应该如何优化Shader？ A：从GOT Online报告可以看到，项目Shader内存峰值约200MB，且常驻Shader内存在不同场景下存在明显波动。单从Shader资源内存的走势曲线即可判断，当前Shader内存管理存在一定问题。 我们可以通过指定帧来具体定位一下Shader内存偏高的原因。选中指定帧按内存占用倒序排序，会发现大量单体Shader存在占用过高的情况。建议在移动端游戏项目中重点关注所有单体占用超过1MB的Shader，这类Shader基本都存在优化空间，例如Built-in管线的Standard、UPR管线中的UberPost、Lit等Shader。 另外由于Shader内存占用与变体数量存在极强的关联性，Shader内存本质上反映的是编译后文件的复杂程度，核心取决于两个关键因素：单个变体的计算量，以及变体总数，两者可近似视为乘积关系。 通常我们不会为了降低Shader内存而牺牲单个变体的计算复杂度 —— 这会直接影响画面表现，因此优化的核心切入点，应聚焦于控制变体数量。举例来说，一般URP项目中的UberPost Shader内存占用约21MB，此时它的对应变体数量在2000个左右。因此除非是项目中自己写的Shader比这种还要复杂的多，否则变体的内存和数量的比例大致就会维持在这样的数量级。 结合该规律可进一步推断：项目中各类高占用单体Shader，其内存偏高的核心原因，大多是大量冗余变体长期驻留。反观常规移动端项目，即便复用范围较广的Shader，实际运行中真正参与渲染的变体往往仅有数百个甚至更少，大量未启用的无效Shader变体长期驻留，持续造成内存冗余与资源浪费。当前报告中排名靠前的这些Shader推测都会有数千个变体进入内存，可以用UWA的在线AssetBundle检测工具进一步排查变体的数量和关键字组合情况。 对此，可借助OnProcessShader等回调节点定制逻辑，精准拦截并屏蔽渲染流程内无用的宏关键字组合及多余变体。由于变体规模会随关键字叠加呈指数级扩张，因此清理冗余关键字、精简无效组合后，Shader变体总量与内存开销均可大幅缩减，整体优化收益十分显著。 综上，再结合我们以往的项目优化经验，更推荐的做法是：在Shader效果稳定后进行统一管理，完成变体收集，将ShaderVariantCollection文件与Shader资源统一打包到同一AssetBundle，在游戏启动阶段集中加载、缓存与预热。该优化方案可有效控制Shader内存开销，使其维持在合理区间，最终在内存走势图上呈现平稳无波动的线性状态。 实战案例 Q：我们在UWA GOT Online报告中发现了字体资源存在冗余，数量峰值显示有多个相同名称的资源同时驻留在内存中，这是什么原因导致的？ A：资源重复冗余最常见的原因之一，是未按依赖关系正确打包。若同一字体资源被打入多个不同AssetBundle包，且多个AssetBundle包在运行期同时加载，就会造成该资源在内存中重复实例化，最终出现同名资源多份驻留的现象。该问题具备普遍性，并非仅存在于字体资源，贴图、模型、Shader等各类资源均会出现同类情况。后续若发现项目资源数量异常、内存峰值偏高，可优先排查是否存在因AssetBundle打包导致的冗余问题，这也是游戏开发中最典型、最常见的资源冗余类型之一。 此类问题可借助UWA的在线AssetBundle检测工具快速完成问题定位：通过解析完整资源依赖链路，精准筛选出被重复打包的资源，高效排查冗余根源。除此以外，不合理的资源加载与释放策略，也会引发资源冗余情况，需要结合项目资源生命周期管理逻辑，进行针对性排查与优化。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ HPWater BSDF - 稍微基于物理的水体散射模型 实时水体渲染BSDF模型，支持体积散射、薄层SSS和背光透射。 本模型的散射模拟 直射光5°恒定厚度 直射光15°恒定厚度 直射光30°恒定厚度 第一张（厚度近似）后三张（不同直射光角度） 海浪厚度近似（法线）这个近似并不准确，但将就着用 可以看到不同厚度下，薄层SSS和透射经过不同厚度介质时，前向散射的分散性导致的散射形状 不同光向下的厚度近似模拟 与GGX BRDF的类比 本模型的结构设计参考了经典的GGX微表面BRDF，两者都将光照分解为几个物理项的乘积： GGX镜面公式： 水体散射公式： 两者的共同点： 都用几何项G表示入射光的有效投影面积 都用菲涅尔项F/T处理界面的能量分配 都将复杂光照分解为可独立计算的物理项相乘 主要区别： GGX处理的是表面微观几何的反射 水体BSDF处理的是体积介质的散射和透射 水体需要区分反射方向（diffR）和透射方向（diffT） 输入参数 相比GGX BRDF（只需要roughness和fresnel0），水体BSDF需要额外的体积散射参数。 材质参数（BSDFData）： 体积散射参数（WaterLightLoopData）： 派生量： 消光系数：μt = μa + μs（absorptionColor + scatterColor） 散射反照率：ω = μs / μt（决定吸收/散射比例） 光学深度：γ = μt · d（决定介质的“不透明度”） 整体概述 水体的光照与普通材质不同。光进入水中后会被吸收和散射，部分光在水体内部多次弹射后从表面出射回到摄像机。这个过程涉及：入射折射、体积散射、出射透射三个阶段。 本模型将水体散射分解为两个输出通道（遵循HDRP的CBSDF结构）。 关于diffR/diffT的命名： 在HDRP的CBSDF中，R = Reflection（反射方向），T = Transmission（透射方向）： diffR：光从表面的“同一侧”出射（入射和出射在法线同侧） diffT：光从表面的“另一侧”穿过来（入射和出射在法线两侧） 对于水体： diffR（宏观体积散射）：光从正面入射 → 水下散射 → 正面出射。光路在法线同侧，属于“反射方向”。适用于深水区域，通过Ray Marching累加散射。 diffT（薄层散射）：光从背面/侧面穿过薄层出射。光路穿过表面，属于“透射方向”。包含： thinLayerSSS：近似深水散射在薄层区域的延续，侧面和背面可见 backlitTransmission：光从背面直接穿透薄水层，看向光源时产生辉光 三个组件通过入射几何项自然分离，避免能量重复计算。 // ============================================================================ // 水体BSDF模型概述 // ============================================================================ // // 【物理场景：圆锥形薄层 + 深水区域】 // // ☀ 光源 // | // 波峰（薄层） thickness ≈ 0 // /\ // / \ ← 薄层：透射率高，fallback到深水 // / \ // / \ thickness渐变 // /________\ ← 圆锥底部，thickness = 1（薄层与深水边界） // | | // | 深水区 | ← ray marching 区域（macroScattering） // |__________| // ↓ // 水底 // // 【散射模型分解】 // // 1. 宏观体积散射 (diffR)： // - 正面入射 → 水下 ray marching → 正面出射 // - 处理深水区域的体积散射 // // 2. 薄层散射 (diffT)： // - 薄层 SSS：近似深水散射在薄层区域的延续 // - 背光透射：光直接穿透薄层，强前向散射 // - 处理波峰、浪花等薄水区域 // // 【薄层与深水的过渡】 // // 薄层（thickness 小）→ 透射率高 → 直接使用深水散射颜色（S_volume） // 厚层（thickness 大）→ 透射率低 → 使用薄层 SSS（近似深水累积） // // 通过 sss_transmittance 自动混合两者 // // 【出射菲涅尔】 // // T_exit = 1 - F(NdotV) 在 PostEvaluateBSDF 末尾统一应用 // // ============================================================================ 渲染流程总结 Step 1：入射计算 计算三个入射几何项（G_entry、G_sss、G_backlit）和入射菲涅尔透射（T_entry），决定多少光进入水体、进入哪个散射路径。 Step 2：体积散射（diffR） 对深水区域进行少量Ray Marching，累加多次散射的光量，输出S_volume。 Step 3：薄层SSS（diffT的一部分） 对薄层区域计算散射光量S_sss，使用非线性光程修正处理不同厚度，并与深水散射混合。 Step 4：背光透射（diffT的一部分） 计算光从背面穿透薄层的透射光，使用极强前向相位函数。 Step 5：出射透射 所有散射光经过出射菲涅尔透射T_exit后到达摄像机。 最终输出： diffR = G_entry × T_entry × S_volume diffT = (G_sss × S_sss) + (G_backlit × T_backlit × P_backlit) output = (diffR + diffT) × T_exit Step 1：入射计算 1.1 菲涅尔透射（Fresnel Transmission） 光在界面上一部分被反射，剩余部分透射进入水中。透射比例由菲涅尔方程决定。 数学公式： 水的f₀ ≈ 0.02，即垂直入射时只有2%的光被反射。 代码实现： // HPWaterBSDFLibary.hlsl: Line 200 float3 T_entry = 1.0 - F_Schlick(bsdfData.fresnel0, clampedNdotL); 1.2 入射几何项（Incident Geometry Terms） 三个组件各自有独立的入射几何项，根据NdotL的正负自然分配能量，避免重复计算。 数学公式： 代码实现： // HPWaterBSDFLibary.hlsl float G_entry = clampedNdotL; // Line 204 float G_sss = 1.0 - G_entry; // Line 309 float G_backlit = saturate(-NdotL); // Line 375 分工表： 解释： 正面（NdotL>0）：大部分光穿过薄层进入深水，由diffR处理 侧面（NdotL≈0）：diffR无贡献，薄层散射thinLayerSSS主导 背面（NdotL<0）：光从背后入射，thinLayerSSS和backlitTransmission共同作用 Step 2：体积散射（Volume Scattering） 深水区域使用Ray Marching沿视线采样，累加每一步的散射光量。循环次数默认6次。 物理过程： 光进入水后沿视线传播 每走一小步，部分光被吸收、部分被散射 散射的光中，朝向摄像机的部分被累加 重复直到到达水底或视线结束 2.1 Beer-Lambert透射 光在介质中传播时按指数衰减。 公式： 代码实现： // HPWaterVolumetrics.hlsl: Line 202-205 float3 extinctionCoeff = absorptionCoeff + scatteringCoeff; transmittance = exp(-extinctionCoeff * crossDistance); 2.2 散射光计算 被消光的光中，一部分被吸收（变成热量），另一部分被散射（改变方向）。 公式： L：入射光 (1−T)： 被消光的光的比例（积分结果） μs / μt：散射反照率（被消光的光中有多少是散射而非吸收） P(θ)：相位函数（散射光中朝向摄像机的比例） 代码实现： // HPWaterVolumetrics.hlsl: Line 199-218 float3 CaculateScatteredLight( float3 originLight, // L: 入射光 float3 absorptionCoeff, // μa: 吸收系数 float3 scatteringCoeff, // μs: 散射系数 float crossDistance, // d: 光程 float3 phase, // P(θ): 相位函数 out float3 transmittance) { // μt = μa + μs float3 extinctionCoeff = absorptionCoeff + scatteringCoeff; // T = exp(-μt × d) transmittance = exp(-extinctionCoeff * crossDistance); // (1 - T): 被消光的光量 float3 extinguishedLight = originLight * (1.0 - transmittance); // μs / μt: 散射反照率 float3 scatteringAlbedo = scatteringCoeff * rcp(extinctionCoeff); // S = L × (1-T) × (μs/μt) × P(θ) float3 scatteredLight = extinguishedLight * scatteringAlbedo * phase; return scatteredLight; } 2.3 Henyey-Greenstein相位函数 相位函数描述光散射后的方向分布。水体主要是前向散射。 数学公式： g = 0.8：强前向散射（水体典型值） g = 0：各向同性 g < 0：后向散射 代码实现： // HPWaterVolumetrics.hlsl: Line 171-177 float HenyeyPhase(float cosTheta, float phaseG) { float g2 = phaseG * phaseG; float denom = 1.0 + g2 - 2.0 * phaseG * cosTheta; float mieScatter = (1.0 - g2) * rcp(pow(abs(denom), 1.5)); return mieScatter; } 2.4 混合相位函数（瑞利 + 米氏） 真实水体同时存在瑞利散射（分子级，波长相关，产生蓝色）和米氏散射（颗粒级，前向散射）。 数学公式： 代码实现： // HPWaterVolumetrics.hlsl: Line 187-196 float3 CaculateScatterPhase(float cosTheta, float phaseG) { // 瑞利散射（5%）：βR ∝ 1/λ⁴，短波长（蓝光）散射更强 static const float3 betaRayleigh = float3(5.8e-6, 13.5e-6, 33.1e-6); float rayleighPhase = (1.0 + cosTheta * cosTheta) * (3 / (16 * PI)); float3 rayleighScatter = betaRayleigh * rayleighPhase * 1e6; // 米氏散射（95%） float mieScatter = HenyeyPhase(cosTheta, phaseG); // 混合 float3 scatterPhase = rayleighScatter * 0.05 + mieScatter * 0.95; return scatterPhase; } 2.5 Ray Marching主循环 辐射传输方程的数值积分： 理论上，沿视线累加的散射光是一个积分： 其中S(x)是位置x处的散射光源项，T(x → eye)是从x到眼睛的透射率。 这个积分没有解析解，所以用Ray Marching做数值近似（黎曼求和）： 指数步进的数学推导： 近处对视觉贡献大（细节可见），远处贡献小（被衰减）。用指数步进可以在采样数相同时获得更好的精度。 设t∈[0,1] 是归一化采样索引（t=i/N），k=ln(EXP_FACTOR)： 当t=0时d=0，当t=1时d=0。步长为导数： 代码实现优化： 令currentExp=e^(k·t)=EXP_FACTOR^t，则： d=(currentExp-1)*kDenom dd=currentExp*kDD 每次迭代currentExp*=expStep（其中expStep=EXP_FACTOR^(1/N)） // HPWaterVolumetrics.hlsl: Line 294-345 // 指数步进参数预计算 float rcpCount = rcp(float(WATER_SAMPLE_COUNT)); // 1/N float kDenom = rcp(EXP_FACTOR - 1.0); // 1/(e^k - 1) float kDD = log(EXP_FACTOR) * rcpCount * kDenom; // k/(N·(e^k-1)) float expStep = pow(EXP_FACTOR, rcpCount); // e^(k/N) float currentExp = pow(EXP_FACTOR, Dither * rcpCount); // 抖动起始点 //WATER_SAMPLE_COUNT = 6 for (int i = 0; i < WATER_SAMPLE_COUNT; i++) { // d: 归一化采样位置 [0,1]，dd: 当前步的归一化步长 float d = (currentExp - 1.0) * kDenom; float dd = currentExp * kDD; float3 samplePos = RayStart + NoLinearRayDirection * d; float3 samplePosDynamic = RayStart + DynamicRayDirection * d; // 计算阴影 shadowValue = ComputeShadowValue(samplePosDynamic, featureFlags, lightLoopContext, posInput, bsdfData); if(i == 0) lastShadowValue = shadowValue; // ===== 一次散射：直接光照 ===== float cosTheta = dot(safeNormalize(samplePos), safeNormalize(LightDir)); float3 scatterPhase = CaculateScatterPhase(cosTheta, _PhaseG); float3 directLighting = LightColor * shadowValue; float directCrossDistance = dd * NoLinearRayLength + SunDepth * dd; // 先计算不带相位的基础散射（phase = 1） float3 baseScatter = CaculateScatteredLight( directLighting, AbsorptionCoefficient, ScatterCoefficient, directCrossDistance, 1, transmittance, scatteringAlbedo).rgb; // 多次散射效果：根据散射反照率决定相位的各向同性程度 // scatterAlbedo 高 -> 多次散射充分 -> 相位趋于各向同性 // scatterAlbedo 低 -> 单次散射主导 -> 保留方向性相位 float scatterAlbedo = Luminance(scatteringAlbedo) * GetInverseCurrentExposureMultiplier(); float3 effectivePhase = lerp(scatterPhase, 1.0, saturate(smoothstep(0, 0.5, scatterAlbedo))); float3 directScatteredLight = baseScatter * effectivePhase; // 累加 accumTransmittance *= transmittance; scatteredLight += directScatteredLight; currentExp *= expStep; } // ===== 水下场景散射（Scene In-Scattering）===== // 水下物体反射的光，穿过水体时也会被散射，产生「雾化」效果 float3 sceneLight = SceneColor * lerp(shadowValue, 1, 0.3); float lightIntensity = Luminance(LightColor); float3 sceneScatteredLight = sceneLight * accumTransmittance * ScatterCoefficient * NoLinearRayLength * lightIntensity; scatteredLight += sceneScatteredLight; 解释： EXP_FACTOR控制指数步进的疏密程度，近处采样密集以捕捉细节 Dither用于抖动起始点，减少带状Artifact effectivePhase在厚介质中趋于各向同性，模拟多次散射后方向性丢失 水下场景散射公式： 这是一个简化近似（避免对场景光再做完整Ray Marching）： Lscene：水下场景光（SceneColor，考虑30%阴影穿透） Taccum：累积透射率（场景光穿过水体的衰减） μs：散射系数（决定多少光被散射出来） d：光程长度（路径越长散射越多） Isun：光源亮度（调制系数，使场景散射与直接光散射亮度一致） 物理效果：让远处的水下物体看起来更模糊、更偏向水的散射色（雾化）。 Step 3：薄层SSS（Thin Layer Subsurface Scattering） 薄层（如波峰、浪花）厚度无法直接得知，只能用高度或法线近似。关键是正确估算光程，并处理与深水散射的过渡。 输入参数thickness： thickness是归一化的厚度值，范围 [0, 1]： 0=极薄（波峰顶部、浪花边缘） 1=最厚（薄层与深水区域的边界） 通常由高度场、法线或其他几何信息近似得到。代码中乘以HPWATER_SSS_PATH_SCALE（默认20米）转换为等效光程。 薄层与深水的几何关系： 波峰（薄层顶部）thickness ≈ 0 /\ / \ / \ ← 薄层区域 / \ /────────\ ← thickness = 1（薄层底部 = 深水顶部） │ │ │ 深水区 │ ← S_volume（ray marching 累积） │__________│ 薄层的底部与深水区域连接。当thickness接近1时，薄层实际上“看到”的是深水区域累积的散射光。因此： 薄层SSS不是独立计算，而是近似深水散射在薄层区域的延续 光程缩放（20米）较大，是因为要匹配深水Ray Marching的累积效果 Fallback机制：当thickness很小（透射率高）时，直接使用S_volume作为薄层的颜色 为什么相机与光源角度相近时，水面背面会稍亮？ 典型场景：夕阳下，人以较低视角看水面。此时相机方向V和光源方向L角度相近（都是低角度），水面波峰的背面会呈现微微透亮的效果。 这是相位函数、入射几何项和出射菲涅尔透射三者共同作用的结果： 1. 相位函数P(cosθ)的前向散射 cosθ=dot(-V, L)，V和L角度越近，cosθ越大 HG相位函数随cosθ增大而增大（前向散射特性） 散射光更多地朝向摄像机方向 2. 入射几何项G_sss在背面有值 背面入射时NdotL<0，导致G_entry=0 因此G_sss=1-G_entry=1，薄层SSS获得入射能量 正面入射时G_sss减小，薄层贡献减少 3. 出射菲涅尔透射fresnelTransmissionExit T_exit=1-F_Schlick(f0, NdotV) 法线朝向相机（NdotV大）→ T_exit大 → 压制小，光容易出射 法线不朝向相机（NdotV小）→ T_exit小 → 压制大，光难以出射 波峰背面的法线相对更朝向相机，所以T_exit较大，散射光能出射 三者的平衡： 夕阳低角度 + 人高视角看水面： 入射：G_sss ≈ 0.6~1（背面/侧面入射） ↓ 散射：P_sss 中等偏高（V、L 角度相近但非正对） ↓ 出射：T_exit ≈ 0.7~0.9（低视角有所衰减） 三者相乘 → 背面稍亮（自然的透光效果） 这就是为什么波峰薄处在这种视角下会呈现柔和的透光效果。 3.1 非线性光程修正（Nonlinear Path Length） 薄层SSS用单次计算近似深水散射的延续。光程需要随thickness增加而增长，以匹配深水Ray Marching的累积效果。 物理直觉： 薄层（d→0）：透射率高，fallback到S_volume，光程影响小 厚层（d→1）：需要更长光程来匹配深水累积散射的视觉效果 数学公式： 代码实现： // HPWaterBSDFLibary.hlsl: Line 264-280 // 线性部分：薄层区域 float L_linear = thickness * HPWATER_SSS_PATH_SCALE; // 非线性部分：厚层区域，d² 让光程在 thickness→1 时增长更快 float scatterStrength = Luminance(bsdfData.scatterColor); float L_nonlinear = thickness * thickness * HPWATER_SSS_PATH_SCALE * (1.0 + scatterStrength); // 光学深度决定混合比例，τ 大时使用非线性 float opticalDepth = sss_extinctionScalar * thickness * HPWATER_SSS_PATH_SCALE; float nonlinearWeight = saturate(opticalDepth * HPWATER_SSS_NONLINEAR_STRENGTH); // 有效光程 float sssPathLength = lerp(L_linear, L_nonlinear, nonlinearWeight); 3.2 薄层散射计算与深水混合 薄层SSS是深水散射在薄层区域的延续。根据透射率与深水散射混合：透射率高说明水很薄，应该直接看到深水的散射颜色。 数学公式： 代码实现： // HPWaterBSDFLibary.hlsl: Line 282-296 // P_sss：相位函数 float sss_cosTheta = dot(-V, L); float3 P_sss = CaculateScatterPhase(sss_cosTheta, _PhaseG); // S_sss：散射光计算 float3 sss_transmittance; float3 S_sss = WaterVolumeLightLoop::CaculateScatteredLight( LightColor, bsdfData.absorptionColor, bsdfData.scatterColor, sssPathLength, P_sss, sss_transmittance); // HPWaterBSDFLibary.hlsl: Line 309-329 // G_sss：薄层入射项 = 1 - 正面入射项 float G_sss = 1.0 - G_entry; // 薄层 SSS 输出 = 入射项 × 散射 × 阴影 × 补偿 float3 thinLayerSSS = S_sss * G_sss * lastShadowValue * HPWATER_SSS_SCATTER_BOOST; // -------------------------------- // 薄层与深水的混合（Fallback 机制） // -------------------------------- // // 薄层区域（thickness 小）： // └─ transmittance 高 → sssWeight 低 // └─ 直接使用深水散射颜色（S_volume） // // 厚层区域（thickness 大）： // └─ transmittance 低 → sssWeight 高 // └─ 使用薄层 SSS（近似深水累积散射的延续） // // 注意：两个分支都乘以 G_sss，保持入射分工一致 // float sssWeight = saturate(1.0 - Luminance(sss_transmittance)); thinLayerSSS = lerp(S_volume * G_sss, thinLayerSSS, sssWeight); 解释： G_sss=1-G_entry确保正面入射时薄层SSS为 0，能量由diffR处理 HPWATER_SSS_SCATTER_BOOST补偿单次计算与深水Ray Marching的亮度差异 Fallback机制让波峰顶部（极薄）直接看到深水散射颜色 两个分支都乘以G_sss，保证入射分工一致 Step 4：背光透射（Backlit Transmission） 当对准光源观察时，光从背后穿透薄水层产生辉光。这是对准太阳时水面发亮的原因。 与薄层SSS的区别： 薄层SSS：光散射后出射，方向较分散（g≈0.8） 背光透射：光几乎直穿，极强方向性（g≈0.998） 数学公式： 其中： Gbacklit=clamp(−N⋅L,0,1)：背面入射投影 Tbacklit=e^(−μt⋅d)：Beer-Lambert透射 Pbacklit=PHG(cosθ,0.998)：极强前向相位 代码实现： // HPWaterBSDFLibary.hlsl: Line 370-392 // G_backlit：背面入射投影 float G_backlit = saturate(-NdotL); // 背光专用光程（比 SSS 短，纯穿透路径） float backlitPathLength = thickness * HPWATER_BACKLIT_PATH_SCALE; // T_backlit：Beer-Lambert 透射 float3 T_backlit = exp(-sss_extinctionCoeff * backlitPathLength); // P_backlit：极强前向相位（g = 0.998） float backlit_cosTheta = dot(V, -L); float P_backlit = HenyeyPhase(backlit_cosTheta, 0.998); // 输出 float3 backlitTransmission = LightColor * G_backlit * T_backlit * P_backlit * lastShadowValue; 解释： G_backlit只在背面（NdotL<0）有值 backlit_cosTheta=dot(V, -L)是摄像机看向光源穿透方向的夹角 g=0.998意味着只有几乎正对光源时才能看到背光透射 HPWATER_BACKLIT_PATH_SCALE比SSS的scale小，因为背光是纯穿透 Step 5：出射透射（Exit Transmission） 散射光从水内出射时，再次经过菲涅尔透射。 数学公式： 代码实现： // HPWaterBSDFLibary.hlsl: Line 98 (PostEvaluateBSDF) float3 fresnelTransmissionExit = 1.0 - F_Schlick(bsdfData.fresnel0, clampedNdotV); lightLoopOutput.diffuseLighting *= fresnelTransmissionExit; 最终输出组合 数学公式： 代码实现： // HPWaterBSDFLibary.hlsl: Line 207, 220 float3 underwaterLight = G_entry * T_entry; float3 macroScattering = S_volume * underwaterLight; // HPWaterBSDFLibary.hlsl: Line 312 float3 thinLayerSSS = S_sss * G_sss * lastShadowValue * HPWATER_SSS_SCATTER_BOOST; // HPWaterBSDFLibary.hlsl: Line 392 float3 backlitTransmission = LightColor * G_backlit * T_backlit * P_backlit * lastShadowValue; // HPWaterBSDFLibary.hlsl: Line 440-441 cbsdf.diffR = macroScattering; cbsdf.diffT = thinLayerSSS + backlitTransmission; 参数说明 薄层SSS参数 背光透射参数 体积散射参数 视觉效果分区 物理参考 Beer-Lambert定律：光在介质中的指数衰减 Henyey-Greenstein相位函数：描述散射的角度分布 菲涅尔方程：界面反射/透射比例 辐射传输方程（RTE）：体积散射的理论基础 这是侑虎科技第1971篇文章，感谢作者hulalalala供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/ou-yang-xuan-58-47 再次感谢hulalalala的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
1）GPU Clocks正常但带宽高怎么办 2）FixedUpdate一帧调两次，这个"追帧"真的有必要吗 这是第472篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：在我们的项目中，用UWA GOT Online测了一个新坐骑场景用例的报告，单看GPU Clocks数据信息，表现还可以，基本没碰红线，按理说不太像是典型的GPU算力瓶颈。但问题是，带宽这边还是有点高。像这种情况，后面到底该先排查什么？是继续盯Fragment、三角形这些指标，还是该往别的方向看？ A：这个问题其实挺典型的，一般都会下意识觉得：GPU指标有波动，那是不是面数高了、Shader重了、像素太多了。就比如说Clocks，它是像素数量乘以Shader复杂度，只看像素数量，其实不一定能反映Clocks是特别高还是特别低，如果Clocks本身已经没超，那说明至少从“GPU算力被打满”这个角度看，当前这个场景在这档设备上问题不算大，Fragment Shader影响就比较小了，可以先不用关注。真往下查，还是要回到带宽到底是被谁拉高了的角度去查。 带宽压力一般会去从下面这两个角度进行考虑，一个是纹理，另一个是RT（顶点也可能有一定影响，不过当前报告中顶点的影响比较小）。 1. 纹理方面： 第一个纹理，一般是考虑纹理的压缩，比如说是ASTC 4×4、6×6或者8×8，原理就是你压得越厉害，带宽就肯定更小。 再一个要考虑3D纹理是不是开了Mipmap？假设用来做3D渲染的，如果不开，影响会比较大。而开启Mipmap的影响会比压缩大很多，因为不开Mipmap，它很容易Cache Miss，而打开，它就有缓存，可以从很大程度上减少带宽的开销。 还有一个就是各向异性。各向异性只会在那种视角很小的情况下，比如一个很长的地形，镜头拉得很远，看到比较远的地方会有一些毛刺，开各向异性才比较有收益。大部分情况下开各向异性其实没有什么好处，它只会增加带宽。 2. RT方面： 对于RT来说，影响带宽的主要因素是分辨率。RT分辨率越大，Fragment Shader数量就越多，每多画一个像素就要做纹理读取、Shader计算、寄存器存取等步骤，带宽就会越多。 再有，RT分辨率还会影响Load/Store次数。分辨率越大，要Load和Store的像素内容就会越多。如果能把分辨率降一下，带宽收益会非常高，顺带着Clocks也会更低。 再回到我们GOT Online的报告上来，从报告来看，分辨率影响最大。因为看起来RT的格式是比较正常的，穿插的也不是特别多，看上去只有一个Bloom的后期处理。所以还是分辨率的影响最大，如果用这个原始分辨率去做渲染，确实渲染的像素数量会非常多，切RT的时候，或者说Load/Store的时候也会有比较大的影响。 UWA以往优化的常规做法是把RT分辨率控制在1920×1080这个面积，约200万像素，而格式选32位而非64位。 实战案例 Q1：项目是帧同步，主要逻辑放在FixedUpdate里。UWA GOT Online报告显示，有些帧会出现FixedUpdate调用两次的情况，而且一旦跑两次，耗时明显就上去了。这个“两次调用”到底有没有必要？ A：一般追帧是为了保证物理模拟的准确性，比如子弹速度很快，如果不追帧，可能中间穿过去的墙就挡不住它了。但像这个项目的情况，应该不太会需要非常高速的移动或高速判定，所以理论上来说，触发两次是没有太多意义的，就需要做一下限制，可以在参数上做调整，就比如说Time里面的两个参数，一个是每一帧的上限，还有一个是每间隔多少时间触发一次，我们可以把每一帧的上限，调得更小一点，或者说把间隔调得更大一点。 Q2：但项目中是逻辑帧，间隔调得更大点可能就不合适了。 A：如果参数不好调，可以在代码里加一个硬核的限制。比如给它一个变量，判断这一帧有没有更新过，然后把更新过的变量变成True，第二次调用的时候因为变量是True就Return。这样不论上限设多少次，对这个游戏来说，FixedUpdate最多只调一次。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
1）Spine动画更新耗时问题治理 2）项目卡顿与GC问题优化方案 这是第471篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q1：我们是一款三消游戏，进到游戏场景后SkeletonGraphic.Update和SkeletonGraphic.LateUpdate的耗时比较高，该怎么优化？ A：从GOT Online的报告中可以看到，进入游戏场景后Update和LateUpdate的调用次数达到50个以上，它其实反映了场景中激活状态的Spine组件数量，首先我们要定位下具体的来源，检查有没有预期之外的。 经讨论后确认，这些格子里的小图标特效是Spine做的，像这种变化幅度不是特别大的、没有像骨骼比较精细的动画，从我们经验上看确实没有太大必要用Spine来做，这种小的简单动画可以尝试改成其它动画方案实现。 另外了解到画面上方的一些角色也都是Spine做的，但是它们在屏幕外时没有做相关处理，仍然在更新。这种情况可以利用Graphic组件上的Freeze功能，也可以通过代码进行控制，当这个对象在视域体外的时候就把它冻结，这样它就会停止Spine相关逻辑的更新，等到快要回到视域体中时再把Freeze状态解除。 Q2：那如果我在这些角色滑出屏幕后直接disable隐藏掉，和这个Freeze效果是一样的吗？ A：理论上是一样的，但还是更推荐Freeze，因为SkeletonGraphic也是属于UI组件之一，然后看画面本身也是比较复杂的UI元素，如果直接调用激活隐藏，可能对UI的性能会有一些负面的影响，触发网格重建导致额外的开销。 实战案例 Q：我们目前比较关心项目的卡顿和GC相关的情况，想问下该怎么优化？ A：从GOT Online的性能测评报告来看，主要有两方面的问题。 首先是GarbageCollectAssetsProfile这个节点，也就是在调用Resource.UnloadUnusedAssets时触发垃圾回收的开销，在这个类似主城的场景里它的调用频率非常高，每隔几百帧就会触发一次，这是不太合理的。Unity中本身有一个机制，在LoadScene的时候会自动调用Asset垃圾回收，所以一般不需要手动去触发。不过也有一些像这种没有去做LoadScene切场景，不会有自动的调用，有的项目会选择用定时器的方式去手动调用，一般控制在5~10分钟调一次，还有的项目会放在触发内存报警Low Memory的时候主动调用，或者还有在场景切换前后这种明确的节点，主动调用。其他时候尽量还是要避免频繁调用，不然会造成非常多的卡顿。 另外还看到在三消的场景中，GC.Collect的调用频率比较高，从Mono报告能看到有个函数会以非常高的频率进行分配，且每次分配1MB以上的堆内存，主要是Json相关的数据写入序列化等。 总之从这个表现来看还是分配的频率太高，而导致GC的频率也比较高，建议排查一下这种操作的单次分配量和频率能否降低一些。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
系统层显存 vs 引擎层显存的差值从哪来
1）系统层显存 vs 引擎层显存的差值从哪来 2）如何定位String的异常增长 这是第470篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们项目中通过系统层统计到的显存有1.5GB，但是通过引擎层采集到的纹理、网格、RT这些加起来也只有400+MB，为什么会这样，其它的部分是什么呢？ A：这个其实是一种比较少见的情况，根据以往的经验来说可能有以下几种可能性： Shader占用本身是属于引擎层Native部分的，在运行中会执行Shader.CreateGPUProgram把它编译成GPU可以理解使用的一个Program，生成的这个GPU Program在一些项目中可能造成相当可观的高占用，我们在过往部分极端测试中也发现，它会同时导致系统层Native内存与GFX显存出现显著上升。 测试发现一些RT除了在Memory Profiler中统计到的部分外，也会对显存造成一些额外的开销。 在其它项目中还见过使用Compute Buffer，或者在引擎设置里面开辟一些额外的Buffer，通过C++进行加载的，也会对显存造成额外开销。 这些内存的具体来源是什么无法直接通过Unity引擎的分析工具进行分类和定位，建议可以做一些对比测试，比如去拆分一些渲染策略或场景，如果观察到开启某个渲染策略、进入某个渲染场景时显存出现明显的飙升，那么基本就能做更精准的定位了。 参考文章：《Unity移动游戏工业级性能优化指南 3.0》 实战案例 Q：我用Memory Profiler看到托管堆内存部分中String驻留了50+MB、45万多条，这个现象是正常的吗？ A：这一部分占用还是比较高的，从下图右边的Details信息看很多来自TMP图集用的文本，这种大量字符串理论上不应该驻留在运行时了，我们生成了TMP的图集纹理后像这个文本应该就不需要了，可以看看是不是可以及时卸载。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 物理系统与物理世界 物理系统是每个游戏引擎必备的功能，可以让游戏模拟真实世界的物理规则。而现代游戏引擎中的“物理”，应该称为刚体动力学。刚体（Rigidbody）是理想化、无限坚硬、不变形的固体物理。动力学（Dynamics）是一个过程，计算刚体怎样在力（Force）的影响下随时间移动及相互作用。 在游戏世界背后，有一个镜像的物理世界，其中不关心Actor的视觉表现，只关注Actor的形状、物理材质等信息。简单使用场景下，只要把所有Actor当成刚体处理即可。 以常见的StaticMeshActor为例。下图左侧是Editor窗口，右侧是Chaos Visual Debugger看到的物理世界。 UE5的物理引擎已经从PhysX改成了Chaos，可能物理查询速度会慢些，但更适合做物理破坏效果，而且数据结构和游戏引擎统一，不需要再做转换。 物理世界类 UWorld是代表由Actor构成的整个游戏世界，它持有一个FPhysScene来代表物理世界，可以类比为渲染中的FScene，物理世界的步进（Advance）初始也由UWorld::Tick()发起。UWorld与物理相关的功能一般在PhysLevel.cpp中实现。FPhysScene等价于FPhysScene_Chaos，继承自FChaosScene，是Chaos的首要入口。碰撞事件的注册分发，物理网络同步相关的内容也由其处理，物理模拟的步进也在该类开始（StartFrame）。 示例：创建一个StaticMeshComponent StaticMesh的物理配置 编辑器中，可以给StaticMesh设置碰撞，这里直接使用和模型大小一样的Box碰撞即可。还有其他很多选项，如Sphere、Capsule、凸包等，面对复杂模型时会用多种基础形状拼出一个碰撞。 UBodySetup 这些碰撞配置，最终会存储到UBodySetup类里，作为资源的一部分，它会是StaticMesh的一个配置项。对于例子中添加的简单几何体碰撞，存储在其AggGeom变量中。 class UBodySetup : public UBodySetupCore { //... /** Simplified collision representation of this */ UPROPERTY(EditAnywhere, Category = BodySetup, meta=(DisplayName = "Primitives", NoResetToDefault)) struct FKAggregateGeom AggGeom; //... }; AggGeom里是一大串几何体的数据，Box就位于BoxElems数组里。 struct FKAggregateGeom { GENERATED_USTRUCT_BODY() UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Spheres", TitleProperty = "Name")) TArray<FKSphereElem> SphereElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Boxes", TitleProperty = "Name")) TArray<FKBoxElem> BoxElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Capsules", TitleProperty = "Name")) TArray<FKSphylElem> SphylElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Convex Elements", TitleProperty = "Name")) TArray<FKConvexElem> ConvexElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Tapered Capsules", TitleProperty = "Name")) TArray<FKTaperedCapsuleElem> TaperedCapsuleElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Level Sets", TitleProperty = "Name")) TArray<FKLevelSetElem> LevelSetElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "(Experimental) Skinned Level Sets", TitleProperty = "Name"), Experimental) TArray<FKSkinnedLevelSetElem> SkinnedLevelSetElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "(Experimental) ML Level Sets", TitleProperty = "Name"), Experimental) TArray<FKMLLevelSetElem> MLLevelSetElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "(Experimental) Skinned Triangle Meshes", TitleProperty = "Name"), Experimental) TArray<FKSkinnedTriangleMeshElem> SkinnedTriangleMeshElems; }; UPrimitiveComponent UPrimitiveComponent为所有持有几何物体的基类，StaticMeshComponent也是它的子类。会创建一个FBodyInstance作为其在物理世界中的代表。无论是UStaticMeshComponent还是USkeletalMeshComponent又或者UCapsuleComponent，实际最终都会通过FBodyInstance与物理世界进行交互。此外还有UGeometryCollectionComponent等用于特定物理功能相关的Component，它们会持有额外的物理代理。 FBodyInstance 一个物理对象的实例，在Gameplay部分的表示，用于设置物理相关的各项属性。引擎代码注释中对FBodyInstance的定义如下： Container for a physics representation of an object 一些属性举例： TWeakObjectPtr BodySetup：关联的BodySetup，BodyInstance本身并不包含具体的几何形状。 FBodyInstance* WeldParent：一个BodyInstance所连接的父Body。 FPhysicsActorHandle ActorHandle：实际是FSingleParticlePhysicsProxy*，为物理引擎内部BodyInstance的呈现与代理。 FName CollisionProfileName：碰撞的ProfileName。 至此，StaticMeshComponent创建完毕，其包含了BodyInstance，并关联了BodySetup。 简单理解的话，StaticMeshComponent包含了Render和物理信息，而BodySetup+BodyInstance就表示物理信息。 物理对象与Shape 直观理解，物理世界就是大量的形状构成的。 物理世界如何表示"对象" Game世界使用Actor、Component等类型来表示一个对象，但物理世界只关注一个物体的几何形状、位置、以及质量等属性，它们构成了另一套PhysScene里的数据结构，以及PhysScene里的“对象”。这个对象称为GeometryParticle，Game世界通过FPhysicsActorHandle类型来索引它。 FPhysicsActorHandle using FPhysicsActorHandle = Chaos::FSingleParticlePhysicsProxy*; 看声明代码，等价于FSingleParticlePhysicsProxy*，既然叫XXProxy，那就是一个代理的作用。一个需要进行物理解算的对象在物理引擎内被称为一个Particle，是一个约定俗成的名字，该类作为其代理充当与物理交互的接口。物理引擎只关注一个物体的几何形状、位置、以及质量等属性，它们由TGeometryParticle持有。PhysicsActorHandle的TGeometryParticleHandle属性指向了GeometryParticle，实现关联。不仅如此，TGeometryParticle内部还有一个指向更底层物理SOA存储的FGeometryParticleHandle，暂时用不到，先不管。概念比较多，之后会做总结。 注意，目前都只涉及物理世界，但不涉及物理线程。游戏线程和物理线程都可以读写物理世界，但物理线程主要负责物理模拟。 除此之外还有描述Chaos破坏集合的FGeometryCollectionPhysicsProxy、描述关节约束的FJointConstraintPhysicsProxy等。 FSingleParticlePhysicsProxy的属性： TUniquePtr<PARTICLE_TYPE> Particle; FParticleHandle* Handle; FPhysicsObjectUniquePtr Reference; int32 GravityGroupIndex; TUniquePtr<FProxyInterpolationBase> InterpolationData; GetGameThreadAPI()是GameThread获取操作类的方法，直接cast即可： FORCEINLINE FRigidBodyHandle_External& GetGameThreadAPI() { return (FRigidBodyHandle_External&)*this; } UPrimitiveComponent::OnCreatePhysicsState 用于创建物理数据。Component在创建时会执行RegisterComponent函数，其中执行到OnCreatePhysicsState创建物理数据，OnCreatePhysicsState本身是虚函数，可以被PrimitiveComponent的不同子类重载。 重载了OnCreatePhysicsState的Component： 而其中主要逻辑都在FBodyInstance::InitBody函数里，最终执行到FInitBodiesHelperBase::InitBodies。 UPrimitiveComponent和FPhysicsActorHandle的映射 给UPrimitiveComponent创建完物理对象后，和PhysicsActorHandle的映射关系会存储在PhysScene里，用于后续查询。 值得注意的是，Component和PhysicsActorHandle是个一对多的关系，比如一个Component里面可以创建多个BodyInstance，自然就有了多个PhysicsActorHandle。 一个例子是SkeletalMeshComponent，会有多个刚体组成。 /** Array of FBodyInstance objects, storing per-instance state about about each body. */ TArray<struct FBodyInstance*> Bodies; 创建Actor（Particle） 这里Actor是PhysX遗留的术语，可以视为物理场景中的一个对象，基本等价于Particle，也即前文的TGeometryParticle。这里的Particle和下文的Shapes都在CreateShapesAndActors函数中创建。 最终的创建代码如下，StaticMesh情况比较简单，直接创建TGeometryParticle即可： void FChaosEngineInterface::CreateActor(const FActorCreationParams& InParams,FPhysicsActorHandle& Handle, UObject* InOwner) { TUniquePtr<FGeometryParticle> Particle; // Set object state based on the requested particle type if(InParams.bStatic) { Particle = FGeometryParticle::CreateParticle(); Particle->SetResimType(EResimType::ResimAsFollower); } //... } TGeometryParticle的属性如下： TChaosProperty<FParticlePositionRotation, EChaosProperty::XR> MXR; TChaosProperty<FParticleNonFrequentData,EChaosProperty::NonFrequentData> MNonFrequentData; void* MUserData; FShapeInstanceProxyArray MShapesArray; EParticleType Type; FDirtyChaosPropertyFlags MDirtyFlags; MXR：表示位置和旋转；FVec3 MX；FRotation3f MR；MShapesArray是Particle所包含的Shape，一个GeometryParticle完全可以包含多个形状，如球、长方体等，只是用的不多。 MNonFrequentData指向了低频访问的数据。 有了Particle，随之就能创建PhysicsActorHandle了，把刚创建的FGeometryParticle指针存入即可。 创建不同形状的Shape Shape为一个几何体，它包含碰撞、几何与物理材质等数据，一个Particle可以有多个Shape。 Shape的形状描述都在AggGeom里，总共有以下几种基础形状： Sphere：对应几何类为TSPhere，记录了圆心坐标和半径。 Box：对应几何类为TBox，记录了Box的HalfExtent和中心Transform。 Capsule：对应类型为FCapsule，照理说应该记录HalfHeight、Radius和中心Transform，但小小优化了一下，改为记录圆柱体上下两个点+Radius。 Convex：除了这些基础形状，还有能处理任意形状的凸包，对应类型为FConvex，里面存储了所有顶点数据，是三角形的集合。下图是为一个锥体生成的凸包。 这里例子的StaticMesh只配置了Box类型的碰撞，会创建TBox类型的Geometry，包含了Min和Max两个坐标，其实就是HalfExtend的作用。 template<class T, int d> class TBox final : public FImplicitObject { TAABB<T, d> AABB; }; class TAABB { TVector<T, d> MMin, MMax; }; 然后对TBox再包一下，加上Box的Transform数据，生成ImplicitObjectTransformed对象。 ChaosInterface::CreateGeometry函数负责根据配置的AggGeom数据，创建Geometry实例，然后存入Particle中。 至此，示例中StaticMesh对应的物理Geometry已创建完毕。 Simple Collision & Complex Collision 通常一个对象会有简单&复杂两套碰撞，就需要分别创建两个Shape和Geometry。勾选UseSimpleAsComplex则只用一套，但不建议这么做。 把上面几个类都联系起来，总结关系如下。 写入物理世界 目前为止，只是创建了Particle、Shape等数据，还需要把它们注册到物理世界中，就像把Actor注册到UWorld。 FChaosScene::AddActorsToScene_AssumesLocked 物理世界写锁 首先物理世界会被多线程同时读取与写入，因此是个读写锁的场景，写入时需要加写锁，通过FPhysicsCommand::ExecuteWrite函数实现，执行到这时可能会阻塞在等待锁上。 写入Solver Solver即为FPBDRigidsSolver，管理了物理世界所有Particle，如此理解即可，写入代码如下： FPBDRigidsSolver::RegisterObject RigidBody_External.SetUniqueIdx(GetEvolution()->GenerateUniqueIdx()); TrackGTParticle_External(*Proxy->GetParticle_LowLevel()); //todo: remove this Proxy->SetSolver(this); Proxy->GetParticle_LowLevel()->SetProxy(Proxy); AddDirtyProxy(Proxy); UpdateParticleInAccelerationStructure_External(Proxy->GetParticle_LowLevel(), EPendingSpatialDataOperation::Add); 然后Particle会被赋予一个UniqueIdx作为标识。 RigidBody_External.SetUniqueIdx(GetEvolution()->GenerateUniqueIdx()); 写入空间加速结构 这是一个重点。物理世界中有大量的几何体，为了支持射线检测、碰撞查询、物理模拟等功能，必然要用到空间加速结构。这就是FChaosScene类中的SolverAccelerationStructure属性。 Chaos::ISpatialAccelerationCollection<Chaos::FAccelerationStructureHandle, Chaos::FReal, 3>* SolverAccelerationStructure; 虽然是一个Interface，但实际都用的是： 它只是一个Collection，里面划分了多个底层的加速结构。Chaos的默认底层加速结构为AABBTree，它是一个类似BVH的数据结构，但实现更简单。 为什么要划分多个AABBTree？ 整个物理场景使用一个超大的AABBTree来管理并不合适，元素多了效率会很低。所以分多个AABBTree子树是合理的，划分依据并不是空间远近，而是Particle的Static/Movable属性、QueryOnly/QueryAndPhysics属性等，大方向是划分了Static Tree和Dynamic Tree。因为Static Tree是很少做更新的，Particle移动时，更新单独的Dynamic Tree效率更高。Particle的SpatialAccelerationIdx属性就表示它属于哪一个AABBTree，有16位，能表示8个Bucket，每个Bucket又有最多8192个AABBTree，但目前远没有用这么多，实际默认用了四个，最多能支持到8个。 struct FSpatialAccelerationIdx { uint16 Bucket : 3; uint16 InnerIdx : 13; } Bucket的划分 对于Bucket的划分，只有两种，一个是0，即默认的，另一个是1，当AABB的包围盒过大时会放到Bucket 1里。这个包围盒阈值默认为100米。 在FPBDRigidsSolver::RegisterObject函数中，会根据这个条件设置Bucket。 为什么超大AABB要单独划分？如果超大Particle和小Particle存储在一棵树中，会导致查询效率变低。想象一个叶节点包含了一个超大Particle和若干小Particle，那么做射线检测时，很容易与这个叶节点相交，但做逐Particle判断时，大概率只和这个超大Particle相交，小Particle的射线检测就都浪费了。 InnerIndex的设置 查看FChaosEngineInterface::CreateActor代码，会发现根据Particle的Static/Dynamic，以及是否QueryOnly，分成了四类： Defautl是Static Tree，Dynamic是Dynamic Tree。 Static Tree会存储静态对象，如场景中的树木、石头、房屋等。 例子中，Box的属性是Static+QueryOnly，因此InnerIndex被设置成了ESpatialAccelerationCollectionBucketInnerIdx::DefaultQueryOnly。 Dynamic Tree存储会移动的对象，如角色、载具、电梯。 DefaultQueryOnly是对Static Tree再细分出Query Only的Particle，DynamicQueryOnly类似，但目前没启用，通过p.Chaos.AccelerationStructureIsolateQueryOnlyObjects参数开启。 通过Bucket和InnerIndex划分AABBTree的示意图如下： 例子中，Box的属性是Static+QueryOnly，因此InnerIndex被设置成了DefaultQueryOnly。 具体写入了什么数据 这里立即更新物理世界的加速结构，UpdateElement就是把Geometry插入到AABB中。 写入数据有Payload和PayloadInfo两部分。 Payload更像Key，类型是FAccelerationStructureHandle，包含Particle指针、UniqueIdx、FilterData等数据。 PayloadInfo是AABBTree叶节点真正存储的数据，能反向关联到Payload和Particle，类型为FAABBTreePayloadInfo。属性如下： struct FAABBTreePayloadInfo { int32 GlobalPayloadIdx; // GlobalPayloads里单独存的NodeIndex，没有BoundingBox int32 DirtyPayloadIdx; // 单独的DirtyElementTree里的NodeIdx int32 LeafIdx; // 常见情况，Leaf Node的Index int32 DirtyGridOverflowIdx; // 用Dirty Grid而不是DirtyElementTree时，overflow的Index，少见情况 int32 NodeIdx; // 常见情况，Tree Node的Index } 最终Payload和PayloadInfo会存在PayloadToInfo这个Map里，其实是个数组，通过UniqueIndex索引，模拟成了Map。 typename StorageTraits::PayloadToInfoType PayloadToInfo; AABBTree 既然AABBTree加速结构很重要，不妨展开分析下。 核心思想 其实和BVH类似，都是把一群AABB包围盒，通过启发式的规则，不断划分成左右两个AABB子树，直到每个叶节点包含的AABB包围盒少于一个阈值，本质是一棵二叉树。 以2D情况为例，AABB树的非叶节点，会把叶节点的AABB给取并集，作为自己的AABB，叶子节点则包含了一定数量的AABB对象。下面Root的Child1节点就是叶子节点。 其对应的二叉树结构如下： 至于AABBTree的具体代码实现，为了效率，没有使用动态new节点的方法，而是用数组来表示了二叉树的拓扑。 TArray<FNode> Nodes; TLeafContainer<TLeafType> Leaves; Nodes存储了所有节点，每个节点如下： struct TAABBTreeNode { TAABB<T, 3> ChildrenBounds[2]; int32 ChildrenNodes[2]; int32 ParentNode; bool bLeaf : 1; bool bDirtyNode : 1; }; 当Node是叶节点时，ChildrenNodes[ ChildrenBounds是左右子节点的Bounds。 TAABBTreeLeafArray TArray<TPayloadBoundsElement<TPayloadType, T>> Elems; 叶子节点数组只存储AABB包围盒，会略微扩大一点，DynamicTreeLeafEnlargePercent=0.1，为了给Update做一点开销缓冲，避免Update太频繁。 叶节点包含最多8个AABB包围盒，叶节点的Bounds是它们的并集。 8是默认值，也可以通过CVar参数改变： int32 FAABBTreeCVars::DynamicTreeLeafCapacity = 8; FAutoConsoleVariableRef FAABBTreeCVars::CVarDynamicTreeLeafCapacity(TEXT("p.aabbtree.DynamicTreeLeafCapacity"), FAABBTreeCVars::DynamicTreeLeafCapacity, TEXT("Dynamic Tree Leaf Capacity")); 直接向叶节点插入元素 InsertLeaf 把Payload和Bounds插入到AABBTree中。简单起见，假设已经找到了最适配的那个叶节点，直接插入其中。 叶节点有个Elements数组，打包存储了Payload和Bounds，元素类型如下： struct TPayloadBoundsElement { TPayloadType Payload; TAABB<T, 3> Bounds; }; 具体代码层面的实现上，一个Leaf节点还需要一个额外的Node节点来辅助。 下图为AABBTree拓扑到实际存储结构的示例，可以看到树的叶节点，需要一个Node节点与一个Leaf节点来表示。 插入完成后，会返回Particle在AABBTree中的索引，索引由Node数组下标和Leaf数组下标两部分组成，类型是NodeAndLeafIndices。 struct NodeAndLeafIndices { int32 NodeIdx; int32 LeafIdx; }; 然后这个数据就存储在之前的FAABBTreePayloadInfo中。 如何寻找最合适的叶节点 叶节点满了，新建一个Node，作为Parent。 FindBestSibling 如果一颗AABBTree层数较多，那么插入一个AABB包围盒时，需要寻找“最合适”的叶节点插入。 何谓“最合适”，加入新AABB，通常会使一些ChildNode的BoundingBox变大，需要使BoundingBox增加的体积尽量小，以此来判定“最合适”。这是一种启发式的方法，认为BoundingBox大小直接影响到AABBTree查询的效率，类似构建BVH使用的SAH表面积启发算法。这个寻找过程称为“FindBestSibling”。 下面看几个例子。 例子1，新加入AABB在原先AABBTree的BoundingBox之内。此时经过对比BoundingBox增加的体积，应该加入ChildTree[1]。 例子2，新加入AABB在原先AABBTree的BoundingBox之外。此时不仅要考虑分别加入ChildTree[ 这里Root节点带来的增量相同，显然加入ChildTree[1]更合适。 例子3，多层子节点情况。看个更复杂的多层子节点例子，ChildTree[ 对于ChildTree[ 对于ChildTree[1]的两个子树，先要计算与ChildTree1产生的额外空间，再计算与两个子树的额外空间，最后都加起来。 因此这里显然应该把新节点加入ChildTree[ Leaf满了怎么办？ 当BestSibling找到的叶节点已经满了，就无法直接添加，需要再新增两个Node，一个作为中间节点，另一个作为新的Leaf。之后同样要更新Leaf到根节点路径上所有Parent的BoundingBox。 例子2的变体： 删除元素 与插入元素对应的，就是删除元素了，删除逻辑会简单很多。 virtual bool RemoveElement(const TPayloadType& Payload) 首先从PayloadToInfo中找到Payload所属的节点下标，然后从节点的Element数组里把Payload移除即可，并且把到根节点链路上的所有Bounds再更新一遍。 但是特别的，当叶节点删除Payload后整个都空了，就要精简一下AABB树了，把Parent节点和自己都删掉，然后Parent节点的位置放Sibling节点即可。 Static AABBTree AABBTree大体上分了Dynamic Tree和Static Tree两类，其中Dynamic Tree最贴近原生的AABBTree实现，增、删、改都直接操作AABBTree即可，而Static Tree则做了不少优化，来提升效率。因为通常Static Tree里的元素远超Dynamic Tree。 “Static”并不意味着不更新，比如可以把石头先改成Movable移动，再改回Static等等。Static Tree的构建总是先提供所有AABB对象，一次性建好，之后也能继续添加/删除/修改Payload，但树节点结构不实时改变，只会重建。 Static AABBTree的构建 构造函数中提供一个能表示AABB的数组即可，像这里的数组元素有3202个。 接着执行GenerateTree函数来构造AABBTree。 GenerateTree(Particles); 一次性构造一整棵树有个好处，就是能让左右子树尽量空间上均衡，提升之后的查找效率。如果是Dynamic那样一个一个节点的插入，树的质量和节点插入顺序是有关系的。那么重点就是对于一群AABB节点，如何合理地划分左右子树。回想在构造BVH时，有两种常用划分方法，一种是在XYZ三个方向里选取Max-Min最大的一个作为划分轴，然后取中点划分；另一种是SAH表面积启发算法，同样是考虑XYZ三个轴，但在每个轴上要计算多种表面积划分组合，再选取某个能使Cost最小的位置做划分，计算量更大，但效果也更好。UE的AABBTree使用了更接近前者的简单做法，但不会取Max-Min最大的作为划分轴，而是计算每个轴上AABB 中心点形成的方差，取方差最大的轴作为划分轴。计算中心点和方差的代码如下，使用了流式数据常用的Welford算法。 举个二维平面的例子。 按照Max-Min的方式，应该用X轴划分，按照方差方式，应该按照Y轴划分，结果上看方差方式更优。 然后GenerateTree的具体实现上，也有两个特点，一是支持分帧构建，避免突然卡一下，另一个就是全程避免递归和动态内存分配了，是比较值得学习的工程实践。 Static AABBTree删除Payload 如果Static AABBTree要删除Payload，过程反而更简单，直接从Leaves数组中移除Payload即可，不用考虑树的坍缩，也不用更新整个树节点链路上的AABB包围盒。 然后标记ShouldRebuild为true，等待后面一起更新树，见PBDRigidsEvolution.cpp文件。 Static AABBTree插入Payload 插入Payload比较有意思，既要不改变AABBTree的结构，又要插入元素。UE做法是另外创建了一个容器，来存储插入的元素，而且这个容器同样支持空间加速查询。一种实现是2D Grid，另一种实现是再加一个Dynamic AABBTree。还是比较复杂的。 Grid实现 默认用Grid实现，首先Payload加入DirtyElements数组，然后把整个场景划分为2D正方形Grid，再把Payload加入到Grid数据结构中。 Grid的每个Cell大小为CVarDirtyElementGridCellSize，默认1000，如果一个Payload和某个Cell重叠，该Cell就会把Payload的DirtyPayloadIndx记下来。 但Grid也有大小限制和容量限制。首先，Payload所覆盖的Cell不能过多，即Payload的AABB不能过大，然后每个Cell重叠的Payload数量不能过多，不然都会影响效率。目前前者配置是16，后者配置是32。如果超过了限制，Payload就要被加入到单独的DirtyElementsGridOverflow数组，并把下标记录在PayloadInfo中，后续也会单独查询。 Grid示意图如下： DirtyElementTree实现 新加的AABBTree称为DirtyElementTree，Payload插入完成后，PayloadInfo会存储其在DirtyTree中的Node下标。 如此一来，Grid/DirtyElementTree就是Static AABBTree的一部分了，往后的增/删/改Payload，以及AABBTree做碰撞查询，都要额外考虑它们。后面AABBTree重建了，再清空Grid/DirtyElementTree。 AABBTree可视化 最后，可以读取AABBTree的所有元素，并画出来，看下AABBTree都是什么样。 遍历PhysicsScene的SolverAccelerationStructure属性即可，可以看到整个AABBTree还是比较复杂的。 尤其是中间角色，有多个BodyInstance。 碰撞通道设置 一个PrimitiveComponent，不仅包含几何信息，还可以配置它的碰撞通道、ObjectType、CollisionType等信息，这些设置都会影响到PrimitiveComponent的物理表现。比如例子中的StaticMeshComponent，默认的ObjectType为WorldStatic，然后对所有CollisionResponses的响应都是Block。 首先需要根据ColllisionProfileName加载对应的各个碰撞响应，每个BodyInstance都自己存了一份CollisionProfileName和CollisionResponses，加载函数为UCollisionProfile::ReadConfig。至于为什么每个BodyInstance都要存一份，而不是用BodySetup里的，是因为运行时可以动态改变单个BodyInstance的碰撞设置。 此时这些碰撞设置还是一堆Bool和Enum，对于底层物理引擎存储不太方便，因此要把它们进行编码，最终会存入四个int32，用FCollisionFilterData表示。 struct FCollisionFilterData { uint32 Word0; uint32 Word1; uint32 Word2; uint32 Word3; }; 在CreateShapes函数中，会对碰撞数据进行编码，并且生成三个FCollisionFilterData实例，存储不同用途的物理碰撞信息。 /** Helper struct holding physics body filter data during initialisation */ struct FBodyCollisionFilterData { FCollisionFilterData SimFilter; FCollisionFilterData QuerySimpleFilter; FCollisionFilterData QueryComplexFilter; }; 编码过程主要由CreateShapeFilterData函数实现。 SourceObjectID：UPrimitiveComponent的UniqueID。 InstanceBodyIndex：这是UPrimitiveComponent里的第几个BodyInstance，比如常见的SkeletalMeshComponent有多个BodyInstance。 SimFilter：物理模拟相关信息。 inline void GetSimData(uint32 BodyIndex, uint32 ComponentID, uint32& OutWord0, uint32& OutWord1, uint32& OutWord2, uint32& OutWord3) const { OutWord0 = BodyIndex; OutWord1 = BlockingBits; OutWord2 = ComponentID; OutWord3 = Word3; } QuerySimpleFilter：SimpleCollision的碰撞响应。 inline void GetQueryData(uint32 SourceObjectID, uint32& OutWord0, uint32& OutWord1, uint32& OutWord2, uint32& OutWord3) const { OutWord0 = SourceObjectID; OutWord1 = BlockingBits; OutWord2 = TouchingBits; OutWord3 = Word3; } QueryComplexFilter：ComplexCollision的碰撞响应，响应部分和Simple是一样的。 // Build filterdata variations for complex and simple SimpleQueryData.Word3 |= EPDF_SimpleCollision; if (bUseSimpleAsComplex) { SimpleQueryData.Word3 |= EPDF_ComplexCollision; } ComplexQueryData.Word3 |= EPDF_ComplexCollision; if (bUseComplexAsSimple) { ComplexQueryData.Word3 |= EPDF_SimpleCollision; } OutFilterData.QuerySimpleFilter = SimpleQueryData; OutFilterData.QueryComplexFilter = ComplexQueryData; Word3的设置： inline uint32 CreateChannelAndFilter(ECollisionChannel CollisionChannel, FMaskFilter MaskFilter) { uint32 ResultMask = (uint32(MaskFilter) << NumCollisionChannelBits) | (uint32)CollisionChannel; return ResultMask << NumFilterDataFlagBits; } PrimitiveComponent的销毁 Component销毁时，需要同步的清除掉物理世界数据，主要通过FBodyInstance::TermBody函数实现。 1.从PhysScene的几个容器里移除 PhysicsProxyToComponentMap和ComponentToPhysicsProxyMap。 2.从PhysScene的加速结构中移除 FChaosScene::RemoveActorFromAccelerationStructure 就是从AABBTree移除，先从Buckets找到对应的AABBTree，然后调用RemoveElement移除。 最终进入AABBTree的RemoveElement函数，根据DynamicTree属性、使用Grid还是DirtyElementTree等情况，做移除。 3.从Solver中移除 FPBDRigidsSolver::UnregisterObject，物理模拟信息的删除。 这是侑虎科技第1966篇文章，感谢作者南京周润发供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/xu-chen-71-65 再次感谢南京周润发的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）