UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - 厚积薄发
UWA旨在诊断与优化游戏和VR项目开发过程中潜在的运行性能问题，帮助项研发团队快速检测项目运行时的性能瓶颈，高效定位问题根源，及时反馈针对瓶颈问题的深度分析和解决方法。
如何使Bloom只局部地作用于特效以提高性能
1）如何使Bloom只局部地作用于特效以提高性能 2）为什么物理内存一直上涨 这是第460篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：我的游戏中只有主角的各种技能特效需要Bloom，目前是使用的Unity传统渲染管线的官方后处理中的，在低端设备上性能有点差，发烫，使用UWA排查后，主要瓶颈在后处理导致的GPU带宽高。 请问有没有什么办法，可以只对角色的技能特效局部Bloom来提高性能吗？或者还有其他方法，可以不用后处理的Bloom实现类似的辉光效果呢？ A：是的，完全可以通过局部 Bloom（Selective Bloom）和免后处理辉光方案显著降低GPU带宽与发热，尤其适用于“仅主角技能需辉光”的典型场景。以下是经验证的高效方案： 一、局部Bloom（推荐首选） 避免全屏采样，只对技能特效区域做辉光： 原理：将技能特效渲染到独立RenderTexture（带Alpha通道），用自定义Shader对其高亮区域（如亮度>0.8的像素）进行模糊+叠加。 关键优化： 使用1/4~1/8渲染分辨率的RT进行模糊（非原分辨率下采样），带宽下降达75%~94%； 模糊采用2-pass高斯（水平+垂直），每pass仅5tap，避免多级Mipmap下采样； 最终仅Blit一次到主帧缓冲，无额外Fullscreen Pass。 二、免后处理辉光替代方案（零带宽开销） ⚠️ 注意事项 禁用传统Post-Processing Stack v2的全局Bloom Volume，改用Local Volume+Trigger Collider控制作用范围； 所有方案务必关闭Anisotropic Filtering和Trilinear Filtering（对辉光贴图无意义，徒增带宽）； 若使用URP，优先迁移到URP的Bloom（支持Bloom Intensity per Layer），但仍需配合局部渲染逻辑，不可直接开启全局。 回复基于训练数据生成，建议结合其他可靠来源进一步核实。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=d34a9869-eb45-42cb-bec6-c246a6d83a03 From 问答社区 Q：请问有遇到这种Physics 2D的物理内存一直上涨的问题吗？除了初始化后没有新增物理相关的，但是物理占用的内存却一直上涨： Memory Profiler中是这个在上升： A1：最近有了解到有个办法能看到Unity Subsystem下面Unknown（Unrooted）部分中的内存类型和引用堆栈，不过需要Unity 6+Memory Profiler 1.1.9+编辑器环境，如果能够在这个条件下复现泄露问题，可能能看到更多细节： 这个的效果图供参考： 除此之外，物理这块内存按经验往往是一些引擎生成的不受控制的网格碰撞体容易导致高占用。可以对比看下是在什么条件下观察到该问题的，是否出现在某次版本更新后？也可以开关一些游戏对象、引擎设置里物理相关的功能看看有没有关联。 A2：最终我们发觉是Unity的版本Bug，目前已经做升级处理，从2022.3.62f2升到2022.3.62f3。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/69649adf244ce21ce9ec096a 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
时光流转，深耕不辍。UWA知识型栏目《厚积薄发 | 技术分享》已悄然陪伴大家走过459个工作周。在这一年的技术征途里，我们依旧扎根真实的游戏开发场景，以问答社区的经验沉淀为基石，又迎来了UWA AI技术的全新助力。 2025年的十大精选内容，既收录了社区开发者们互助探讨的智慧结晶，也融入了UWA AI基于海量知识沉淀的精准解答，每一篇分享都瞄准开发中的痛点卡点，为大家提供切实可行的破局思路。 回望这一年，是无数开发者的踊跃分享与积极互动，让技术的价值持续传递；展望2026年，UWA问答社区与UWA AI问答将继续并肩前行，以更专业的内容、更高效的解决方案，陪伴每一位开发者在技术深耕之路上稳步迈进！ UWA 社区主页：community.uwa4d.com UWA QQ群：793972859 From 问答社区 TOP1：如何让GameObject销毁时无论是否Active过，都调用OnDestroy？ 请教一个问题，MonoBehaviour的OnDestroy方法，如果这个GameObject的实例化时就是隐藏的，销毁时不会调用OnDestroy方法。我们项目有些核心逻辑是基于这个OnDestroy做的（时间太久远，耦合太多逻辑不好改出去了）。有没有什么办法，可以让这个GameObject销毁时无论是否Active过，都调用OnDestroy。 A1：要触发OnDestroy，必须要触发过Awake（Awake可以不明着写出来），通常是被实例化或者第一次激活的时候触发Awake，如果一直处于不激活的状态，那么销毁的时候不会自动触发OnDestroy。所以可以在激活的父节点上主动SetActive一下： A2：1. 一般一个Prefab本身是点亮的，大部分是里面的某些节点才会隐藏。如果是我说的这种情况，应该是在根Prefab摧毁的时候主动调用一次需要控制调用的子组件。 如果你真的是根Prefab就隐藏的那更简单了： （1）做一个工具扫描所有Prefab检查并且修改点亮情况； （2）编辑器模式下加载完一个Prefab之后检查是否点亮，如果没有则打印Error。 A3：我们也遇到了这个，主要导致的问题自定义组件清理不掉Lua注册过来的回调，导致返回登录报错。 我这边解决思路是，在脚本反序列化之后，将自己注册到UI根节点的管理脚本上，由这个管理脚本在Destroy时清理下面的子节点。Base脚本如下图： 有几个要点说明下： 我们只处理了UI（其他地方基本不会出这问题），项目的用法上每个UI根节点有必挂的组件，这个组件一定会执行Awake； 如果组件自身Awake走到了，就不需要额外的销毁管理，需要从管理器中将自己移除； 反序列化接口中有一堆限制，要小心处理，比如常用的接口只能在主线程执行，如果在子线程要跳过。还有Prefab打开的编辑状态也有一些状态取不到，我这边直接忽略了（因为不常用）。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/67d390183d306f3e9d594e7a From UWA AI 问答 TOP2：Unity中可以禁用GC吗？ Unity中可以禁用GC吗？ A：在Unity中，可以禁用垃圾回收（GC），但需谨慎使用，并结合具体场景进行管理。 禁用GC的方法 Unity提供了通过脚本控制垃圾回收器行为的API：GarbageCollector.GCMode。 该API支持以下三种模式： GarbageCollector.Mode.Enabled：默认模式，自动触发GC。 GarbageCollector.Mode.Manual：禁用自动GC，但可手动调用System.GC.Collect()或GarbageCollector.CollectIncremental()。 GarbageCollector.Mode.Disabled：完全禁用GC，包括手动调用也无效。 将GarbageCollector.GCMode设置为Disabled即可彻底关闭垃圾回收机制。 使用场景与最佳实践 禁用GC主要用于性能敏感的关键阶段，如战斗、加载、过场动画等，以避免GC导致的CPU卡顿（GC Spike）。 推荐做法： 预分配内存：在进入关键阶段前，预先分配所需的所有对象（如对象池、缓存等），确保运行时无新内存分配。 临时禁用GC：进入关键阶段时设置GCMode = Disabled，结束后立即恢复为Enabled或Manual。 手动清理：恢复GC后，调用System.GC.Collect()回收阶段性内存，防止堆持续增长。 示例流程： // 进入关卡前 AllocateLevelResources(); // 预分配资源 GarbageCollector.GCMode = GarbageCollector.Mode.Disabled; // 关卡运行中（避免任何new操作） // 关卡结束 GarbageCollector.GCMode = GarbageCollector.Mode.Enabled; System.GC.Collect(); // 回收内存 风险与注意事项 内存持续增长：禁用GC后，未引用的对象不会被释放，若持续分配将导致内存溢出。 必须严格控制内存分配：禁止使用字符串拼接、装箱、Linq、匿名函数等易产生GC的操作。 推荐结合0GC编程：使用StringBuilder、对象池、结构体等技术实现运行时零托管内存分配。 辅助优化手段 启用增量GC（Incremental GC）：在Player Settings中开启Use Incremental GC，将GC工作分摊到多帧，减少单次停顿时间。 性能监控：使用Profiler或UWA GOT工具监控GC Alloc值，定位内存分配热点。 综上，Unity支持运行时禁用GC，但应作为高阶优化手段，在可控内存分配的前提下短期使用，以换取更稳定的帧率表现。 From 问答社区 TOP3：为什么不应将游戏逻辑写在FixedUpdate中？ 请问在向别人解释不要把游戏逻辑放在FixedUpdate，而应正常地放在Update里，除了Input事件外，我还能使用哪些理由呢？ A1：FixedUpdate只处理物理相关的逻辑，Input事件应该放Update。 A2：如果卡，FixedUpdate会在一帧里执行多次来追上更新次数，可能导致严重的掉帧和发热。若想慢点更新，建议用携程或者用UniTask。 我需要一些硬性的不能做的理由，目前想到的就只有Input事件会重复或被跳过。 A3：那Update里面干什么?而且位置更新不放到Update和渲染匹配不上，看着应该会有顿的感觉。 A4：FixedUpdate不能动态锁帧，全放里面手机发热发烫耗电也快。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/677b429f682c7e5cd61bf9e4 From 问答社区 TOP4：InstantiateAsync有什么需要特殊处理的吗？ 请教个问题，InstantiateAsync有什么需要特殊处理的吗？我发现异步实例化出来的角色，好像丢了蒙皮一样，Mesh完全不跟着动画、还有根节点动，始终以T-pose的形态呆在固定位置，而且同一个角色，同步加载是正常的。 这是我这边的环境： A1：我们在接近你的版本和更高版本试了下，分别复现了问题和正常加载。查了下可能是类似下面的Unity Bug（异步实例化相关的bug有很多），应该要升一下版本。 A2：的确升级Unity版本后ok了。 A3：没啥问题，效果也比较好、明显，总得来说异步实例化好处多余坏处，我是调研过了并且项目在使用的。 AsyncInstantiateOperation.GetIntegrationTimeMS() AsyncInstantiateOperation.SetIntegrationTimeMS()可以设置异步实例化的间隔时间，默认是2ms，是Unity 2022.3.20新增加的功能。 写代码的时候可以考虑Async/Await，当然国际版Unity最新.3.60，国内版.3.54。 如果你项目没啥特殊可以考虑升级到最新，正常来说如果你没有上线最好到最新或者你得平时关注Unity版本发布日志。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/67ce97913d306f3e9d594e6c From 问答社区 TOP5：在Unity转微信小游戏下，如何用Worker实现多线程？ 想用WebWorker计算Unity里的小球的运动轨迹，请问Unity转微信小游戏的情况下，可以用Worker实现多线程吗？ A：可以参考官方文档：https://developers.weixin.qq.com/minigame/dev/api/worker/wx.createWorker.html 功能描述： 创建一个Worker线程。 参数： string scriptPath Worker入口文件的绝对路径 object options： 可选参数： 属性：useExperimentalWorker 类型：boolean 默认值：false 必填：否 说明：是否使用实验worker。在iOS下，实验worker的JS运行效率比非实验Worker提升数倍，如需在Worker内进行重度计算的建议开启此选项。同时，实验Worker存在极小概率会在系统资源紧张时被系统回收，因此建议配合worker.onProcessKilled事件使用，在Worker被回收后可重新创建一个。 最低版本：2.13.0 返回值 Worker Worker对象 注意事项 接口使用前需要在game.json（插件为plugin.json）中配置workers字段，表示Worker代码根目录。 scriptPath为入口文件的绝对路径，且不以/开头。 目前限制最多只能创建一个Worker，创建下一个Worker前请先调用Worker.terminate。 多线程Worker指南（小游戏）。 示例代码 // 创建普通worker wx.createWorker('workers/index.js') function createNewWorker() { const worker = wx.createWorker('workers/index.js', { useExperimentalWorker: true }) // 监听worker被系统回收事件 worker.onProcessKilled(() => { // 重新创建一个worker createNewWorker() }) } // 创建实验worker createNewWorker() 欢迎大家转至社区交流： https://answer.uwa4d.com/question/67c559603d306f3e9d594e69 From 问答社区 TOP6：增量GC机制，会导致碎片化更严重吗？ 请问增量GC这种频繁分配与释放的GC机制，会导致碎片化更严重吗？好像介绍增量GC的资料很少，所以有这种疑问。 A：对于碎片的产生，理论上增量GC和普通GC应该是没有什么区别的，都是标记-清除。增量GC只是把GC的工作分帧处理了，控制碎片的占用比例更关键的是要减少大块临时分配。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/684fe482682c7e5cd61bfaae From 问答社区 TOP7：为什么场景中没有刚体更无碰撞事件，但仍有较高物理耗时？ 我有个疑问：为什么GOT性能报告和Profiler里看物理模块耗时都很高？但其实这时候场景里已经没有刚体了，按理说不会发生碰撞，只有在代码里用了Raycast判断子弹命中，后来我把设置里的物理模拟关掉，又打了包，物理耗时确实降了下来，看子弹还是能正常命中（因为是代码调的），看粒子里的碰撞也还是能生效。 我不明白的是：之前那么高的物理耗时是在做什么？可能是什么导致的？是有一些我没留意到的地方用了物理导致的，还是说Unity机制如此？（Unity版本2022.3.59） A：的确，当前Unity版本的机制就是这样，很多时候必须要关闭掉物理模拟（指把Simulation Mode改成Script）才可以节省耗时。具体机制是场景中只要Static Collider数量大于0且没有关闭物理模拟就会有耗时，和场景中有没有刚体、有没有发生碰撞事件无直接关联。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/684688ea682c7e5cd61bfaa0 From 问答社区 TOP8：如何用GPU Instancing来优化树木草石重复模型？ 项目场景里的树木草石重复模型非常多，在尝试用GPU Instancing来优化。使用的Shader应该是SRP Batcher和GPU Instancing都支持的，但是材质勾上GPU Instancing后看FrameDebugger里还是走的SRP Batcher，要怎么修改比较好呢？ A：如果Shader两个都兼容，就会优先走SRP Batcher的。常规做法就是把规划要用GPU Instancing的物体用别的不走SRP Batcher的Shader；也可以考虑仍然用两个都兼容的Shader，但是用MaterialPropertyBlock打断SRP Batcher，这样就会顺延走GPU Instancing了。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/680dd4d53d306f3e9d594ecc From 问答社区 TOP9：如何在运行时获取硬件信息？ 请问怎样在运行时获取硬件信息？一般参考其中哪些信息进行分级比较合理？ A：一般是用SystemInfo里的一些接口，如图： 真机上输出效果如图： 一般用其中deviceType、deviceName去找对应的硬件档次划分；或者直接graphicsDeviceName给到的GPU型号进行画质分级。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/67f374e1682c7e5cd61bfa35 From 问答社区 TOP10：如何排查优化URP内置Shader冗余？ 请教一下Shader冗余应该怎么查，似乎好几个Shader运行时都有两份？ A：资源冗余最常见的原因是AssetBundle没有依赖打包导致的，可以使用UWA的在线AssetBundle检测进行冗余检测先试试。 测了AssetBundle，确实有冗余，但AssetBundle冗余的Shader和运行时冗余的Shader好像又不一致。实际运行时的冗余都是Hidden/Universal Render Pipeline/xxx。这又是为什么呢？ A：这些是URP的Shader，通常是URP Asset的引用导致的，因为URP Asset会引用这些Shader。而内存中出现两份Shader，说明内存中出现了两个来源路径不一样的URP Asset，通常一份是在PlayerSetting中引用的URP Asset，另外一份可能来自AssetBundle中动态加载的URP Asset。RendererData里面会引用到PostProcessData，PostProcessData就会引用这些Shader，如果代码里面动态加载的AssetBundle里面也有这种资源，也会引用一份Shader进内存，就会造成冗余。 请问这个怎么处理，直接删去吗？ A：一般只处理内存占用比较大的即可，其他的内存占用比较小，冗余开销也不大。比如Hidden/Universal Render Pipeline/Uberpost，需要删除其中用不到的Keyword来降低占用；又比如Hidden/Universal Render Pipeline/HBAO，看是否确实要用到，用不到就解除引用。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/69245652244ce21ce9ec095c 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ TaskGraph是线程池的进阶，能让任务之间产生依赖，上层可以方便地指定这种依赖。各任务的依赖关系就形成了“图”。 除了线程池，TaskGraph还可以管理GrameThread、RenderThread等独立线程的调度，是UE中最复杂，功能最全面的多线程调度框架了。 典型场景 UE的多线程GC是TaskGraph的一个典型场景，需要把一个大的Array分割成若干小的Array，然后分到多个线程处理，GameThread需要等这些线程都处理完了，再执行以后的任务。代码如下： 注意最后的ParallelFor，把多线程处理封装成并行For行为，分发到多个线程，然后等待多线程执行结束。 如果用普通线程实现这个功能，需要手动用FEvent实现等待，要写一些特化代码。 一、使用TaskGraph 1. Gamethread Tick 最常见的GameThread World Tick，就是由TaskGraph驱动的，因为GameThread也由TaskGraph管理，我们写的Actor::Tick，Component::Tick都在这里执行。Tick函数本身可以包装到TGraphTask里，然后用WaitUntilTasksComplete函数执行所有Task。 2. Async函数 Async函数可以指定EAsyncExecution::TaskGraph，让任务在TaskGraph线程池中执行。还能指定EAsyncExecution::TaskGraphMainThread，让一些短时间任务在主线程执行。 3. WaitUntilTasksComplete 如果需要发出一些异步任务，然后等待执行结束，可以手动构造FGraphEventArray，然后调用WaitUntilTasksComplete等待执行完毕，这里能体现TaskGraph的调度。 二、TaskGraph线程池 TaskGraph包含了线程池功能，不妨首先看线程池部分是如何实现的，这也比较好切入。类似FQueuedThreadPoolBase结构，TaskGraph的线程池有FTaskGraphInterface、FScheduler、FThread、TGraphTask和TAsyncGraphTask。 1. FTaskGraphInterface FTaskGraphInterface是TaskGraph的管理类，是个单例，本身也是Interface，一些重要功能由子类实现。 接口 Startup：初始化TaskGraph。 Shutdown：关闭TaskGraph。 AttachToThread：把一个独立线程添加到TaskGraph中，比如GameThread和RenderThread。 WaitUntilTasksComplete：让一些线程运行若干任务，并在当前线程等待这些任务都执行完。 TriggerEventWhenTasksComplete：当若干任务执行完，触发一个Fevent。 ProcessThreadUntilIdle：让一个NameThread一直处理自己的TaskQueue，直到执行完所有Task。 子类 FTaskGraphCompatibilityImplementation UE5的新TaskGraph子类，实现了TaskGraph的核心功能，不包含任务依赖功能，任务依赖由task实现。 成员 uint32 PerThreadIDTLSSlot：TaskGraph用FWorkerThread结构体管理线程，每个线程在自己的TLS变量中存储指向FWorkerThread结构的指针。 Int32 NumNamedThreads：Named线程数量。 Int32 NumWorkerThreads：Worker线程数量。 Int32 NumBackgroundWorkers：BackgroudWorker数量。 Int32 NumForegroundWorkers：ForegroundWorker数量。 TArray NamedThreads：管理了所有NamedThread。 FTaskGraphImplementation：旧TaskGraph子类实现，不看了。 2. FScheduler FScheduler用于创建、管理Workder线程，以及把Task分派给Worker线程。 成员 TArray<TUniquePtr> WorkerThreads：工作线程。 TAlignedArray<FSchedulerTls::FLocalQueueType> WorkerLocalQueues：WorkerThread对应的Task。 TAlignedArray WorkerEvents：WorkerThread对应的Event。 EThreadPriority WorkerPriority：工作线程优先级。 EThreadPriority BackgroundPriority：Background WorkerThread优先级。 FSchedulerTls::FQueueRegistry QueueRegistry：全局任务队列。 方法 StartWorkers：创建WorkerThreads和Event等。 StopWorkers：执行完所有Task，然后销毁WorkerThreads。 TryLaunch：在WorkerThreads上执行Task。 WakeUpWorker：通过Event Trigger唤醒WorkerThreads。 3. FThread TaskGraph创建的WorkerThread，使用FThread来管理，它是操作系统中一个线程的表示，封装了一个FThreadImpl。 方法 Join：最主要的方法，等待线程执行完毕。 成员 TSharedPtr<class FThreadImpl, ESPMode::ThreadSafe> Impl：实际的Frunnable。 4. FThreadImpl FThread的具体实现，继承自Frunnable。 方法 Run：调用了成员ThreadFunction。 成员 TUniqueFunction<void()> ThreadFunction：线程要执行的函数，就是WorkerMain。 TUniquePtr RunnableThread：对应的FRunnableThread对象。 5. TGraphTask TaskGraph系统中管理的Task，不直接调用用户提供的Task函数，而是把函数封装成一个user defined task，存储在其中。 成员 TAlignedBytes<sizeof(TTask),alignof(TTask)> TaskStorage：存储的user defined task，类型由模板指定。 FGraphEventRef Subsequents：存储哪些GraphTask以我们为前置。 方法 CreateTask：创建一个新GraphTask。 ExecuteTask：执行Task。 SetupPrereqs：设置Task前置。 6. TAsyncGraphTask 属于user defined task，是UE为实现Async函数而创建的类。 成员 TUniqueFunction<ResultType()> Function：用户提供的Task方法。 LowLevelTasks::FTask TaskHandle：FSchedule中对应的FTask对象。 方法 DoTask：执行Function。 7. FTask Scheduler中使用的最底层任务对象。 成员 FTaskDelegate Runnable：封装的Task函数对象。 FPackedDataAtomic PackedData：Priority，DebugName等信息。 方法 ExecuteTask：执行Task。 借用其他博主画的类图，这张类图画的很好，但需要把其中的FTaskGraphImplementation类换成FTaskGraphCompatibilityImplementation： 三、初始化Worker线程 在PreInitPreStartupScreen函数中，会调用FTaskGraphInterface::Startup函数初始化TaskGraph，然后调用到Fscheduler::StartWorkers创建WorkerThreads。 参数NumberOfWorkerThreadsToSpawn与CPU核数有关，Windows平台为总核数减2，估计一个留给GameThread，一个留给RenderThread。 WorkerThreads分为ForegroundWorker和BackgroundWorker，线程优先级不一样，分别是TPri_SlightlyBelowNormal和TPri_BelowNormal，ForegroundWorker默认只有两个。最终的创建WorkerThreads代码如下： 对于每个WorkerThread，要创建三样东西： 首先创建一个属于该WorkerThread的FSleepEvent，内部包含了WorkerThread当前状态和对应的FEvent对象，用于管理WorkerThread的Sleep、Running等状态转换，存储在WorkerEvents中。 然后创建一个Local任务队列，用于存储Task，存在WorkerLocalQueues数组中。 最后通过CreateWorker创建一个线程，用FThread包装，存储在WorkerThreads数组。线程函数是FScheduler::WorkerMain，主要任务从Task队列中取出Task并执行。 对于ForegroundWorker和BackgroundWorker，一些参数会有不同。 除了专门的WorkerThread，GameThread也能作为WorkerThread使用，可以把一些Task指定到GameThread执行，具体会在下面介绍。 1. 添加任务 观察Async函数，首先调用CreateTask，创建一个FConstructor对象，内部包装一个TGraphTask实例。TGraphTask创建时可以指定前置Task，但Async函数的任务是轻量的异步任务，没有前置，因此这里直接用NULL。TGraphTask接受模板参数TTask，这里为TAsyncGraphTask。 TAsyncGraphTask TAsyncGraphTask是用户自定义Task，可以把一个Lambda函数派发到WorkerThread或者GameThread上执行。 DoTask函数 GetDesiredThread函数，可以在构造函数中传入想执行的线程。 然后执行ConstructAndDispatchWhenReady，先构造一个TAsyncGraphTask实例，设置到TGraphTask.TaskStorage指针上。然后执行Setup函数，其中一些操作是GraphTask前置和后置相关的，先不管，最后会进入QueueTask函数，把任务添加到TaskGraph执行。 注意到这里用了FConstructor作为Helper类，把难写的TaskStorage原地构造包在里面，更易使用。 FConstructor还有另一个函数ConstructAndHold，这可以先创建TGraphTask，但不执行，后面通过手动调用TGraphTask::Unlock执行，但这种用法不多。 GraphTask也有一个优先级类型，为ETaskPriority，这里首先会根据GraphTask希望执行的线程类型，得到对应的TaskPriority，AnyThread对应的就是Normal。 Task->GetTaskHandle()获取了GraphTask内部的FTask对象，Init操作用于把Priority和封装的Lambda函数参数赋值进去，初始化FTask对象。 最后TryLaunch会进入FSchedule，把FTask加入到任务队列中。 任务队列分为Thread Local和Global两种，Async函数场景会加入Global，TaskGraph任务队列特点是无锁，即使多生产者，多消费者，也不需要加CriticalSection级别的锁，只使用原子操作。关于无锁任务队列，会在下面专门介绍。 WakeUpWorker后面再看。 至此，用户提供的Task已经被加入到任务队列。 2. 执行任务 首先看创建Worker Trhead的线程函数WorkerMain： 参数含义： WorkerEvent：线程对应的SleepEvent，存在Scheduler数组中。 ExternalWorkerLocalQueue：存Task的LocalQueue，当前WorkerThread独占，存在Scheduler数组中。 WaitCycles：线程短等待的YieldCycles，不同WorkerThread会有些差异，避免大家一起执行YieldCycles。 bPermitBackgroundWork：BackgroundWorker为true，ForegroundWorker为false。 然后是一个大While循环，不断从Task队列中取Task执行，没有Task则进入Sleep。这里涉及到一些细节，首先看到Worker队列有很多种，然后线程也不是简单的没Task就进入Sleep，而是有更多状态切换，以达到更好性能。 先忽略Task队列的细节，因为这涉及到无锁队列的实现，认为从一个逻辑上的队列里取Task，进入TryExecuteTaskFrom函数。最终进入ExecuteTask函数，执行用户提供的Task，返回值AnyExecuted表示是否执行了Task。 Task处理完后不直接用WaitEvent进入Wait，TaskGraph里增加了一个Drowsing（休眠）状态，总共有三个状态，状态通过FSleepEvent结构体维护，转换逻辑在TrySleeping函数。 Running：正在执行Task。 Drowsing：队列中Task刚执行完不久，执行WorkerSpinCycles次的主动YieldCycles函数，释放一点CPU时间片，估计为了避免频繁调用Wait和Trigger。进入Drowsing会把FSleepEvent加入SleepEventStack容器，认为已经处于不活跃状态，需要通过WakeUpWorker调用从容器中移除，改回Running。 Sleeping：一段时间的Drowsing状态内没有执行新的Task，调用FEvent.Wait，线程进入阻塞状态。只有通过WakeUpWorker函数执行FEvent.Trigger后才能恢复执行，同时会把FSleepEvent从SleepEventStack中弹出，把状态改回Running。 状态转换图如下： 3. Task优先级 游戏运行过程中会产生大量Task，UE支持为Task指定多个优先级，提供更细粒度的控制，虽然在Async函数里只提供了一种优先级。这里只讨论Task在WorkerThread中执行的情况，GameThread和RenderThread执行Task另外再讨论。 Task优先级定义如下： 真正有意义的是High、Normal、BackgroundHigh、BackgroundNormal和BackgroundLow五种，运行时会按照优先级维护多个队列，按照优先级顺序执行这些Task。 但用户不能直接指定Task的优先级。用户自定义Task可以通过GetDesiredThread函数指定希望执行的线程、线程优先级、以及Background Task的优先级，最终会设置在TGraphTask的ThreadToExecuteOn属性上。 这个int32中嵌入了很多信息： ENamedThreads的组成如下，按比特位划分了不同区域，具体也可看enum定义，这里过长不贴了。 ThreadId部分8位 标识线程的ID，NamedThread下标从0开始，StatsThread=0，RHIThread=1，AudioThread=2，GameThread=3，AnyThread=0xff。 QueueIndex部分1位 MainQueue=1，LocalQueue=2。 ThreadPriority部分2位 指定不同线程优先级，也可以认为是Task的粗粒度优先级，NormalThreadPriority=0，HighThreadPriority=1，BackgroundThreadPriority=2。 TaskPriority部分1位 用户定义的Task细粒度优先级，仅对ThreadPriority=BackgroundThreadPriority时有效，把BackgroundThreadPriority再细分，NormalTaskPriority=0，HighTaskPriority=1。 注意ThreadId的AnyThread选项，表示在任意Worker线程执行，但之前介绍过Worker线程分为ForgroundWorker和BackgroundWorker，它们线程优先级不同，Task具体在哪类Worker中执行，还是要看根据ENamedThreads得到的TaskPriority。 多个枚举可以组合，引擎提供了一些预置enum，目前并不是所有组合都支持，比如AnyHiPriThreadNormalTask和AnyHiPriThreadHiPriTask是等同的，只是先都定义了。 以AnyBackgroundThreadNormalTask为例，该Task会在WorkerThread中执行，线程TaskPriority是BackgroundNormal，用户定义TaskPriority是NormalTaskPriority。 UE也提供了一些Helper函数，从中获取信息： GetThreadIndex GetQueueIndex GetTaskPriority GetThreadPriorityIndex 最终的TaskPriority和WorkerThread种类由ThreadPriority和用用户定义TaskPriority共同决定，代码在FTaskGraphCompatibilityImplementation::QueueTask中，整理的对应关系如下： TaskQueue也按照TaskPriority数量进行了划分，各优先级有自己的容器。TaskQueue分为Thread Local LocalQueue和全局的OverflowQueues，定义如下，是个ETaskPriority::Count的数组： 以OverflowQueues为例，添加Task代码如下： 取Task代码如下，优先级从高到低遍历： 总结一下，TaskGraph提供线程池功能时执行流程图如下，这里TAsyncGraphTask也可以换成我们自己写的用户Task，同样使用TGraphTask::CreateTask().ConstructAndDispatchWhenReady接口即可。 四、TaskGraph管理NamedThread TaskGraph不仅可以创建WorkerThread执行任务，还能把GameThread、RenderThread等专用线程也纳入管理，分派任务给线程执行。 回顾FTaskGraphCompatibilityImplementation定义，其中包含了NameThreads容器，用一个FWorkerThread代表一个NamedThread。 NamedThread线程ID定义如下，有RHIThread、AudioThrad、GameThread和RenderThread四个。 1. FWorkerThread 表示一个线程，包含相关信息，目前实现只用于NamedThread。 成员 FTaskThreadBase*TaskGraphWorker：真正的TaskGraphWorker。 bool bAttached：NameThread是否被注册到TaskGraph系统。 2. FTaskThreadBase 用于让NamedThread有执行GraphTask的能力。 成员 ENamedThreads::Type ThreadId：线程ID。 Uint32 PerThreadIDTLSSlot：FWorkerThread对象指针会被存储到这个Slot对应的TLS中，这样NamedThread就能取到它了。 TArray<FBaseGraphTask*>NewTasks：这个线程要执行的Task。 FWorkerThread*OwnerWorker：所有者FWorkerThread的指针。 函数 ProcessTasksUntilQuit ProcessTasksUntilIdle：两个都用于让NameThreads不断执行Task，直到线程Idle或者设置RequestQuit标记。 EnqueueFromThisThread：向线程添加GraphTask任务，当前执行的线程就是NamedThread。 EnqueueFromOtherThread：效果同上，当前执行线程不是NamedThread。 Run：内部执行ProcessTasksUntilQuit。 3. FNamedTaskThread 继承自FTaskThreadBase，用于管理NamedTask。 成员 FThreadTaskQueue Queues[ENamedThreads::NumQueues]：存储Task的队列，分MainQueue和LocalQueue两个。 函数 覆写了ProcessTasksUntilQuit，ProcessTasksUntilIdle，EnqueueFromOtherThread。 4. FThreadTaskQueue NamedTaskThread拥有的Task队列。 FStallingTaskQueue<FBaseGraphTask, PLATFORM_CACHE_LINE_SIZE, 2> StallQueue：包装了两个LockFreelist，对应High和Normal两个优先级，NamedThread的Task只有这两个优先级。 FEvent*StallRestartEvent：当线程执行完Task后，在该Event上等待 五、创建FWorkerThread对象 在TaskGraph Startup时，会根据NameThreads数量，创建对应的FWorkerThread对象，存储在NamedThreads数组中。FWorkerThread初始化主要有两个参数：一个是分配的TLS Slot，用来存它，另一个是FNamedTaskThread对象。 六、GameThread注册到TaskGraph 当前线程调用AttachToThread函数可以把自己注册到TaskGraph中，需要提供一个线程ID。 这是GameThread的注册方式，在Startup后就立即注册了： 接着执行到这里，先根据CurrentThread ID获取到对应的TaskGraphWorker，然后调用InitializeForCurrentThread，该函数会把OwnerWorker存储在PerThreadIDTLSSlot的TLS中。 这样就完成了注册。 其他几个NamedThread也用同样的方式注册。 七、向NameThread添加Task任务 使用Async函数可以向GameThread添加Task，把参数设为EAsyncExecution::TaskGraphMainThread即可。往后的CreateTask等流程都相同，区别只在最后的QueueTask。 这里传入的InThreadToExecuteOn为GameThread，InCurrentThreadIfKnown没有设置，默认为AnyThread，也可以工作。 QueueToExecuteOn表示希望加在MainQueue还是LocalQueue，在外部可以设置。 比较值得注意的GetCurrentThread函数，需要得到当前线程ID，用ENamedThreads表示。 如果是NamedThread，已经设置了TLS，从中取出FWorkerThread指针，然后得到在NamedThreads中的偏移，就是ThreadId。 如果是AnyThread，还会先尝试获取当前线程上的ActiveTask，然后获取ThreadPriority和TaskPriority，一并返回。 最后根据ThreadToExecuteOn和CurrentThreadId，调用EnqueueFromThisThread或EnqueueFromOtherThread，这两个接口区别为前者是当前线程调用的，后者可以由其他线程调用，也可以由当前线程调用，多了一步线程唤醒操作。 EnqueueFromThisThread把Task加到Queues容器中，QueueIndex决定是MainQueue还是LocalQueue，默认MainQueue，然后从之前的ThreadIdAndIndex里获取到TaskPriority，决定加到内部的HighPriority还是NormalPriority Task容器。 EnqueueFromOtherThread也会先把Task加入StallQueue，然后看是否有ThreadToStart，有则调用Trigger，唤醒线程。 八、NamedThread执行Task 以GameThread为例，看如何执行TaskGraph中的Task。 GameThread每帧都会通过World::Tick函数，执行各种Actor的Tick，驱动游戏世界，而各种Tick函数又通过FTickTaskManager管理，背后再转换成一个个TGraphTask，放到TaskGraph中执行。 直接进入FTickTaskSequencer::ReleaseTickGroup函数，这里会执行一个TickGroup中全部的Tick，代码如下： 然后进入WaitUntilTasksComplete函数，执行这些Task。WaitUntilTasksComplete含义是等待这些Task执行完，方法为创建一个FReturnGraphTask，并把要等待的Task设为前置，FReturnGraphTask作用是把FNamedTaskThread.Queue.QuitForReturn设为true，让TaskGraph执行完这些Task后就返回。 WaitUntilTasksComplete 之后执行到ProcessTasksUntilQuit和ProcessTasksNamedThread，不断从Queue中取GraphTask并执行，直到执行了FReturnGraphTask，然后返回。 我们之前通过Async函数向GameThread添加的Task，也是在这里从Queue中取出，然后被执行的。 再借用一张图，描述NamedThreads执行Task的过程： 九、GraphTask的依赖关系 TaskGraph区别于普通线程池的一大特点，就是GraphTask能存在前置依赖，这样可以自定义Task的执行顺序，多线程动画、多线程GC等都是这样实现的。 GraphTask依赖关系需要解决两个问题： 如何组织Task，按照依赖顺序执行这些Task； 等待依赖的Task执行完成会可能造成线程休眠，如何唤醒线程。 以多线程动画更新为示例，看如何建立Task间依赖。动画多线程更新可以把动画的Update、Evaluate开销都放到WorkerThread中，减轻GameThread负担，当SkeletalMeshComponent多时尤为明显。 首先创建一个FParallelAnimationEvaluationTask，用来做动画多线程Update和Evaluate，派发到WorkerThread上执行。然后创建一个FParallelAnimationCompletionTask，用来做动画更新后的PostAnimEvaluation，在GameThread上执行，前置为FParallelAnimationEvaluationTask，这一切都发生在PrePhysics tick阶段。 简单时序图如下： 1. GraphEvent 这里Task依赖通过FGraphEventArray结构实现，而FGraphEventArray其实是一组FGraphEvent的引用，FGraphEvent是Task依赖的关键。 GraphEvent可以理解为GraphTask相关的“事件”，GraphTask之间通过“事件”联系。 2. FGraphEvent 包含了一系列后置Task，该GraphEvent是它们的触发条件。 成员 TClosableLockFreePointerListUnorderedSingleConsumer<FBaseGraphTask, 0> SubsequentList：后置Task，是无锁链表。 FGraphEventArray EventsToWaitFor：该GraphEvent要等待的其他GraphEvent数组，其实只有一个元素，其他GraphEvent完成后，该EventGraph才会触发，在DontCompleteUntil里设置。 方法 AddSubsequent：添加一个后置Task。 DontCompleteUntil：提供一个前置GraphEvent，前置完成后自己才触发。 回顾一下TGraphTask的成员： Subsequents：该GraphTask对应的FGraphEvent。 NumberOfPrerequistitesOutstanding：该GraphTask有多少个前置待执行。 ConstructAndDispatchWhenReady函数会返回GraphTask对应的GraphEvent，外部就能操作它了。 3. CreateTask CreateTask方法可以接受Prerequistes参数，得到该GraphTask的前置，接着进入TGraphTask::SetupPrereqs函数。 会通过AddSubsequent函数把自己添加到所有Prerequisties的后置里，然后会判断Prerequisties是否都完成了，完成后才通过QueueTask把该GraphTask加到Task队列里，等待执行，大部分情况都不会进入，需要等待前置。 4. DispatchSubsequents 在TGraphTask执行完后，会通过Subsequents对象执行DispatchSubsequents，让其他依赖自己的Task执行。这里要分有无EventsToWaitFor的情况。 无EventsToWaitFor： TGraphTask执行完后，就立即触发完成事件，需要遍历所有SubsequentList里的后置Task，调用ConditionalQueueTask，如果后置的所有前置都已被触发，就调用QueueTask，把自己加入Task队列，等待执行。 有EventsToWaitFor： 有时候TGraphTask自己完成了，但不想立即触发事件，还想等待另一个GraphEvent完成后再触发， 比如多线程动画更新里的TickFunction函数，对应的事件要等到TickCompletionEvent完成后再触发。相当于TickFunction Task已经在执行了，但还想给它添加前置一样。 这个操作通过增加一个NullGraphTask完成，这个Task继承了自己的Subsequents，并且把EventsToWaitFor作为自己前置，本身的ExecuteTask并没有任何逻辑，只是为了触发原本的后置Task。 回到动画多线程更新的例子，用图表展示执行流程和GraphTask、GraphEvent的工作过程： 这只是简单的TaskGraph依赖关系，当然可以自己组合出一些多前置，多后置的TaskGraph依赖，背后原理是一样的。 十、NamedThread Sleep/唤醒 多线程动画例子中，如果FParallelAnimationEvaluationTask执行时间过长，GameThread已经把PrePhysics阶段的所有Tick都执行完了，就会进入Sleep状态，等FParallelAnimationEvaluationTask执行完后再唤醒GameThread继续执行。 1. 进入Sleep GameThread在Tick时会执行ProcessTasksNamedThread，While循环从Queue中获取下一个Task，执行到ReturnTask之前都不会退出，如果取不到Task了，说明需要等其他线程执行完前置Task，那么GameThrad自身会在这个Queue的StallRestartEvent上Wait，进入Sleep状态。 StallQueue有设计，可以用一个uint64记录线程是否在StallRestartEvent上Wait，目前支持一个线程，因为StallQueue也是单个FNamedTaskThread对象独有的，但看代码是想设计成支持26个线程。 看下StallQueue的Pop函数： 当没能获取到新的Task时，表示当前Thread要进入Wait了，会修改MasterState，记录下这个线程。MasterState是一个巧妙的uint64位结构，可以同时记录多线程访问信息和等待的线程信息，结构如下： Counter用于多线程保护，每次进Pop和Push都会加1，在修改Ptrs前都会比较一下Counter是否和进函数时相同，防止Pop和Push在不同线程被执行，导致判断不正确。 当Counter判断通过，就会把Ptrs的MyThread位设置为1，表示这个线程在StallRestartEvent上Wait了，目前MyThread固定为0。 2. 唤醒 当调用EnqueueFromOtherThread添加Task后，会判断线程是否在Sleep状态，然后执行StallRestartEvent.Trigger()唤醒线程，继续执行。 StallQueue的Push函数如下： 会从MasterState中寻找Ptrs里被设置为1的位，表示哪些线程在上面Wait，得到ThreadToWake，外层函数再对其调用Trigger唤醒。 十一、一些Task同步函数 当发出多个Task，分派到不同线程执行后，逻辑上通常希望能对这些Task做些同步操作，比如在一个时间点等待这些Task都执行完，或者像动画多线程例子那样给TickFunction加WaitEvent，TaskGraph框架提供了多种这样的函数。 1. TaskGraph接口 WaitUntilTasksComplete(Tasks) 等待多个GraphEvent执行完，内部做法是增加一个FReturnTask，把传入的Tasks作为其前置，然后调用ProcessThreadUntilRequestReturn。 比如如下代码： ProcessThreadUntilIdle 在NamedThread上调用，阻塞执行当前Queue里的所有Task，直到完成。 ProcessThreadUntilRequestReturn 与ProcessThreadUntilIdle类似，只是需要预先添加一个ReturnTask任务。 ProcessThreadUntilIdle和ProcessThreadUntilRequestReturn两个函数通常只有引擎会使用，项目代码里感觉没这个需求。 2. GraphEvent接口 DontCompleteUntil GraphEvent的函数，之前动画蓝图例子已介绍过，会给当前GraphEvent设置另一个Event作为EventsToWaitFor，在EventsToWaitFor触发后，才触发当前的GraphEvent。 流程图见上面。 Wait 内部调用了TaskGraph的WaitUntilTasksComplete接口，把自己作为参数传入，效果与WaitUntilTasksComplete相同。 这是侑虎科技第1932篇文章，感谢作者南京周润发供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/xu-chen-71-65 再次感谢南京周润发的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
UE是怎么管理纹理的各向异性采样的
1）UE是怎么管理纹理的各向异性采样的 2）Unity 2022动态设置光照贴图手机端显示异常 这是第459篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From 问答社区 Q：优化我们UE项目的功耗发热问题时，在移动端真机测试中，通过各种性能工具的参数发现GPU带宽很高，且其中Anisotropic Filtered各向异性过滤的比例一直很高。按之前的经验来说，各向异性对带宽的影响就是很大的，但是在编辑器里没找到设置项，请问UE是怎么管理纹理的各向异性采样的？ A：UE中纹理是否开启各向异性需要检查以下设置： Texture Group 在DeviceProfiles.ini中可以创建TextureGroup并进行设置，例如在Engine\Config\BaseDeviceProfiles.ini中（或Project\Config\DefaultDeviceProfiles.ini）： 其中，MinMagFilter是纹理拉伸时使用的过滤方式，包括point、linear、aniso，此处aniso即开启纹理的各向异性设置。如果想对该Group的所有纹理关闭各向异性设置，可以修改为point或linear。 纹理编辑器设置 打开纹理的资产编辑器，在设置中能够通过Filter设置调整纹理采样方式，可选包括Nearest，Bi-linear，Tri-linear，Default。其中，Default为使用TextureGroup的设置，大部分默认的Group中纹理都开启了各向异性（参考上一条）。此处可以选择另外三种过滤方式，选择后将关闭该纹理的各向异性。该设置适合针对单独纹理关闭各向异性时使用。 各向异性最高采样数设置 各向异性的最高采样数可以通过Cvars：r.MaxAnisotropy进行设置。例如： r.MaxAnisotropy=8，则纹理最高采样数为8。数值为0或1时关闭各向异性。该设置为全局设置，对所有开启了各向异性的纹理生效。在Scalability.ini中各纹理级别已经包含了该设置： 此外在DeviceProfile中，也可以直接在对应平台添加该Cvars的设置： 欢迎大家转至社区交流： https://answer.uwa4d.com/question/69523009244ce21ce9ec0967 From 问答社区 Q：编辑器加载显示都正常，出安卓包，光照贴图出现错乱，使用FrameDebugger抓帧查看数据unity_lightmap有值，使用工具获取LightingSetting中设置的光照贴图也是正常的，Mesh中也添加了对应的光照信息数据。 补充测试点：同一个物件，固定在场景中，光照信息正常，动态加载的物件会出现异常（使用Yooasset进行加载）即使动态重新设置了光照贴图，固定的物件光照信息仍然正常。（提供的图片为手机端的显示和FrameDebugger的抓帧数据。） 为什么Unity 2022动态设置光照贴图手机端会显示异常？ 欢迎大家转至社区交流： https://answer.uwa4d.com/question/694eab9d682c7e5cd61bfb83 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
参数GPU Write Total Bandwidth的含义是什么，导致其值过高的因素有哪些
1）参数GPU Write Total Bandwidth的含义是什么，导致其值过高的因素有哪些 2）对Unity项目来说，性能和效果可以兼顾的物理插件有哪些推荐 这是第458篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：GPU Write Total Bandwidth这一参数的含义，和其过高时可能的影响因素有哪些？ A：GPU Write Total Bandwidth含义： 该指标表示GPU每帧向内存（显存或系统内存）写入的总数据量，单位通常为MB/s或GB/s。它是GPU带宽压力的关键子项，反映帧渲染过程中所有写操作的带宽消耗总和，包括：帧缓冲（Color/Depth/Stencil）、RenderTexture、计算着色器输出、顶点着色器输出（如Transform Feedback）、以及部分缓存写回等。 Write Total Bandwidth 过高的常见影响因素： ✔ 高分辨率多目标渲染（MRT）或高精度帧缓冲 同时写入多个RenderTarget（如GBuffer中Albedo+Normal+Metallic+Depth），尤其使用高精度格式（RGBA16F、RGB10A2）； 主摄像机启用HDR渲染（R11G11B10或RGBA16F），大幅增加Color Buffer写入带宽； MSAA启用后（如4xMSAA），Color/Depth Buffer写入量成倍增长（需resolve前写入+resolve后写入）。 ✔ 频繁或大尺寸RenderTexture写入 全屏后处理中反复创建/清空/Blit到高分辨率RT（如2048×1536的Bloom临时纹理）； UI渲染中大量使用CanvasRenderTarget或Camera.targetTexture； 动态阴影图（Shadow Map）分辨率过高（如4096×4096）且每帧更新。 ✔ 深度/模板缓冲频繁写入与清除 每帧Clear Depth/Stencil（尤其在多Camera、多Pass场景下重复Clear）； 启用深度预通道（Z-Prepass）但未合批或存在冗余绘制，导致两次深度写入（Prepass+Main Pass）； 不合理的ZTest/ZWrite设置（如ZWrite Off缺失导致无效深度覆盖重试）。 ✔ Compute Shader高频大块写入 使用RWTexture2D/RWBuffer执行大规模粒子更新、物理模拟或图像生成，且线程组尺寸过大或Dispatch次数过多； 未使用AppendBuffer/ConsumeBuffer而采用全量写入方式。 ✔ GPU管线冗余写入行为 多个Camera渲染同一区域且未做裁剪/层级隔离，造成重复Color/Depth写入； URP/HDRP中未关闭不必要的Pass（如ShadowCaster、DepthOnly在非阴影场景仍启用）。 快速定位建议： 查看GOT Online GPU报告中Write Total曲线与Front-end Write/Texture Write/Depth Write等子项占比（若支持）； 对比FPS下降时段是否伴随Write峰值，结合GPU Freq是否因带宽瓶颈触发降频； 检查RenderPipeline Asset中是否启用了非必要高精度RT格式、MSAA或多Pass渲染； 使用UWA资源检测报告筛查高分辨率、未压缩的RenderTexture和ShadowMap资产。 优化优先级：降低RT分辨率 → 合并MRT输出 → 关闭冗余Clear/MSAA → 替换高精度格式（如RGBA8替代RGBA16F）→ 后处理降采样。 UWA GPU分析页面中还展示了很多对性能排查分析有一定参考价值的参数和其子参数，若您对这些参数的具体含义和参考价值仍有疑问，可在社区中进行查阅、或尝试使用AI问答助手解答。如以上问题，除了了解写带宽的影响因素外，您还可以提问在不同GPU架构下多种多样的读带宽子项的含义：Front-end Read、LoadStore Read、Texture Read、Vertex Read、SP Read。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=8919019e-4d69-43a2-bba1-39138791414a From UWA AI 问答 Q：对Unity项目来说，性能和效果可以兼顾的物理插件有什么推荐？ A：在Unity项目中，兼顾性能与效果的物理插件需满足三大硬性条件： ✔ 跨平台行为一致（尤其iOS/Android）； ✔ CPU占用可控（单帧<1ms，中端机）； ✔ 支持表现/逻辑分层（避免帧同步风险）。 推荐方案： 首选：Unity原生PhysX+工程化封装（非插件，但最可靠） 适用场景：角色布娃娃、关卡交互物体、简单布料、碰撞反馈。 关键优化实践： 使用Rigidbody.interpolation=Interpolate+collisionDetectionMode=ContinuousDynamic控制抖动与穿透； 所有Collider替换为Primitive Collider组合（禁用MeshCollider，见FAQ文档），复杂模型用BoxCollider+CapsuleCollider分区代理； 物理更新频率降为Time.fixedDeltaTime=0.033f（30Hz），配合Physics.autoSimulation=false手动调度。 高效替代：Obi Softbody & Obi Cloth（商业插件） 优势： 纯C#实现，完全绕过PhysX，iOS/Android行为100%一致，支持帧同步逻辑层（需定点数改造）； 布料解算支持GPU加速（Metal/Vulkan），CPU负载下降60%+； 提供ObiSolver多线程调度器，可绑定至Unity Job System。 注意：需关闭Substeps（默认3→设为1），避免低端机卡顿；布料顶点数建议≤128。 表现专用（零物理开销）：Dynamic Bone（免费开源） 定位：纯表现层附属物模拟（头发、衣摆、尾巴）。 优势： 无刚体、无物理引擎依赖，仅靠Transform插值计算； 支持LOD控制（Update When Inactive+Max Distance）； 源码开放，可无缝接入URP/HDRP。 不适用于受力反馈、碰撞交互等逻辑需求。 明确规避 Havok Physics for Unity：仅限高端主机/PC，移动端无授权支持，且无法保证Android/iOS一致性。 自研PhysX封装库：跨平台浮点误差不可控，已有多款上线项目因PhysX版本差异导致iOS/Android同步失败。 最终建议路径 所有方案均需配合UWA GOT Online的Physics模块实时监控Rigidbody Count与FixedUpdate ms，确保长期稳定。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 背景：前段时间在项目内做了关于Mono内存（堆内存）的优化。从结果上将Mono内存从220MB降低到130MB，优化过程中唤起了部分关于GC的消失的回忆，虽然实际的优化工作中也许并用不到，但是更明确底层实现机制总归是一件迭代自我的过程，在这里就来回顾一下。 一、什么是垃圾回收 - GC（Garbage Collector） 在游戏运行的时候，数据主要存储在内存中，当游戏的数据在不需要的时候，存储当前数据的内存就可以被回收以再次使用。内存垃圾是指当前废弃数据所占用的内存，垃圾回收（GC）是指将废弃的内存重新回收再次使用的过程。 1. 什么时候触发垃圾回收 有三个操作会触发垃圾回收： 在堆内存上进行内存分配操作而内存不够的时候都会触发垃圾回收来利用闲置的内存。 GC会自动触发，不同平台运行频率不一样。 GC被代码强制执行。 2. GC操作带来的问题 直白点就两个问题：一个是Stop-the-world导致的“卡”；一个是内存碎片导致的“堆内存太大”。 GC操作会需要大量的时间来运行，如果堆内存上有大量的变量或者引用需要检查，则检查的操作会十分缓慢，这就会使得游戏运行缓慢。 GC可能会在关键时候运行，例如在CPU处于游戏的性能运行关键时刻，此时任何一个额外的操作都可能会带来极大的影响，使得游戏帧率下降。 另外一个GC带来的问题是堆内存的碎片。当一个内存单元从堆内存上分配出来，其大小取决于其存储的变量的大小。当该内存被回收到堆内存上的时候，有可能使得堆内存被分割成碎片化的单元。也就是说堆内存总体可以使用的内存单元较大，但是单独的内存单元较小，在下次内存分配的时候不能找到合适大小的存储单元，这也会触发GC操作或者堆内存扩展操作。 堆内存碎片会造成两个结果：一个是游戏占用的内存会越来越大；一个是GC会更加频繁地被触发。 特别是在堆内存上进行内存分配时内存单元不足够的时候，GC会被频繁触发，这就意味着频繁在堆内存上进行内存分配和回收会触发频繁的GC操作。 二、Unity托管堆 在讲具体的Unity GC机制之前再回顾一下Unity托管堆。 1. 托管堆的工作原理及其扩展原因 “托管堆”是由项目脚本运行时（Mono或IL2CPP）的内存管理器自动管理的一段内存。必须在托管堆上分配托管代码中创建的所有对象。 Unity官方文档图 在上图中，白框表示分配给托管堆的内存量，而其中的彩色框表示存储在托管堆的内存空间中的数据值。当需要更多值时，将从托管堆中分配更多空间。 GC定期运行将扫描堆上的所有对象，将任何不再引用的对象标记为删除。然后会删除未引用的对象，从而释放内存。 至关重要的是，Unity的垃圾收集是非分代的，也是非压缩的。“非分代”意味着GC在执行每遍收集时必须扫描整个堆，因此随着堆的扩展，其性能会下降。“非压缩”意味着不会为内存中的对象重新分配内存地址来消除对象之间的间隙。 内存空隙 上图为内存碎片化示例。释放对象时，将释放其内存。但是，释放的空间不会整合成为整个“可用内存”池的一部分。位于释放的对象两侧的对象可能仍在使用中。因此，释放的空间成为其他内存段之间的“间隙”（该间隙由上图中的红色圆圈指示）。因此，新释放的空间仅可用于存储与释放相同大小或更小的对象的数据。 这导致了内存碎片化这个核心问题：虽然堆中的可用空间总量可能很大，但是可能其中的部分或全部的可分配空间对象之间存在小的“间隙”。这种情况下，即使可用空间总量高于要分配的空间量，托管堆可能也找不到足够大的连续内存块来满足该分配需求。 如果分配了大型对象又没有足够的连续空间提供使用则： 运行垃圾回收器，尝试释放空间来满足分配请求。 如果在GC运行后，仍然没有足够的连续空间来满足请求的内存量，则必须扩展堆。堆的具体扩展量视平台而定。 2. Unity托管堆的问题 Unity在扩展托管堆后不会经常释放分配给托管堆的内存页, 防止再次发生大量分配时需要重新扩展堆。 在大多数平台上，Unity最终会将托管堆的空置部分使用的页面释放回操作系统。发生此行为的间隔时间是不确定的，不要指望靠这种方法释放内存。 频繁分配临时数据给托管堆，这种情况通常对项目的性能极为不利。 如果每帧分配1KB的临时内存，并且以60帧的速率运行，那么它必须每秒分配60KB的临时内存。在一分钟内，这会在内存中增加3.6MB的垃圾。对内存不足的设备而言每分钟3.6MB的垃圾也无法接受。 三、Unity的GC机制 -- Boehm GC 以前看过Unity使用的GC方案但最近才惊觉现在使用的Unity都是IL2CPP的版本了，所谓的Mono GC本来就已经不存在了。于是来看下现在的IL2CPP的GC机制： Boehm GC（贝姆垃圾收集器）。 1. IL2CPP - Boehm GC 贝姆垃圾收集器是计算机应用在C/C++语言上的一个保守的垃圾回收器（Garbage Collector），可应用于许多经由C/C++开发的程序中。 摘录一段定义： Boehm-Demers-Weiser garbage collector，适用于其它执行环境的各类编程语言，包括了GNU版Java编译器执行环境，以及Mono的Microsoft .NET移植平台。同时支援许多的作业平台，如各种Unix操作系统，微软的操作系统（Microsoft Windows），以及麦金塔上的操作系统（Mac OS X），还有更进一步的功能，例如：渐进式收集（Incremental Collection），平行收集（Parallel Collection）以及终结语意的变化（Variety Offinalizersemantics）。 在Unity中我们可以看到关于Boehm GC的算法部分： BoehmGC.cpp内部调用的就是这个第三方库，他是Stop-the-world类型的垃圾收集器，这表明了在执行垃圾回收的时候，将会停止正在运行的程序，而停止时间的只有在完成工作后才会恢复，所以这就导致了GC引起的程序卡顿峰值，很显然这对游戏的平滑体验造成了较大的负面影响。 通常，解决这个问题的常规方案是尽可能地“减少”运行时垃圾回收（后续用GC代替），亦或者将GC放在不那么操作敏感的场景中，比如回城、死亡后等。但完全避免运行时垃圾回收在大部分时间是不现实的。 接下来我们来看看Boehm GC的背后机制。 2. Boehm GC算法思路 Boehm GC是一种Mark-Sweep（标记-清扫）算法，大致思路包含了四个阶段： 准备阶段：每个托管堆内存对象在创建出来的时候会有一个关联的标记位，来表示当前对象是否被引用，默认为0。 标记阶段：从根内存节点（静态变量；栈；寄存器）出发，遍历扫描托管堆的内存节点，将被引用的内存节点标记为1。 清扫阶段：遍历所有节点，将没有被标记的节点的内存数据清空，并且基于一定条件释放。 结束阶段：触发注册过的回调逻辑。 3. 渐进式GC 使用渐进式GC允许把GC工作分成多个片，因此为了不让GC工作长时间的“阻塞”主线程，将其拆分成了多个更短的中断。需要明确的是这并不会使GC总体上变得更快，但是却可以将工作负载分配到多帧来平缓单次GC峰值带来的卡顿影响。 注: Unity在高版本已经默认是渐进式GC了，大概是Unity 19.1a10版本。 [Unity 活动]-浅谈Unity内存管理_哔哩哔哩_bilibili 4. GC中的内存分配 Boehm GC的使用方法非常简单，只需要将malloc替换为GC_malloc即可，在此之后便无需关心free的问题。 void * GC_malloc(size_t lb) { return GC_malloc_kind(lb, NORMAL); } void * GC_malloc_kind(size_t lb, int k) { return GC_malloc_kind_global(lb, k); } 在整个内存分配链的最底部，Boehm GC通过平台相关接口来向操作系统申请内存。为了提高申请的效率，每次批量申请4KB的倍数大小。 分配器的核心是一个分级的结构，Boehm GC把每次申请根据内存大小归类成小内存对象和大内存对象。 小内存对象：不超过PageSize/2，小于2048字节的对象。 大内存对象：大于PageSize/2的对象。 对于大内存对象，向上取整到4KB的倍数大小，以整数的内存块形式给出。而小内存对象则会先申请一个内存块出来，而后在这块内存上进一步细分为Small Objects，形成free-list。 下面会分别说下大内存对象和小内存对象，参考网上的资料整理，确实有点点干，但是配图我重新做了一下，大概可以辅助消化。 四、IL2CPP - Boehm GC：小内存分配 1. 粒度对齐 实现思路是，提出粒度（GRANULES）的概念，即一个GRANULE的大小是16字节。实际分配内存的时候按照GRANULE为基本单位来分配。分配过程中，按照原始需要的大小，计算并映射得到实际需要分配的GRANULE个数，代码如下： //lb是原始的分配大小，lg是GRANULE（1～128）。 size_t lg = GC_size_map[lb]; 例如需要18字节的内存，则lg=2，即实际分配2个GRANULE（32字节），如果需要1字节的内存，则lg=1，即实际分配1个GRANULE（16字节）。 GC_size_map是一个“GRANULE索引映射表”，用来维护原始分配的内存大小和内存索引之间的关系。最多可以返回128个GRANULE，所以小内存的大小上限是128*16=2048。GC_size_map数组本身会不断加载根据需要不断扩容。 示意 2. 空闲链表 - ok_freelist 决定了GRANULE的大小之后，在申请内存时刻首先会从“空闲链表”中查看是否有空闲内存块，如果有则直接返回这块内存，完成分配，其算法维护了一个数据结构obj_kind： struct obj_kind { void **ok_freelist; struct hblk **ok_reclaim_list; ... } GC_obj_kinds[3]; GC_obj_kinds[3]对应了3种内存类型，分别是PTRFREE、NORMAL和UNCOLLECTABLE，每种类型都有一个obj_kind结构体信息。 PTRFREE：无指针内存分配，明确的告诉GC，该对象内无任何的指针信息，在GC时候无需查找该对象是否引用了其他对象。 NORMAL：无类型的内存分配，因为无法得到对象的类型元数据，所以在GC时会按照只针对其的方式扫描内存块，如果通过了指针校验，就会认为该对象引用了该指针地址指向的对象。 UNCOLLECTABLE：为BOEHM自己分配的内存，这些不需要标记和回收。 每一个obj_kind的结构体都维护了一个ok_freelist的二维指针链表用来存放空闲的内存块。ok_freelist维护了0~127个链表索引。而每一个尺寸的freelist就是对应大小的GRANULE池子，其结构示意如图： freelist示意 于是，根据要申请的内存大小计算得到GRANULE在freelist的索引，然后去查询对应索引的freelist，如果存在空闲看空间ok_freelist[index][ 0]，则将其返回并从链上移除。 ok_freelist链表最初为空，如果ok_freelist中没有相应的空闲内存块，则调用GC_allocobj(lg, k)去底层查找可用的内存。 GC_allocobj的核心逻辑是调用GC_new_hblk(gran, kind)去底层内存池获取内存，并且查看底层内存池中是否分配了空闲的内存块，如果没有则通过系统函数例如malloc分配内存给底层内存池，如果内存池有，直接取出一块返回。GC_new_hblk的代码逻辑如下： GC_INNER void GC_new_hblk(size_t gran, int kind) { struct hblk *h; /* the new heap block */ GC_bool clear = GC_obj_kinds[kind].ok_init; /* Allocate a new heap block */ h = GC_allochblk(GRANULES_TO_BYTES(gran), kind, 0); if (h == 0) return; /* Build the free list */ GC_obj_kinds[kind].ok_freelist[gran] = GC_build_fl(h, GRANULES_TO_WORDS(gran), clear,(ptr_t)GC_obj_kinds[kind].ok_freelist[gran]); } GC_new_hblk的主要逻辑有2步： 调用GC_allochblk方法进一步获取内存池中可用的内存块； 调用GC_build_fl方法，利用内存池中返回的内存块构建ok_freelist，供上层使用。 3. 核心内存块链表GC_hblkfreelist 底层内存池的实现逻辑和ok_freelist类似，维护了一个空闲内存块链表的指针链表GC_hblkfreeelist，但是和ok_freelist不同的是，这个链表中的内存块的基本单位是4KB，也就是一个内存页（page_size）的大小。GC_hblkfreelist一个有60个元素，每一个元素都是一个链表。 4. 内存块 - hblk、头信息 - hblkhdr 链表中的每一个内存块都以大小4096（4KB）为一基本单位，一个大小为4096的内存块被称为hblk，数据定义如下： struct hblk { char hb_body[HBLKSIZE]; //HBLKSIZE=4096 }; 每个hblk拥有一个相应的header信息，用来描述这个内存快的情况，数据的定义如下： //头部信息 struct hblkhdr { struct hblk * hb_next; //指向下一个hblk struct hblk * hb_prev; //指向上一个hblk struct hblk * hb_block; //对应的hblk unsigned char hb_obj_kind; //kink类型 unsigned char hb_flags; //标记位 word hb_sz; //如果给上层使用，则表示实际分配的单位，如果空闲，则表示内存块的大小 word hb_descr; size_t hb_n_marks;//标记位个数，用于GC word hb_marks[MARK_BITS_SZ]; //标记为，用于GC } 5. hblk内存块查询 structh blk *GC_allochblk(size_t sz, int kind, unsigned flags/* IGNORE_OFF_PAGE or 0 */) { ... //1.计算需要的内存块大小 blocks_needed = OBJ_SZ_TO_BLOCKS_CHECKED(sz); start_list = GC_hblk_fl_from_blocks(blocks_needed); //2.查找精确的hblk内存块 result = GC_allochblk_nth(sz, kind, flags, start_list, FALSE); if (0 != result) return result; may_split = TRUE; ... if (start_list < UNIQUE_THRESHOLD) { ++start_list; } //3.从更大的内存块链表中找 for (; start_list <= split_limit; ++start_list) { result = GC_allochblk_nth(sz, kind, flags, start_list, may_split); if (0 != result) break; } return result; } STATIC int GC_hblk_fl_from_blocks(word blocks_needed) { if (blocks_needed <= 32) return blocks_needed; if (blocks_needed >= 256) return (256-32)/8+32; return (blocks_needed-32)/8+32; } 先根据上层需要分配的内存大小计算出需要的内存块大小，如果申请的大小小于4096字节，则结果是1，对于小对象内存块的个数就是1。 根据实际需要的内存块数，判断并决定从哪一个GC_hblkfreelist链表查找，start_list是开始查找的链表index，即从GC_hblkfreelist[start_list]开始查找。并不是需要blocks，就一定会从GC_hblkfreelist[blocks]的链表中查找，遵循转换规则（小内存索引是连续的，中内存索引是32+8的步长，大点的内存索引都是60）。 如果blocks_needed小于32，则startlist=blocks_needed，直接去GC_hblkfreelist[blocks_needed]中查找。 如果blocks_needed位于32～256，则startlist=(blocks_needed-32)/8+32，即blocks_needed每增加8个，对应GC_hblkfreelist[index]的index增加1。 如果blocks_needed大于256，则都从GC_hblkfreelist[60]链表中查找。 决定从哪个链表开始查找之后，首先进行精确查找，如果直接找到，则直接返回找到的内存块。 如果精准查找失败，则逐渐增大start_list，从更大的内存块链表中查找。 STATIC struct hblk *GC_allochblk_nth(size_t sz, int kind, unsigned flags, int n, int may_split) { struct hblk *hbp; hdr * hhdr; struct hblk *thishbp; hdr * thishdr;/* Header corr. to thishbp */ //计算需要分配的内存块大小 signed_word size_needed = HBLKSIZE * OBJ_SZ_TO_BLOCKS_CHECKED(sz); //从链表中查找合适的内存块 for (hbp = GC_hblkfreelist[n];; hbp = hhdr -> hb_next) { signed_word size_avail; if (NULL == hbp) return NULL; //获取内存块的header信息 GET_HDR(hbp, hhdr); //内存块大小 size_avail = (signed_word)hhdr->hb_sz; if (size_avail < size_needed) continue; //可用内存大于需要的分配的大小 if (size_avail != size_needed) { //要求精准不分割，退出循环，返回空 if (!may_split) continue; ... if( size_avail >= size_needed ) { ... //分割内存块，修改链表 hbp = GC_get_first_part(hbp, hhdr, size_needed, n); break; } } } if (0 == hbp) return0; ... //修改header信息 setup_header(hhdr, hbp, sz, kind, flags) ... return hbp; } 当分配字节的时候先通过精确查找如果发现有精确内存，则会返回相应的内存块，如果没有发现精确内存则会去查找更大的内存块并进行分割，一半返回使用，一半放到池子里。 拆分示意 如上图示例，如果要申请1KB，则会先找4KB，如果没有4KB则去找8KB，找到了8KB就进行两个4KB的拆分，然后移除8KB出池子，再把拆分过的另一半4KB内存块加入到池子里： STATIC struct hblk *GC_get_first_part(struct hblk *h, hdr *hhdr, size_t bytes, int index) { word total_size = hhdr -> hb_sz; struct hblk * rest; hdr * rest_hdr; //从空闲链表删除 GC_remove_from_fl_at(hhdr, index); if (total_size == bytes) return h; //后半部分 rest = (struct hblk *)((word)h + bytes); //生成header信息 rest_hdr = GC_install_header(rest); //内存块大小 rest_hdr -> hb_sz = total_size - bytes; rest_hdr -> hb_flags = 0; ... //加入相应的空闲链表 GC_add_to_fl(rest, rest_hdr); } 6. 内存块分配 如果GC_hblkfreelist空闲链表中找不到合适的内存块，则考虑从系统开辟一段新的内存，并添加到GC_hblkfreelist链表中。在GC_expand_hp_inner方法中实现： GC_INNER GC_bool GC_expand_hp_inner(word n) { ... //调用系统方式开辟内存 space = GET_MEM(bytes); //记录内存地址和大小 GC_add_to_our_memory((ptr_t)space, bytes); ... //添加到GC_hblkfreelist链表中 GC_add_to_heap(space, bytes); ... } GC_add_to_heap方法将创建出来的内存块加入相应的GC_hblkfreelist链表中。同时加入一个全局的存放堆内存信息的数组中。 其中如果发现内存连续的前后内存块存在且空闲，则合并前后的内存块，生成一个更大的内存块。 7. ok_freeList 在GC_new_hblk中调用GC_build_fl方法构建链表，就是这个GC系统的缓存池核心数据结构。 //构建ok_freelist[gran] GC_obj_kinds[kind].ok_freelist[gran] = GC_build_fl(h, GRANULES_TO_WORDS(gran), clear,(ptr_t)GC_obj_kinds[kind].ok_freelist[gran]); GC_INNER ptr_t GC_build_fl(struct hblk *h, size_t sz, GC_bool clear, ptr_t list) { word *p, *prev; word *last_object;/* points to last object in new hblk*/ ... //构建链表 p = (word *)(h -> hb_body) + sz;/* second object in *h*/ prev = (word *)(h -> hb_body);/* One object behind p*/ last_object = (word *)((char *)h + HBLKSIZE); last_object -= sz; while ((word)p <= (word)last_object) { /* current object's link points to last object */ obj_link(p) = (ptr_t)prev; prev = p; p += sz; } p -= sz; //拼接之前的链表 *(ptr_t *)h = list; //返回入口地址 return ((ptr_t)p); } 以4096字节的内存块划分为16字节单元的freeList为例，步骤如下： 4096字节按照16字节分配，划分为256个小内存块，编号是0～255，将最后一个内存块（255）作为新链表的首节点。 内存地址向前遍历，建立链表，即255的下一个节点是254，尾节点是0。 将尾节点的下一个节点指向原链表的首地址。 将新链表的首节点地址作为ok_freelist[N]，N是上文提到的GRANULE，例如16字节对应1。 重建好的freeList，并将首节点提供给上层使用。 五、Boehm GC：大内存分配 分配大内存对象是指分配的内存大于2048字节。 OBJ_SZ_TO_BLOCKS用于计算需要的hblk内存块的个数，对于大内存，需要的个数大于等于1。例如需要分配9000字节的内存，则需要3个hblk内存块，然后调用GC_alloc_large分配内存。 GC_INNER ptr_t GC_alloc_large(size_t lb, int k, unsigned flags) { struct hblk * h; word n_blocks; ptr_t result; ... n_blocks = OBJ_SZ_TO_BLOCKS_CHECKED(lb); ... //分配内存 h = GC_allochblk(lb, k, flags); ... //分配失败，系统分配内存块后继续尝试分配 while (0 == h && GC_collect_or_expand(n_blocks, flags != 0, retry)) { h = GC_allochblk(lb, k, flags); retry = TRUE; } //记录大内存创建大小 size_t total_bytes = n_blocks * HBLKSIZE; ... GC_large_allocd_bytes += total_bytes; ... result = h -> hb_body; //返回内存地址 return result; } 大内存分配的内存查找和小对象方式一样，会不断增加start_list。从更大的链表中查找是否有空闲内存，不同的是，如果查找到了空闲内存不会分裂构建ok_freeList链表而是直接返回大内存块的地址提供使用。 六、Boehm GC：内存分配流程图 示意 七、额外：SGen GC Simple Generational Garbage Collection简称SGen GC，是相比Boehm GC（贝姆GC）更为先进的一种GC方式。官方Mono在2.8版本中增加了SGen GC，但默认的仍是Boehm GC。3.2版本之后，Mono正式将SGen GC作为默认GC方式。 SGen GC将堆内存分为初生代（Nursery）和旧生代（Old Generation）两代进行管理，并包含两个GC过程：Minor GC对初生代进行清理；Major GC对初生代和旧生代同时进行清理。 1. 内存分配策略 - 初代 在SGen GC中，初生代是一块固定大小的连续内存，默认为4MB，可以通过配置修改。这一点与G1不同，在G1中同一代的Region在物理上是不要求连续的。 为了支持多线程工作，新对象的内存分配依然在每个线程的TLAB中进行，当前每个TLAB均为4KB，有提到可能会在不久后进行优化。而在TLAB内部，内存分配是通过指针碰撞的方式进行的，也就是说，在SGen GC中，初生代内存并没有进行粒度划分也没有分块管理。 初生代对象跟随Minor GC和Major GC进行回收。 2. 内存分配策略 - 旧代 在SGen GC中，旧生代内存划分方式可以概括为： Section（1MB） → Block（16KB）→ Page（4KB）→ Slot（不同粒度） 在使用内存时，按照上述链条依次向下拆分，与贝姆GC相同，同一个Block中的Page也只能拆分成相同粒度的Slot。 虽然在初生代中并没有划分内存粒度，但是当对象从初生代转移到旧生代时会找到对应粒度的Slot进行存储。释放对象时，对应的Slot也会返还给空闲链表（类似贝姆GC中的ok_freeList），并在某一级结构完全清空时依次向上一级返还。 旧生代内存最终是通过一个GCMemSection结构的链表进行管理的。 3. 内存分配策略 - 大对象 超过8KB的对象均被视为大对象，大对象通过单独的LOSSection结构进行管理。而大对象的内存管理又分为两种情况： 不超过1MB的，仍然存储在Mono自己的托管堆上，清理后返还给托管堆； 超过1MB的，直接从操作系统申请内存，清理后内存也同样返还给操作系统。 4. 内存分配策略 - 固定内存对象 有一些对象被显式或隐式地标记为了固定内存的对象，这些对象在初始时依然被分配在初生代中，但不会被GC过程移动位置。 显式：用户显式声明的，比如通过fixed关键字进行修饰； 隐式：在GC开始时，所有寄存器和ROOT中直接指向的对象都视为固定内存对象。 这是侑虎科技第1924篇文章，感谢作者Jamin供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/liang-zhi-ming-70 再次感谢Jamin的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
哪些因素和参数会影响Bloom的性能开销
1）哪些因素和参数会影响Bloom的性能开销 2）为何Scripting Threads的#0线程数量异常 这是第457篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：哪些因素和参数会影响bloom的性能开销？ A：Bloom（泛光）的性能开销主要来自GPU带宽、计算量与多级采样次数，其关键影响因素和可调参数如下： 一、核心性能影响因素 采样分辨率与下采样层级 Bloom默认从1/2渲染分辨率开始逐级下采样（如Blur1–Blur5），分辨率越低（如1/16）、层级越多，带宽越低但模糊质量下降；反之，高分辨率采样（如1/2）显著增加纹理读取带宽和显存吞吐压力。 移动端建议从1/4或1/8起始，减少首层采样开销。 BloomQuality级别 r.BloomQuality=5（默认）启用全部5层模糊+高质量卷积，开销最大；=1~3可禁用部分层级或使用简化高斯，大幅降低GPU耗时。 ScreenPercentage控制卷积精度 r.Bloom.ScreenPercentage=50表示FFT卷积在50%分辨率下执行；提高该值（如至100%）会线性增加计算与带宽，需谨慎权衡。 泛光卷积（Convolution）启用 启用Bloom Convolution会触发FFT卷积运算，依赖全分辨率内核纹理+额外缓冲区（r.Bloom.ConvolutionBuffer），显著提升GPU计算与显存带宽压力，仅推荐高端设备或离线渲染使用。 后处理链路叠加效应 Bloom常与Tonemapping、Depth of Field、Motion Blur等串联，导致多次全屏纹理读写。尤其在移动平台，多Pass后处理易引发带宽瓶颈（实测可达总GPU带宽的20%+）。 二、关键可调控制台变量（Unreal Engine） 三、优化建议 优先降级BloomQuality+ScreenPercentage，比关闭Bloom更能平衡画质与性能； 使用Stat GPU和Stat RHI定量定位Bloom耗时（查看PostProcessBloom阶段）； 避免在低端机启用Convolution或Dirt Mask（额外纹理采样+混合）； 结合r.VSync与帧率限制，防止Bloom在高帧率下反复重采样。 注：Bloom对GPU温度与功耗影响显著 —— 实测10GB/s带宽可贡献约1000mW功耗（占整机20%+），是移动端发热主因之一。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=f3f99f69-7e55-4af8-9a58-2531d33ec39a From UWA AI 问答 Q：请教个问题, 我们有用到线程池, 我在Profiler的时候看到在Scripting Threads里面有几十个#0的线程显示, 这个是正常的吗？ A：是不正常的，需立即排查。 Unity的Profiler中出现大量名称为#0的Scripting Threads，通常表明： 线程未正确命名或未注册：Unity默认对System.Threading.ThreadPool中的线程不自动命名，Profiler无法识别其用途，统一显示为#0（或空名+编号）。 线程泄漏风险高：若线程池长期持有大量闲置线程（尤其在Unity 2019 – 2022版本中），可能因ThreadPool.SetMinThreads调用不当、异步任务未完成/未释放、或Task.Run+长阻塞操作导致线程堆积。 非Unity管理线程被误纳入Profiler视图：#0线程无法通过Profiler.BeginThreadProfiling()注册，说明它们未被主动追踪，也意味着你无法在Timeline中定位其耗时来源。 建议动作： 检查是否滥用Task.Run或ThreadPool.QueueUserWorkItem；优先改用Unity Job System或Unity.Collections.LowLevel.Unsafe异步方案。 确认无new Thread(...).Start()后未调用Profiler.EndThreadProfiling()（否则资源泄漏且线程残留）。 在Editor中启用Call Stacks+Deep Profiling，筛选GC Alloc/CPU高耗时帧，定位创建线程的调用栈。 使用UWA GOT Online的Mono/Threading模块，查看线程数趋势与存活时间，确认是否随游戏时长持续增长。 注：Unity官方不鼓励直接使用.NET线程池进行高频短任务调度 —— Job System才是跨平台、可Profiler可视化的首选。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=3ba6d84d-9e4e-4fb2-a66f-f6fdc562b15f 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
1）如何减少托管堆内存碎片 2）MIX贴图中的sRGB（Color Texture）是否要取消勾选 这是第456篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：如何减少托管堆内存碎片？ A：减少托管堆内存碎片是提升游戏运行稳定性和降低GC压力的关键。内存碎片会导致堆空间利用率下降，即使总空闲内存足够，也可能因无法分配连续大块内存而触发不必要的GC或导致分配失败。 1. 识别与诊断 首先需确认是否存在严重碎片问题。使用UWA GOT Online的Mono模式进行测试，重点关注： Mono堆内存总量：观察其增长趋势。 Empty Heap Space（空闲堆空间）：若该值持续增长且占比高，表明存在大量碎片。 GC Allocated波形与实际耗时匹配性：如波峰明显但数值不高，可能因碎片导致GC效率低下。 2. 核心优化策略 避免高频小对象分配 减少byte[]、MemoryStream、DeflateStream等频繁创建的大数组操作。 使用对象池复用常用对象（如List、StringBuilder），避免重复分配与回收。 对于字符串拼接，优先使用StringBuilder并预设容量，避免多次扩容引发的复制。 优化集合类使用 预估集合大小并初始化时指定容量（如new List(100)），防止动态扩容产生临时数组。 避免在循环中调用LINQ方法（如Where、Select），因其常生成迭代器对象造成额外分配。 资源加载与数据处理 加载二进制数据时，使用预先分配的缓冲区（如UnityWebRequest配合DownloadHandlerScript传入固定byte[]），避免每帧生成新数组。 资源按需加载，避免一次性加载过多Asset导致堆膨胀。可结合Addressables实现细粒度管理。 代码层规避装箱 避免将值类型作为Object传递（如字典键值、事件参数）。 使用泛型替代非泛型集合（如Dictionary<int, T>而非Hashtable）。 扩展支持泛型参数的格式化方法（如AppendFormat），消除StringBuilder.AppendFormat中的装箱。 3. 引擎与配置调优 禁用不必要的反射：Type.GetProperty()、GetValue()易引发AOT问题且不利于剥离，尽量通过接口或委托替代。 合理设置GC策略：在移动平台启用增量式GC（Incremental GC），分散收集压力。 控制场景切换频率：频繁加载/卸载场景会加剧堆碎片，可通过异步加载与资源预加载平滑过渡。 4. 工具辅助分析 利用Unity Profiler的Memory模块截取堆快照，查看大块空闲区域分布。 结合GOT Online报告中的驻留对象分析，定位长期存活的小对象来源，针对性重构。 通过以上措施，可显著降低托管堆碎片率，提升内存利用效率和运行流畅度。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=49ae18c9-5cb3-4f4d-a4d4-a2b451551c11 From 问答社区 Q：我在排查项目中材质资源的时候发现MIX贴图中sRGB（Color Texture）有的有勾选，有的没勾选。 问了AI都是建议取消勾选，但是美术那边又说取消勾选可能效果就不对了。 这边还是问下诸位同僚，你们项目中的MIX贴图里的sRGB（Color Texture）选项是否都是取消勾选的？ A：和美术这边做了几个测试，确认是得取消勾选的。 之前导入资源时默认勾选了这个选项，导致后续都是在错误的基础上调整效果，所以一开始取消勾选会认为效果不对。 实际上是暴露了错误参数下的效果（比方说光滑度过高导致取消勾选后物件变得油腻）。 感谢题主土司喵@UWA问答社区提供了回答，有经验的朋友欢迎转至社区交流： https://answer.uwa4d.com/question/692eb082682c7e5cd61bfb78 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 当有持续时间短，又比较杂的异步任务时，可以使用ThreadPool，用固定数量的工作线程执行任务，不每次都创建新线程。UE4和UE5的线程池有很大区别，UE4线程池会真的创建很多线程，而UE5主要线程池底层复用了TaskGraph的线程，线程池只是逻辑上的概念。 一、创建线程池 线程池在FEngineLoop::PreInitPreStartupScreen函数中创建。 GThreadPool 类型为FQueuedLowLevelThreadPool，是UE5中的新实现，线程数量由FPlatformMisc::NumberOfWorkerThreadsToSpawn()确定。 GIOThreadPool 类型为FQueuedThreadPool，线程数量由FPlatformMisc::NumberOfIOWorkerThreadsToSpawn()确定，Client为4，Server为2。 GBackgroundPriorityThreadPool 类型为FQueuedThreadPool，Client为2，Server为1。 GLargeThreadPool 类型为FQueuedLowLevelThreadPool，数量由FPlatformMisc::NumberOfCoresIncludingHyperthreads()确定。 二、使用线程池 虽然线程池实现比Runnable复杂，但使用方式也比较简单。 1. Async函数 最常见用法，Async函数可设置EAsyncExecution::ThreadPool参数，指定任务在ThreadPool里执行。 函数内部会创建TAsyncQueuedWork封装Function和Promise，然后使用AddQueuedWork接口把任务加到GThreadPool中。 AddQueuedWork是线程池最重要的接口。 2. AsyncPool函数 与Async类似，但可以指定线程池和Work优先级。 3. 手动调用AddQueuedWork AddQueuedWork函数只需要接受IQueuedWork作为参数，TAsyncQueuedWork只是一个子类，我们可以创建子类，做自定义操作，这样也能指定使用哪个线程池。 比如引擎中Encode LightMap的操作，就使用了FAsyncEncode类： 三、线程池实现 1.类型定义 类型定义可分为线程池，线程池线程，任务。 线程池 FQueuedThreadPool：线程池基类，定义了线程池的接口。 Allocate：创建线程池，类型为FQueuedThreadPoolBase。 Create：创建若干工作线程。 AddQueuedWork：向线程池添加任务。 RetractQueuedWork：撤回任务。 AddQueuedWork和RetractQueuedWork是线程池提供给外部调用的主要接口，注意会在多线程中被调用。 FQueuedThreadPool有多种实现： FQueuedThreadPoolBase 最常用，线程池的基础实现，GIOThreadPool和GBackgroundPriorityThreadPool都会使用。 成员： FThreadPoolPriorityQueue QueuedWork：待处理任务的队列。 TArray<FQueuedThread*> QueuedThreads：等待接收任务的空闲线程。 TArray<FQueuedThread*> AllThreads：所有工作线程。 FCriticalSection* SynchQueue：保护任务队列的CriticalSection，因为任务队列会被多线程修改。 FQueuedLowLevelThreadPool 底层线程使用TaskGraph的ThreadPool，UE5中GThreadPool的默认实现。 FQueuedThreadPoolWrapper FQueuedThreadPoolDynamicWrapper FQueuedThreadPoolTaskGraphWrapper 线程池线程 FQueuedThread：继承自FRunnable，表示线程池中的工作线程。可以想象，它大部分时间都处于idle状态，当有任务来时才工作。 成员： DoWorkEvent：通知线程有任务要执行的Event。 QueuedWork：当前线程正在执行的Work。 Thread：Runnable对应的线程。 函数： Run：主函数，可认为是一个等待、执行任务的循环。 DoWork：由ThreadPool调用，传入一个任务并执行。 任务 IQueuedWork：可排队任务的基类接口，供线程池使用。 接口： DoThreadedWork：执行任务。 IQueuedWork有多种实现： TAsyncQueuedWork 最常用，Async和AsyncPool函数中使用。 DoThreadedWork：通过SetPromise执行任务。 FAsyncTaskBase 可操作内容更多。 DoThreadedWork：通过Task执行任务。 类图如下： 常用部分已高亮显示 2. FQueuedThreadPoolBase 线程池创建 FQueuedThreadPoolBase是默认线程池，FQueuedThreadPool::Allocate函数中构造。 线程池通过Create函数初始化，主要工作是创建InNumQueuedThreads数量的工作线程，使用FQueuedThread类封装，并把创建的线程加入QueuedThreads和AllThreads容器中，QueuedThreads中存储了当前线程池中处于空闲状态的线程。还要创建CriticalSection对象SynchQueue，用于保护对QueuedWork和QueuedThreads的访问。 FQueuedThread FQueuedThread继承自FRunnable，是一个可运行任务的抽象，其Create函数如下。首先创建DoWorkEvent，用于做多线程同步，然后创建一个底层的Thread。线程创建好后进入Run方法，初始没有任务，线程在DoWorkEvent上等待，处于休眠状态。 添加任务 观察AddQueuedWork函数，添加任务时分成了两种情况。 如果线程池中尚有空闲线程，即下图中的情况1，QueuedThreads中有元素，那么把任务分配给其中一个线程即可，这里还有一个细节，QueuedThreads采用栈管理，先进后出，这可以更好利用CPU Cache，因为这个Thread可能刚运行过，同时也可以避免数组中的元素移动。得到Thread后，调用DoWork方法添加任务。 另一种情况是所有线程都在忙碌，QueuedThreads中没有元素，这时只能把InQueuedWork暂存到QueuedWork中，等线程执行完之前任务后再做处理。 FQueuedThread::DoWork方法用于通知一个Thread要执行任务了，首先把InQueuedWork设置到其QueuedWork属性上，然后执行DoWorkEvent的Trigger方法，唤醒该Thread。注意这里加了一个MemoryBarrier，是为了避免CPU指令乱序优化导致1071行在1074行之后执行，导致错误。 执行任务 执行任务通过属性的Run函数实现。Thread一开始会在DoWorkEvent上等待，被DoWork函数唤醒后，会获取之前被赋值的QueuedWork，执行DoThreadedWork函数，这里是真正执行任务。执行完成后再调用ThreadPool的ReturnToPoolOrGetNextJob函数，尝试获取暂存的QueuedWork并执行，若没有就把Thread归还到QueuedThreads中，之后在DoWorkEvent上等待，进入休眠状态。 流程图示： 3. TAsyncQueuedWork 线程池中的任务，包装了一个Function对象，DoThreadWork函数中使用给Promise SetValue的形式来执行Function。 以上就是UE线程池常用的FQueuedThreadPoolBase，FQueuedThread，TAsyncQueuedWork组合。 以下内容是UE5的改动。 4. FQueuedLowLevelThreadPool 在UE5中，非Editor模式下GThreadPool实现变成了FQueuedLowLevelThreadPool。底层使用了TaskGraph，相关内容放在后面看，这里只分析与线程池相关的部分。 UE希望把多线程操作尽量放在TaskGraph里，这样好管理。CPU物理核心数量是有限的，如果TaskGraph和ThreadPool都创建了核心数量的线程，其实在各自管理，两边线程都跑满就会产生更多的CPU调度开销。 Create 其实不需要Create了，因为自己不创建线程，初始化在构造函数里完成，主要任务是获取LowLevelTasks::FScheduler单例。 FQueuedThreadPool::Create只是实现一下纯虚函数。 LowLevelTasks::Fscheduler管理了TaskGraph中的Workers线程，包括ForegroundWorkers和BackgroundWorkers，向Worker线程分发任务，细节后面再看。 5. AddQueuedWork 首先创建FQueuedWorkInternalData对象来存储QueuedWork相关数据，然后设置到InQueuedWork.InternalData属性。 FQueuedWorkInternalData类包装了一个LowLevelTasks::FTask，FTask用于把QueuedWork包装成TaskGraph里可执行的东西。Retract函数用于取消任务，但线程池场景下不需要考虑取消。 Task.Init函数调用有点绕，464行先把InQueuedWork包装成一个Lambda函数，然后在Init实现里面再把Lambda包装到另一个TFunction里面。这样就把InQueuedWork存到Task里面了，往后操作只和TaskGraph有关，与线程池无关了。 FScheduler::TryLaunch把Task添加到任务队列中，等待Worker线程来消费。 6. 执行任务 TaskGraph中Worker线程的Run函数会循环获取任务执行，细节放后面TashGraph里看，这里只看一个调用栈。 下图中1的位置是Worker线程取Task，2的位置是执行InQueuedWork->DoThreadedWork()，终于又回到了线程池。 总体来看，FQueuedLowLevelThreadPool其实就是TaskGraph，和Async函数中传EAsyncExecution::TaskGraph是一个效果。 7. FQueuedThreadPoolWrapper 不是真正的线程池，而是另一个线程池的包装，任务都会转发过去。UE5 Editor下GThreadPool就会设置成这个，包装了GLargeThreadPool，目的为共用GLargeThreadPool中的线程，类似FQueuedLowLevelThreadPool共用TaskGraph的线程，因为Editor下后台任务更多，因此单独使用了GLargeThreadPool。这么做的目的还是减少线程创建。 主要成员 FQueuedThreadPool* WrappedQueuedThreadPool; 包装的ThreadPool。 TArray<FScheduledWork*> WorkPool; Work集合。 TMap<IQueuedWork*, FScheduledWork*> ScheduledWork; 当前正在被执行的Work。 std::atomic MaxConcurrency; 最多允许多少Work在后台线程池中运行。 std::atomic CurrentConcurrency; 当前在后台线程池中运行的Work。 FScheduledWork 成员中出现了FScheduledWork类型，它是一个容器，存储了真正的IQueuedWork，同时也是IQueuedWork的子类，有DoThreadedWork接口。 其中128行执行了异步任务，131行通知FQueuedThreadPoolWrapper任务执行完，可调度下个任务，会在下面介绍。 初始化 构造函数如下，主要接受一个线程池作为后台线程池，InMaxConcurrency表示最多同时在后台线程池中执行多少个任务。 AddQueuedWork AddQueuedWork首先把任务加到QueuedWork中，然后执行Schedule函数，默认参数为空。 Schedule函数最重要的是下面几行。首先从QueuedWork中获取要执行的任务，然后递增CurrentConcurrency。接着通过AllocateWork获取一个FScheduledWork对象，并把InnerWork封装在里面，然后把FScheduledWork交给后台线程池运行。 WorkPool容器就缓存了已创建的FScheduledWork对象，AllocateWork会首先从中获取，没有再创建，避免性能上的浪费。 执行 FScheduledWork执行完DoThreadedWork后，会调用Release，继续让线程池执行剩余任务，并把自己重置，加入WorkPool中，等待下次使用。 图示如下： 这是侑虎科技第1917篇文章，感谢作者南京周润发供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/xu-chen-71-65 再次感谢南京周润发的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
1）如何解决ProtoBuf反序列化中的GC高的问题 2）如何在编辑器中集成实时查看Mipmap使用层级工具 这是第455篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：有没有比较好的解决ProtoBuf反序列化GC的方案？ A：在Unity项目中，使用ProtoBuf进行反序列化时产生的GC主要来源于两方面：内存分配的临时对象（如字符串、装箱操作）和反射机制的低效调用。以下是针对这些问题的成熟且高效的解决方案。 1. 使用[Preserve]与强制AOT初始化避免反射GC IL2CPP环境下，由于AOT编译限制，ProtoBuf生成代码中的泛型反射方法可能未被提前编译，导致运行时尝试动态调用而崩溃或触发大量GC。为确保所有必要的反射初始化代码被保留： using Google.Protobuf.Reflection; using UnityEngine.Scripting; [Preserve] private void ForceReflectionInitialization() { // 显式调用每个消息类型的ForceReflectionInitialization FileDescriptor.ForceReflectionInitialization<MyMessage1>(); FileDescriptor.ForceReflectionInitialization<MyMessage2>(); // 添加所有用到的消息类型 } 此方法无需调用，仅需存在即可被链接器保留。配合[Preserve]特性防止代码剥离，可有效解决因AOT缺失导致的异常及性能损耗。 2. 避免字符串与值类型装箱，实现0GC解析辅助 参考TMP文本优化思路，可对ProtoBuf解析过程中的输出处理进行定制，减少中间对象分配。例如，在自定义的日志系统或UI更新中直接复用StringBuilder写入，而非通过ToString()创建新字符串。 全局复用StringBuilder用于格式化输出。 对数字字段转字符串时，采用无GC的NumberFormatter.NumberToString(ref char[] buffer, int value)方式，将结果写入预分配字符数组。 自定义IFormatter实现，绕过装箱操作，直接处理基本类型。 3. 使用原生字节访问替代高开销API 对于高频解析场景（如网络同步），考虑绕过完整反序列化流程，直接读取字节流中的关键字段： // 使用CodedInputStream直接读取特定字段 var input = CodedInputStream.CreateInstance(data); while (!input.IsAtEnd) { var tag = input.ReadTag(); switch (tag.FieldNumber) { case 1: int hp = input.ReadInt32(); break; // 只解析必要字段 } } 这种方式跳过对象构建开销，适用于只关心部分数据的轻量级更新。 4. 缓冲池管理Message实例 频繁创建/销毁ProtoBuf对象会加剧GC压力。建议使用对象池缓存常用Message实例： public class MessagePool<T> where T : IMessage, new() { private static readonly Stack<T> _pool = new(); public static T Get() => _pool.Count > 0 ? _pool.Pop() : new T(); public static void Release(T msg) { _pool.Push(msg); } } 结合Clear()重置状态后复用，显著降低内存分配频率。 综上，核心策略是：强制AOT初始化 + 禁止代码剥离 + 复用缓冲区 + 裁剪不必要的反序列化流程。这些方法已在多个上线项目中验证，能有效将ProtoBuf相关GC降至接近零。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=228136d0-985e-4af9-8c83-f84b04834677 From 问答社区 Q：最近在优化贴图内存，请问有没有开源库或者计算方法，可以检测贴图在屏幕上的占比情况？ 比如，一张1024的贴图，如果渲染时一直使用很高层级的Mipmap，就说明其实不需要1024这么高的分辨率。我记得有种工具还能通过颜色可视化，我主要想集成在编辑器里面实时查看修改。 A：URP里面编写Mipmap工具可以参考以下链接： Scene View Debug Modes in the Unity URP A way to visualize mip levels 欢迎大家转至社区交流： https://answer.uwa4d.com/question/6925280c682c7e5cd61bfb76 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859