UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - 厚积薄发
UWA旨在诊断与优化游戏和VR项目开发过程中潜在的运行性能问题，帮助项研发团队快速检测项目运行时的性能瓶颈，高效定位问题根源，及时反馈针对瓶颈问题的深度分析和解决方法。
1）堆内存对象的Managed Size具体是如何计算的 2）微信小游戏项目，跑图过程场景加载比较慢如何优化 这是第464篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From 问答社区 Q：在Memory Profiler观察到一个长度为9的字符串对象Managed Size为40B，显然不止每个字符2字节。请问这个应该是怎么算的？ A：堆内存对象的Managed Size可以认为是以下四个部分： 类型对象指针； 同步块索引； 专属属性（比如数组和String就有一个int32 length 4B）； 实例字段。 针对每种对象这四个部分可能都有不同，最好的办法就是直接看下源码里这个类的构造。比如String： 它就是8 (*monotor) + 8(union) + 4(length) + 2( N+1(\0) )，特殊之处就是多了个\0，相当于长度要算N+1。所以总共是22+2*N字节。 其中为什么每个对象都要额外分配类型对象指针和同步块索引，导致一定的基础占用，原理上可以参考一些官方文档或者社区文章。 《.NET 框架内部结构：CLR 如何创建运行时对象 |Microsoft Learn》 欢迎大家转至社区交流： https://answer.uwa4d.com/question/69858fdcabed2e338a7dac5e From 问答社区 Q1：目前有个点比较陌生，就是小游戏的每秒资源后台最大同时下载数量，和下载文件大小的指标比较缺失， 这块会影响玩家跑图的过程中场景加载比较慢的问题。 请问有没有官方大数据指标？ A：官方说法是小游戏资源下载并发数为10，超过时底层自动排队；单个请求文件最大不超过100MB（理论最大值，但考虑到带宽，建议单文件2~5MB以内）。 Q2：由于我们是横屏地图，真机上持续控制角色移动10秒以上，停下后，附近场景资源下载大概要等10秒才加载完整。 大概200个资源，请问有什么解决的方法？ A：这种情况看起来是没有用WXAssetBundle的接口进行AssetBundle加载，一般更推荐用这个，而不是原生的AssetBundle加载接口。WXAssetBundle的加载接口会把网上下载的东西缓存到本地，下次直接从本地加载，而且这个接口会有自动卸载的功能，总体内存占用也会更小一些。其他的优化方法就是资源设置以及打包策略的问题了。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/6985999eabed2e338a7dac60 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ Lyra项目，被誉为UE5的官方教学示例，实际上却远超初学者的难度。官方定义为初学者游戏，但真正掌握它，却需要较深的技术背景和至少200小时的UE开发经验。 视频地址： 【Inside Lyra】之我要学UE 引言 Lyra项目，是伴随着UE5一起发布的官方教学的示例项目，全称是Lyra Starter Game。官方定义为初学者游戏。 但学过的人都知道，这个示例项目远远超出了初学者的难度。简单来说，如果自学Lyra的话，你至少需要掌握以下条件： 1.熟练掌握UE特化C++编程。这就表示，你需要在熟练掌握普通C++的基础上，额外掌握Unreal为C++做的特性化封装，比如：以UHT（Unreal Header Tool）为代表的反射系统，以UObject为代表的UE对象系统，以TSharedPtr等为代表的智能指针和垃圾回收系统。 2.熟练掌握UE编辑器与核心框架。这表示，你需要能够熟练使用UE的编辑器，至少能知道绝大多数功能的入口，以及常用功能的位置和使用方法。在展示某个窗口的时候，你应该知道它是如何打开的，以及它包含哪些操作和信息。掌握核心框架则代表，你了解UE的模块化构建和依赖规则，了解事件委托机制和用法，了解UE的资产序列化与引用，了解常用的UE容器，以及核心的Gameplay框架等等。 除此之外，还希望你拥有至少2年的编程经验，或者至少1年左右的游戏开发经验。这表示，你应该能秒懂某些约定俗成的知识点，比如单例和工厂，广播和分发，会话和鉴权，缓存与热修等等。 低于上面这个门槛的同学，自学Lyra就如同在啃天书一样。并且从经验上来说，200小时的UE开发经验是达到高效学习Lyra的临界值，低于此时长的开发者自学Lyra的放弃率高达 90%。 但可以这么说，如果你能基本看懂并理解Lyra的所有代码，那么月薪2W是简简单单的。如果你能熟练掌握和使用所有的Lyra代码，做个主程也是随随便便的。 本文就是帮大家剖析一下Lyra这个项目，减少自学的挫败感，希望能够尽快掌握这个“工业级”入门游戏，提高自己的游戏开发水平。所以，我将这个系列定义为Inside Lyra。 在开始之前，再次强调一下，本文不是0基础，不会讲解诸如编辑器有几个面板，蓝图有几种类型，用TArray等容器写一个用于排序的Lamba表达式之类的基础知识。而是，尽量为大家深入浅出的去剖析，整个Lyra项目的运作流程，以及各个工业级框架和组件之间的协同关系等。 本文的目标是，能正常打开UE编辑器并运行Lyra就成功。 一、为什么是UE？ 由于本文的目标非常简单，在开始之前我们还是先说明几个前置话题。 我拥有10多年Unity开发经验，过去也一直是在做Unity引擎相关的教程，那么这次为什么选择Unreal的项目做教程呢？从程序的角度来分析一下，主要有以下几点： 1. 引擎源码开放。 这一点对技术开发来说非常重要，非常非常重要。虽然引擎源码会大幅增加项目开发时的复杂度，从而导致学习和开发效率的锐减，但一旦你迈过这个门槛，从学习者变为熟练使用者之后，你才知道引擎源码对于开发者来说是多么提高效率的事情。你无需再盲猜每个功能背后的实现思路，你可以随意修改引擎源码完成特殊功能的适配，你可以随时断点来调试某个功能的调用顺序，你可以自己修复引擎版本自带的BUG，你甚至可以直接Copy引擎模块代码来实现一个比较接近的功能，等等。虽然我自己也能接触到其他引擎的源码，但绝大部分的开发同学是没有这个条件的。因此选择一个提供源码的引擎，对于开发者而言，会得到前所未有的控制力与安全感。 2. 预置Gameplay框架。 从这一点上来说，我个人觉得是有利有弊的。如果大家接触过其他引擎就会知道，大部分引擎在开发项目的时候，是可以自由地定制项目的启动入口的，并且启动的流程也可以完全自定义。但是UE不是，它提供一套自己的Gameplay框架，开发者必须完全理解这套机制，并且在这个基础上穿插去做自己的定制流程。很难说这两种方式，谁更好。自由度高意味着，复用性低。而自由度低则意味着，可以举一反三。 也可以这么认为，当你理解了一个UE项目的启动流程之后，你就可以快速上手任何一个UE项目。但相比于可自由定制的项目来说，这增加了初学者的学习门槛，也是学习UE会卡关的主要问题。在其他引擎上，你花20个小时可能已经做出一个有点意思的小游戏了，而在UE上，20个小时可能连Gameplay框架还没学完。但总结来说： 用短期学习成本换取长期团队协作效率与项目可维护性的指数级提升，是非常有必要的，因为你不可能一直是一个人在战斗。 3. 强大完整的服务器。 UE采用的是服务器同构设计，同构设计代表的是服务器逻辑和客户端逻辑是同一套代码。也就是说服务器可以完全复用所有为该项目实现的框架和代码。以基于Actor级别的同步能力，和可靠的同步机制，让开发者完全不需要关心通信。使绝大多数的异常可锁定在游戏逻辑层，而无需反复排查底层同步机制，减少开发者在定位问题上的尝试，提高开发和调试效率。同时，也可以任意部署在本地或者直接用编辑器充当服务器来调试相关逻辑。但缺点也是有的，因为自己又当客户端，又当服务器，难免会在系统在开发的时候发生“精神分裂”，因为你要时刻切换自己的身份，思考某句代码是执行在权威服务器上，还是在本地客户端上。 4. 完整的无缝大世界方案。 无缝大世界，可以说是次世代开放世界游戏的标配了。在其他引擎中完成这个方案的代价非常高，而在UE中，只需在创建地图时，简单勾选几个选项，即可激活基础框架，配合Data Layers可快速实现昼夜/环境破坏等动态效果。这可以让开发者的精力从实现无缝大世界本身解放出来，全力投入内容创作与玩法设计上。 5. 影视级的动画系统。 内置Control Rig+Motion Matching等工业流水线，搭配IK/FK重定向的工具链，让中小团队也能产出3A级动画的丝滑表现。 6. 完善的编程规范和命名规范。 UE为所有代码和资产提供了一套命名规则，所有开发人员需要共同遵守，甚至部分规则如果不遵守还会强制报编译错误，因为代码可读性、可维护性是一个团队高效协作的利器。 7. 蓝图。 是一把威力与风险并存的双刃剑。无需代码，策划/美术也能快速搭原型、配置数据，对UE新手友好。但从开发效率上和逻辑可读性上来说，远远落后于C++。当哪天你突然意识到蓝图实现某个功能很麻烦的时候，那么恭喜你，你现在是个程序员了。 8. 多线程安全。 在UE中，你可以通过Task Graph和AsyncTask提供高层抽象，在规范使用下大幅降低死锁/竞态风险。傻瓜式的封装，可以让你随意指定线程池（游戏/渲染/后台）来干活，完成后用委托（Delegate）再回调到Game线程去处理（非线程安全操作必须回主线程！）。 以上并不是全部的UE优势，UE最大的优势其实是他们每个版本都提供最前沿的技术，供开发者学习和了解，并且非常慷慨地给出所有资源和示例代码。甚至官网的文档都远远落后于代码的更新速度。 所以综合而言，选择UE，其实是选择一艘拥有核动力引擎的工业巨轮。启航虽慢（学习曲线陡），转向笨拙（框架约束），但其源码级的掌控力、成熟的Gameplay框架、高效的同构Server、革命性的大世界/动画方案、规范的代码环境、灵活的代码/蓝图模式、强悍的并发异步工具链等能力，让你在开发3A级巨制或大型多人游戏的惊涛骇浪中，拥有无与伦比的稳定性、生产力与技术上限。它逼你成长为“正规军”，过程虽让人头秃，但学成之日，你便是战场上的“王牌舰长”！ 所以，就你了，Unreal，启动！ 二、为什么是Lyra？ Lyra一直以来被当做UE官方的“初学者示例”，其实是存在认知错位的。它本质是Epic用来展示UE5工业级开发范式的技术演示，而非真正的零基础教程。它是一个完整的项目，包含各种顶级的框架设计和UE5最新的引擎特性。比如： 1.它有约15万行的代码和200+个蓝图，规模远超独立小游戏的标准。 2.多层抽象的基础框架，GameFeature+Experience+HotFix，再加客户端和服务器一体化的设计，复杂度非常高，需先熟练掌握插件系统/模块化设计。 3.核心机制代码分散，学习难度高。被拆分为20+个独立的插件，对于能力复用和组合有较好的支持度。 4.使用数据驱动设计，一个简单的角色属性的修改，可能就要跨越数个配置文件。而非简单修改一个值。 一个典型的场景就是： 设计一个简单的复活队友的技能可能就要穿透至少5层：LyraHeroComponent → AbilitySystem → GameplayCue → 客户端预测 → 服务器RPC验证。 那么为什么Epic仍坚持推广Lyra呢？说些个人不负责任的猜想： 1. 技术展示。 强制开发者接触UE5前沿特性（如World Partition/MetaSounds, 等优秀能力）。 2. 生态绑定。 使用Lyra等于深度依赖Epic后端服务（如EOS好友，排名，成就等系统）。 3. 开发者筛选。 用高复杂度来过滤非目标用户（3A/多人游戏团队）。 一个不负责任的数据显示，某个国外课程的后台数据表明，仅12%的UE自学者在Lyra项目上坚持超2周。 那么，最后回答一下，我们为什么要学习Lyra呢？我的答案是： 1.从技术提升角度来看，Lyra涵盖了UE5众多前沿且复杂的技术特性和架构设计。学习Lyra能够帮助开发者深入理解UE5的高级功能和工业级开发范式，掌握插件系统、模块化设计、数据驱动开发等关键技术，这些技术在大型游戏开发和复杂项目中具有极高的实用价值。例如，掌握了Lyra的架构设计思路，开发者在面对其他大型项目时，能够更高效地进行系统架构和模块划分。 2.从职业发展角度考虑，由于Lyra的学习难度较大，成功掌握它意味着开发者具备较强的技术学习能力和解决复杂问题的能力。正如前面调查数据所示，掌握Lyra的开发者在求职大公司和大型项目工作室时具有显著优势，能够获得更多的职业机会和更好的职业发展前景。 3.从行业趋势来看，随着游戏行业的发展，对游戏的品质和规模要求越来越高，UE5的工业级开发范式将逐渐成为行业主流。学习Lyra可以让开发者提前适应行业发展趋势，紧跟技术前沿，在未来的职业竞争中占据有利地位。 其实，大家也可从B站或者知乎上搜索Lyra相关的关键词，可以看到对一个子模块的讲解都可以作为一个技术大会的分享主题，也就证明了Lyra的含金量。再一个是，就目前来看，绝大部分UE5的项目，不管是什么类型的，都会或多或少参考Lyra的框架设计。比如，Lyra中完整实现了编辑器扩展，客户端和服务器交互和分离，构建多平台等能力，这些是无论做什么类型的游戏都有参考价值的。 三、教程形式 传统教程像在组装乐高说明书 —— 先逼你认遍所有“零件”，比如从UObject到Actor再到Component等，按照UE的复杂度来看，可能20小时后还在学基础概念。而那种技术大会分享更不用说，简直就是“量子速读现场” —— 满屏架构图咻咻乱飞，一张PPT涉及的知识点可能比他的头发还多。每章的知识点过的还非常快，来不及截图不说，截了也看不懂。 对于Lyra这种工业级的架构和业务来说，这两种就更不合适了，讲个GameState要先科普一个小时的UObject，这谁受得了呢？有可能你讲完Gameplay框架，UE都更新两个版本了。 而如果分模块击破也不靠谱，Lyra的核心代码分散在20多个核心的Plugin中，如果要按模块讲解，要就要把UE的插件-模块化的结构先讲清楚，再讲明白Lyra拆分插件的思路和优劣势，这也是耗时且性价比不高的事情。 所以真要按照传统线性的教学方式来的话，挫败感会很强，弃坑率也绝对会非常高。 而我这次的思路，会偏向于先上路再慢慢修正。也就是直接从Lyra运行的核心代码开始，按照顺序，代码执行到哪我们就拆解到哪。这样的好处就是，每一个介绍的代码你都知道它在当前运行时候是有用的。虽然坏处是会将知识点分散到多节课中，但每节课学到的就是项目当前在用的，知识点不浮空。 最后呢，本文还是以大家能看懂为主，在能看懂的基础上再去局部修改和替换小模块来验证自己的理解，以此为迭代才能真正的掌握。比如一个UI界面，当你看懂之后，可以先尝试修改上面的一个文字，然后添加或者修改一个自己导入的图片，或者直接加载一个自己写的UI等等。循序渐进地理解才能真正掌握。 四、环境准备 本次使用UE最新的版本，也就是5.6版本进行。请严格按照下面的步骤配置你的开发环境。 UE 5.6的下载和安装 1. 安装Epic Games启动器。 https://www.unrealengine.com/zh-CN/download 下载完成之后，像普通程序一样，双击安装。由于要额外下载内容，所以需要等待一会儿，也可以通过观看右边官方提供的安装视频进行安装。 UE引擎官方版本的安装只有这一个办法，当然当你有一定经验之后，可以到GitHub上下载引擎源码自己编译生成引擎也是可以的。 如果你有时间和话，也可以阅读官方提供的安装文档进行引擎安装： https://dev.epicgames.com/documentation/zh-cn/unreal-engine/install-unreal-engine 如果你打开网页是英文的话，可以在这里切换语言： 启动器下载完成之后，需要登录才能正常使用。未登录状态下，运行启动器的界面是这样的，需要自行注册Epic账号进行登录，这一步就留给大家自行完成。 当你登录之后，看到的界面是这样的。 其中右边有三个页签，商城、库和虚幻引擎。前两个就跟Steam平台一样，展示的是作为游戏平台的内容，库则是你已经拥有的游戏。友情提示，Epic平台每周可以免费领取1~2款游戏，重大活动时候免费送大型游戏，可以保持关注。 然后我们要看的其实是第三个页签，也就是关于虚幻引擎的部分，点开页签后长这样。 这个页签下会发布很多UE引擎的动态和新闻。顶上的一排子标签的作用分别是： News：热点资讯。 示例：UE官方发布的各种技术演示工程，基本上都是完整可以运行的部分。 Fab：是EPIC多个资产商城的合集，可以下载大量的三方插件、代码和资产之类的。不过它需要在单独的页面打开才能看，启动器里只是一个跳转入口。你甚至可以在这里下载Unity的插件和资产。 库：这个页签是引擎相关的管理页签。在这里可以下载新的引擎版本，和管理已经下载的引擎版本，比如我这里就已经下载好了5.6版本的引擎。 当你有已经安装好的引擎的时候，在右上角就会提示可以直接启动引擎，这是引擎启动的方式之一。当然如果安装了多个版本的话，右上角也可以切换版本。 如果没有引擎或者想要下载引擎，点击左边的“+”号，可以选择要下载的引擎版本。 选好引擎版本之后，点击安装就会弹出安装的一些选项，包括位置和选项。选项这里可以选择额外支持的部署平台，一般我们加上安卓和iOS就可以了。还有一个是调试符号，一般是用来调试引擎的，新手的话可以不管，这个要多增加几十G的空间。 都选好之后，点击安装，然后等待安装完成即可。 若要删除引擎，只要把鼠标移入标签卡里，就会出现卸载按钮，点击即可卸载。 VS的下载和安装 当引擎安装完毕之后，桌面上就会出现图标，同时右上角也会出现启动引擎的按钮。无论点击哪个都会打开引擎的预启动界面。第一次启动的时候，时间会很久，因为它要编译引擎启动所需要的材质，界面上会有进度和数量显示，需要耐心等待。 打开之后，这个界面长这样，主要是跟随引擎一起的模板、示例这些。然后可以管理和新建项目。 比如我要新建一个第三人称的示例项目就可以通过点击右侧Games标签来完成。 到这里就需要注意了，如果你是新手还不懂代码，只想用蓝图的话，现在就已经可以开工了。但是如果蓝图不能满足或者未来不能满足你的要求，就必须切换成 C++的版本。由于C++是代码，就必须要安装对应的编译环境才可以工作。这是UE附加的必要条件，只要你用代码，就必须要安装。一般我们是安装Visual Studio 2022的社区版本。因为它是免费的。 也可以手动去这里下载安装： https://visualstudio.microsoft.com/zh-hans/vs/community/ 另外当你切换任意一个示例到C++版本的时候，如果本地没有代码环境，那么会有一个黄字提示需要安装VS 2022，点击也会自动帮你下载。 安装VS的技巧可以参考官网，一些必要的组件需要勾选： https://dev.epicgames.com/documentation/zh-cn/unreal-engine/setting-up-visual-studio-development-environment-for-cplusplus-projects-in-unreal-engine 一些SDK版本，尽量选最新的。当然选多个也没问题，就是多占点空间而已。 当VS安装完成之后，UE开发必要的组件就已经准备好了。接下来我们就可以运行项目了。 Lyra示例工程的下载和安装 Lyra示例工程需要到Fab上先订阅，再在Epic启动器里下载。 Fab上直接搜索Lyra，找到图示的这个项目，点开之后加入到库里。点击在启动器查看，或者直接在启动器里搜索Lyra。 注意，这个库是引擎标签的库，不是最外层的游戏库。找到之后，点击创建，设置好目录，等待下载完成就可以了。 到这里Lyra的工程也就准备完成了。到设定的安装目录下就可以看到工程信息了。 由于Lyra是一个C++工程，在运行之前，必须要先编译才行。右键uproject文件，执行代码工程的生成，完成之后会有一个.sln的文件出现，代表生成成功。 这个时候双击Lyra，就可以打开Lyra工程了。当然，由于Lyra工程引入了大量新的材质，所以第一次启动也是会编译较长时间，请耐心等待，编译后会自动打开编辑器界面。 到这里，本文的目标就已经达成了。 后面这个点是我个人推荐的开发经验，并且我之后也都是会使用这个软件进行代码更新的。推荐大家继续看完，否则可能会影响后续课程的一些理解。因为不同IDE软件的操作和显示是不一样的。 Rider的下载和安装 Visual Studio是基础，但Rider专为UE深度优化，提供更智能的代码补全（理解UCLASS, UFUNCTION等）、更便捷的蓝图/C++互跳、更强大的反射信息查看、更直观的调试体验，以及更友好的项目结构管理。它能显著提升阅读和理解引擎和Lyra庞大代码库的效率。 访问以下地址下载Rider for Unreal Engine安装包。运行安装程序，按提示完成安装即可： https://www.jetbrains.com/lp/rider-unreal/ Rider是一个付费软件，但个人非商业许可是免费的。第一次启动Rider的时候，会提示你选择许可。关联许可之后就可以正常使用Rider了。 在打开Lyra项目之前，推荐先下载一下这个插件，可以更好地关联UE： 不手动安装其实也行，Rider在检查项目完成之后，会提示你是否安装，他会有两个选择，一个是安装在引擎目录下，一个是安装在项目目录下。建议大家安装在引擎目录下，这样在创建其他项目的时候就不用再次安装了。 前面我们用双击uproject的方式打开编辑器，但作为程序员，你应该用IDE的方式打开编辑器。也很简单，用Rider打开它即可。 打开后，界面长这样，右上角两个按钮一个是正常启动，一个是调试模式。选择任何一个都可以。点击启动之后会先检查代码有没有编译，没有的话会先编译。编译好之后就会直接启动编辑器，效果跟双击uproject打开是一样的，但不一样的是调试模式就可以断点调试相关代码和逻辑。 那么到此，本文的内容就全部结束了。 这是侑虎科技第1946篇文章，感谢作者放牛的星星供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/niuxingxing 再次感谢放牛的星星的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
1）UE的粒子系统开销怎么优化 2）哪里能下载或共享Adreno Offline Compiler 3）怎样测试游戏在各个机型上的安装/进游戏的成功率 这是第463篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From 问答社区 Q：我在UE的项目中看到粒子系统在Game Thread和Render Thread都有一个耗时指标，有的区间中主线程有开销但渲染线程中却基本没有，它们之间有什么关系吗，主要是受什么影响？ A：简单来说，Game Thread负责粒子系统的逻辑计算，而Render Thread负责渲染数据的准备和提交，它们本身是并行的，所以开销不匹配也是正常的。 要优化的话也是先关注哪一部分开销更大，然后针对性去优化就行。 如果你用的是Niagara也可以参考下官方的文档： https://dev.epicgames.com/documentation/zh-cn/unreal-engine/measuring-performance-in-niagara 欢迎大家转至社区交流： https://answer.uwa4d.com/question/6980641e92894f1c4f0c234d From 问答社区 Q：哪里能下载Adreno Offline Compiler？或可以共享？ Adreno官网找不到，打开以下网址显示Software Restricted： https://softwarecenter.qualcomm.com/catalog/item/Adreno_GPU_Offline_Compiler A：可以找一下这个Gears安装目录里这个路径下，最新的安装包里是有相应插件的。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/6970895792894f1c4f0c234a From 问答社区 Q：测试游戏在各个机型上的安装/进游戏成功率一般是用什么测试？ 欢迎大家转至社区交流： https://answer.uwa4d.com/question/69805f6c92894f1c4f0c234c 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
Async Asset Upload下Time Slice和Buffer Size在移动端分别如何设置
1）Async Asset Upload下Time Slice和Buffer Size在移动端分别如何设置 2）UE LLM中观察到Physics/ChaosAcceleration内存占用代表什么 这是第462篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From UWA AI 问答 Q：Async Asset Upload下Time Slice和Buffer Size设置有什么用，移动端一般怎么设置？ A：在Unity的异步资源上传管线（Async Upload Pipeline，AUP）中，Time Slice和Buffer Size是两个核心可控参数，直接影响纹理/网格上传的CPU占用、GPU上传吞吐、内存开销与帧稳定性，尤其在移动端资源加载密集场景下至关重要。 一、核心作用解析 asyncUploadTimeSlice（时间片） 控制每帧在渲染线程上用于上传数据的最大毫秒数（范围 1～33ms）。值越小，单帧上传压力越低、帧率更稳定；值越大，上传更快、但可能造成偶发卡顿。本质是「CPU时间配额」，防止上传阻塞主线程或渲染线程。 asyncUploadBufferSize（环形缓冲区大小） 设定用于暂存待上传数据的环形缓冲区内存上限（2～2047MB）。该缓冲区复用于解压、读取与GPU上传。设置过小会频繁重分配（触发额外GC/系统调用）；过大则浪费RAM，尤其在内存受限的Android/iOS设备上易引发OOM或系统杀进程。 二、移动端推荐配置（基于实测与平台约束） 三、协同优化要点 必须配合LZ4压缩+Streaming Mipmaps启用，否则异步上传无法生效； 在Quality Settings中统一配置，避免运行时频繁修改（引发缓冲区重分配）。 ⚠️ 注意：Editor中行为与真机不同（全量加载），所有调优必须在真机Profile（如Android Systrace/iOS Metal GPU Capture）下验证。 From UWA AI 问答 Q：UE LLM中观察到Physics/ChaosAcceleration内存占用代表什么？ A：Physics/ChaosAcceleration内存占用（常见于Unreal Engine 5的Chaos物理系统性能报告中）代表Chaos物理求解器在运行时为加速度（Acceleration）数据所分配和维护的内存总量，具体包括： 刚体/软体粒子加速度向量缓存：每个动态物理对象（如TRigidParticles、TDynamicParticles、TSoftsSolverParticlesRange）均需存储当前帧的加速度（FSolverVec3或TVector<float,3>类型），用于积分、约束求解与碰撞响应； 并行计算结构开销：Chaos使用多线程+SIMD（如ISPC）加速，加速度数据常以AoS或SoA方式组织，并伴随对齐填充、任务队列缓冲区、临时中间数组等内存开销； 加速结构关联元数据：若启用AccelerationStructureUseDynamicTree或AccelerationStructureCacheOverlappingLeaves，加速度数据可能与空间索引结构（BVH、Grid）耦合，产生额外引用或缓存内存。 ⚠️ 注意：该指标不等于“每帧实时计算的加速度值大小”（单个加速度仅12字节），而是整个生命周期内被活跃管理的加速度数据集合 —— 尤其在高密度物理对象（如布料、碎裂、大量RigidBody）、未及时休眠或未启用ParticlePool优化时，其内存会显著增长。 ✅ 优化建议： 启用p.Chaos.Solver.UseParticlePool true，复用已休眠粒子内存； 调整休眠阈值（p.Chaos.Solver.Sleep.*Threshold）加速非活跃对象进入休眠态，减少持续更新的加速度数据量； 检查是否误启用了p.Chaos.Solver.ShrinkArrays false（默认） —— 若设为true可在空闲时收缩数组，但需权衡频繁重分配开销； 对非关键物理对象（如远景碎石）启用p.Chaos.Solver.OneWayPairCollisionMode 0或降低p.Chaos.Solver.Joint.TransferCollisionsStiffnessClamp，减少约束激活导致的加速度更新频次。 该内存项通常与Active Constraints、Physics.ProcessReports CPU耗时协同分析 —— 若二者同步激增，大概率指向物理对象过载或约束配置不当。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=59dd2fd7-8e32-4b38-890c-d97c829ef937 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 一、前序 1. 介绍 Nanite是UE5中虚拟几何体（Virtualized Geometry System）的系统，主要用途是高效率渲染的高面数模型。Nanite会为模型自动生成LOD结构，与传统LOD不同，Nanite的LOD不再是每个模型的，而是精细到模型中的局部区域，艺术家不需再为制作或处理LOD烦恼。并且还能享有GPU Driven的高效剔除，单个绘制调用的好处。 2. 技术要点 Nanite技术结合了多种技术做到了高效渲染： Cluster Rendering：由Cluster组织三角形，可以享有更高效的剔除。 Auto LOD：通过Graph Partitioning技术划分和简化模型构建LOD，并且把数据组织成BVH结构在Runtime时候可以高效地并行选择LOD，通过这种方式构建的LOD过渡非常丝滑。 GPU Driven Pipeline：由GPU驱动的绘制，减少了CPU的性能开销。 Occlusion Culling：更细颗粒的遮挡剔除，用于剔除不可见的三角形。 Hardware/Software Rasterization：由于小三角形对于硬件光栅化非常不友好，所以针对这些三角形用Compute Shader执行软光栅提高效率。 Visibility Buffer：利用Visibility Buffer减少Overdraw，进一步提高GPU效率。 Streaming：加载只看到的相关数据，减少几何体对内存的压力。 3. 本文效果 由于Nanite系统非常庞大和有非常多的工程细节要处理，所以本文会简化和略过一些东西，仅实现核心部分，而且会与有UE5的版本有点出入。 下图是本文实现的效果，每个色块是一个三角形，可以看出LOD切换和相机剔除都非常丝滑。 色块表示三角面 色块表示Cluster 二、实现 1. Clusterize 第一步，在离线阶段处理，将复杂的超高精度网格模型高效且合理地分割成更小、更易于管理的簇（Cluster），每个Cluster最多128个三角形。这种划分不是简单的切割，而是旨在最小化簇与簇之间连接的边数（即切割大小），同时保持每个簇的大小大致均衡。 UE使用的Partition是Metis库： https://github.com/KarypisLab/METIS 实现代码可以参考UE5的源码部分： UnrealEngine-release\Engine\Source\Developer\NaniteBuilder\Private\NaniteBuilder.cpp 本文使用meshoptimizer实现Mesh的切分Cluster和Partition功能，这个库功能还有优化Over Draw，Shadow Depth Index等功能： https://github.com/zeux/meshoptimizer 我们新建一个C++导出DLL的工程，封装几个主要函数让Unity可以使用。其实代码量不多，翻译成C#直接用也可以。 分别是： meshopt_buildMeshlets（构建Cluster） meshopt_partitionClusters（Cluster划分Partition） meshopt_buildMeshletsBound（计算Cluster数量） meshopt_computeSphereBounds（合并BoundsSphere） 在C#中引用这些函数： unsafe static List<Cluster> clusterize(Vector3[] vertices, int[] indices) { constint max_vertices = 192; // TODO: depends on kClusterSize, also may want to dial down for mesh shaders constint max_triangles = kClusterSize; //128 constint min_triangles = (kClusterSize / 3) & ~3; constfloat split_factor = 2.0f; constfloat fill_weight = 0.75f; int max_meshlets = BuildMeshletsBound(indices.Length, max_vertices, max_triangles);//meshopt_buildMeshletsBound var meshlets = new Meshlet[max_meshlets * 2]; var meshlet_vertices = newint[max_meshlets * max_vertices]; var meshlet_triangles = newbyte[max_meshlets * max_triangles * 3]; var meshlet_count = BuildMeshletFlex(meshlets, meshlet_vertices, meshlet_triangles, indices, indices.Length, vertices, vertices.Length, sizeof(float) * 3, max_vertices, min_triangles, max_triangles, 0.0f, split_factor);//meshopt_buildMeshlets List<Cluster> clusters = new List<Cluster>(meshlet_count); for (int i = 0; i < meshlet_count; i++) { ref Meshlet meshlet = ref meshlets[i]; fixed (int* ptr = &meshlet_vertices[meshlet.vertex_offset]) { fixed (byte* ptr2 = &meshlet_triangles[meshlet.triangle_offset]) { OptimizeMeshlet(ptr, ptr2, (int)meshlet.triangle_count, (int)meshlet.vertex_count); } } Cluster cluster = new Cluster(); cluster.indices = newint[meshlet.triangle_count * 3]; for (int j = 0; j < meshlet.triangle_count * 3; ++j) cluster.indices[j] = meshlet_vertices[meshlet.vertex_offset + meshlet_triangles[meshlet.triangle_offset + j]]; cluster.parent.error = float.MaxValue; clusters.Add(cluster); } return clusters; } 然后可以直接通过meshopt_buildMeshlets函数，获得每个cluster的indexs。 2. Build DAG 有了这些Cluster，就可以构建“LOD”了，只需要循环这个操作：打组->合并->减面->clusterize。如下图： 这个过程感觉就像Mipmap一样，一层一层往上合并和简化，并记录一个Err误差值和Bounds用于运行时LOD选择用。而这些合并的的节点就叫做Cluster Group。最后得出一个DAG（有向无环图，Directed Acyclic Graph）的结构。 public struct ClusterGroup { public List<int> Children; public Vector3 Bounds; publicfloat radius; public Vector3 LODBounds; publicfloat MinLODError; publicfloat MaxParentLODError; publicint MipLevel; } publicclassNaniteSubMesh { public List<ClusterGroup> clusterGroupList; public List<Cluster> clusterList; publicint maxMipLevel; } static NaniteSubMesh Nanite(Vector3[] vertices,Vector3[] normals, int[] indices) { NaniteSubMesh res = new NaniteSubMesh(); List<ClusterGroup> clusterGroupList = new List<ClusterGroup>(); var clusters = clusterize(vertices, indices); res.clusterList = clusters; res.clusterGroupList = clusterGroupList; res.maxMipLevel = 0; for (int i = 0; i < clusters.Count; ++i) { var c = clusters[i]; c.self = Bounds(vertices, clusters[i].indices, 0f); c.mip = 0; clusters[i] = c; } List<int> pending = new List<int>(clusters.Count); int[] remap = newint[vertices.Length]; for (int i = 0; i < remap.Length; ++i) remap[i] = i; for (int i = 0; i < clusters.Count; ++i) pending.Add(i); int curMip = 1; byte[] locks = newbyte[vertices.Length]; while (pending.Count > 1) { List<List<int>> groups = partition(clusters, pending, remap, vertices); if (kUseLocks) lockBoundary(locks, groups, clusters, remap); pending.Clear(); List<int> retry = new List<int>(); int triangles = 0; int stuck_triangles = 0; for (int i = 0; i < groups.Count; ++i) { var curGroupClusters = groups[i]; if (curGroupClusters.Count == 0) { continue; // metis shortcut } List<int> merged = new List<int>(vertices.Length); for (int j = 0; j < curGroupClusters.Count; ++j) { merged.AddRange(clusters[curGroupClusters[j]].indices); } LODBounds groupb = boundsMerge(clusters, curGroupClusters); ClusterGroup clusterGroup = new ClusterGroup(); clusterGroup.Bounds = groupb.center; clusterGroup.MaxParentLODError = groupb.error; clusterGroup.radius = groupb.radius; clusterGroup.Children = new List<int>(merged.Count); clusterGroup.MipLevel = curMip - 1; for (int j = 0; j < curGroupClusters.Count; ++j) { clusterGroup.Children.Add(curGroupClusters[j]); } clusterGroupList.Add(clusterGroup); // aim to reduce group size in half int target_size = (merged.Count / 3) / 2 * 3; float error = 0f; var simplified = simplify(vertices, normals, merged.ToArray(), kUseLocks ? locks : null, target_size, ref error); if (simplified.Count > merged.Count * kSimplifyThreshold) { stuck_triangles += merged.Count / 3; for (int j = 0; j < curGroupClusters.Count; ++j) { retry.Add(curGroupClusters[j]); } continue; // simplification is stuck; abandon the merge } // enforce bounds and error monotonicity // note: it is incorrect to use the precise bounds of the merged or simplified mesh, because this may violate monotonicity var split = clusterize(vertices, simplified.ToArray()); groupb.error += error; // this may overestimate the error, but we are starting from the simplified mesh so this is a little more correct // update parent bounds and error for all clusters in the group // note that all clusters in the group need to switch simultaneously so they have the same bounds for (int j = 0; j < curGroupClusters.Count; ++j) { int clusterIndex = curGroupClusters[j]; var t = clusters[clusterIndex]; t.parent = groupb; clusters[clusterIndex] = t; } for (int j = 0; j < split.Count; ++j) { var sj = split[j]; sj.self = groupb; sj.mip = curMip; split[j] = sj; clusters.Add(sj); // std::move pending.Add(clusters.Count - 1); triangles += sj.indices.Length / 3; } } curMip++; } if (pending.Count == 1) { var c = clusters[pending[0]]; ClusterGroup clusterGroup = new ClusterGroup(); clusterGroup.Bounds = c.self.center; clusterGroup.MaxParentLODError = c.self.error; clusterGroup.radius = c.self.radius; clusterGroup.Children = new List<int>(1); clusterGroup.MipLevel = curMip - 1; clusterGroup.Children.Add(pending[0]); clusterGroupList.Add(clusterGroup); } res.maxMipLevel = curMip - 1; return res; } static void lockBoundary(byte[] locks, List<List<int>> groups, List<Cluster> clusters, int[] remap) { // for each remapped vertex, keep track of index of the group it's in (or -2 if it's in multiple groups) int[] groupmap = newint[locks.Length]; for (int i = 0; i < groupmap.Length; ++i) groupmap[i] = -1; for (int i = 0; i < groups.Count; ++i) { var c = groups[i]; for (int j = 0; j < c.Count; ++j) { var indices = clusters[c[j]].indices; for (int k = 0; k < indices.Length; ++k) { var v = indices[k]; var r = remap[v]; if (groupmap[r] == -1 || groupmap[r] == i) groupmap[r] = i; else groupmap[r] = -2; } } } // note: we need to consistently lock all vertices with the same position to avoid holes for (int i = 0; i < locks.Length; ++i) { var r = remap[i]; locks[i] = (byte)((groupmap[r] == -2) ? 1 : 0); } } 这样我们得到各级Mip的一系列Clusters。 3. 加速结构 即使把三角形划分成Clusters数量也太多，使用Compute Shader来做并行结算效率也不高，于是Nanite就使用了BVH来作为ClusterGroup的加速结构，然后配合Persistent Threads做查找过滤。 Persistent Threads遍历BVH部分，有兴趣可以参考UE5源码： Shaders\Private\Nanite\NaniteClusterCulling.usf UE5中也有不使用Persistent Threads的流程，应该说一般默认就是不使用的。 UE5源码部分 个人认为Persistent Threads方案在GPU遍历这种BVH结构有点暴力和重度，所以简化了一下，把多个Cluster合并成一个剔除单元（Part），先并行对Part做剔除，再对Part里的Cluster去做并行剔除，两层结构来加速作为Persistent Threads的一个简单替代方案。 然后把多个Part组织成Page用于分块加载。材质处理细节也不同，UE5的材质是每个Cluster会记录MaterialRange，简单起见这里实现是每个SubMesh会去构建独立的Clusters。 代码如下： [Serializable] publicstruct NaniteCluster { publicint indiceIndex; publicint indiceCount; publicfloat selfErrer; publicfloat parentErrer; public Vector4 selfSphere; public Vector4 parentSphere; publicint subMeshID; publicint vertexOffset; }; [Serializable] publicstruct NaniteClusterGroup { publicint ClusterStart; publicint ClusterCount; public Vector3 Bounds; publicfloat radius; public Vector3 LODBounds; publicfloat MinLODError; publicfloat MaxParentLODError; publicint MipLevel; } [Serializable] publicstruct NaniteMeshPart { publicint ClusterStart; publicint ClusterCount; public Vector4 selfSphere; publicfloat MaxParentLODError; } public classNaniteSubMesh { public List<ClusterGroup> clusterGroupList; public List<Cluster> clusterList; publicint maxMipLevel; } publicclassBuildPart { public List<int> clusterList; publicint mip; publicint subMesh; } public static void BuildNaniteMesh(Mesh mesh) { var vertices = mesh.vertices; var normals = mesh.normals; var uvs = mesh.uv; int subMeshCount = mesh.subMeshCount; int totalClusterCount = 0; int totalIndexCount = 0; List<NaniteSubMesh> subMeshList = new List<NaniteSubMesh>(); for (int i = 0; i < subMeshCount; i++) { var triangles = mesh.GetTriangles(i); var subMesh = Nanite(vertices,normals,triangles); subMeshList.Add(subMesh); totalClusterCount += subMesh.clusterList.Count; } List<BuildPart> buildPartsList = new List<BuildPart>(totalClusterCount); int MAX_PART_PERPAGE = 128; int MAX_CLUSTER_PERPART = 8; for (int subMeshIndex = 0; subMeshIndex < subMeshList.Count; subMeshIndex++) { var subMesh = subMeshList[subMeshIndex]; List<Cluster> clusters = subMesh.clusterList; var groupsList = subMesh.clusterGroupList; BuildPart buildPart = null; for (int i = 0; i < groupsList.Count; i++) { var gIndex = i; // sortGroups[i].OldIndex; var g = groupsList[gIndex]; var childs = g.Children; for (int c = 0; c < childs.Count; c++) { int cIndex = childs[c]; int cMip = clusters[cIndex].mip; totalIndexCount += clusters[cIndex].indices.Length; //new Part if (buildPart == null || buildPart.clusterList.Count >= MAX_CLUSTER_PERPART || buildPart.mip != cMip) { buildPart = new BuildPart(); buildPart.clusterList = new List<int>(MAX_CLUSTER_PERPART); buildPart.mip = cMip; buildPart.subMesh = subMeshIndex; buildPartsList.Add(buildPart); } buildPart.clusterList.Add(cIndex); } } } int buildPartCount = buildPartsList.Count; NaniteMeshPage[] pageArray = new NaniteMeshPage[(buildPartCount+(MAX_PART_PERPAGE-1))/MAX_PART_PERPAGE];//ceil List<int> tempIndiceList = new List<int>(totalIndexCount); List<int> mipLists = new List<int>(totalClusterCount); int partIndex = 0; for (int i = 0; i < pageArray.Length; i++) { //create new page var p = ScriptableObject.CreateInstance<NaniteMeshPage>(); pageArray[i] = p; tempIndiceList.Clear(); int partCount = (i == (pageArray.Length -1)) ? (buildPartCount % MAX_PART_PERPAGE) : MAX_PART_PERPAGE; p.parts = new NaniteScene.NaniteMeshPart[partCount]; List<NaniteScene.NaniteCluster> pageClusters = new List<NaniteScene.NaniteCluster>(partCount * MAX_CLUSTER_PERPART); for (int j = 0; j < partCount; j++) { var buildPart = buildPartsList[partIndex]; var buildPartCluster = buildPart.clusterList; //create part var part = new NaniteScene.NaniteMeshPart(); part.ClusterStart = pageClusters.Count; //local index part.ClusterCount = buildPartCluster.Count; int subMeshID = buildPart.subMesh; float maxParentErr = 0f; var clusters = subMeshList[subMeshID].clusterList; for (int c = 0; c < buildPartCluster.Count; c++) { var cluster = clusters[buildPartCluster[c]]; mipLists.Add(cluster.mip); //create Cluster NaniteScene.NaniteCluster naniteCluster = new NaniteScene.NaniteCluster(); naniteCluster.indiceIndex = tempIndiceList.Count; naniteCluster.indiceCount = cluster.indices.Length; naniteCluster.parentErrer = cluster.parent.error; naniteCluster.parentSphere = new Vector4(cluster.parent.center.x,cluster.parent.center.y,cluster.parent.center.z, cluster.parent.radius); naniteCluster.selfErrer = cluster.self.error; naniteCluster.selfSphere = new Vector4(cluster.self.center.x,cluster.self.center.y,cluster.self.center.z, cluster.self.radius); naniteCluster.subMeshID = subMeshID; tempIndiceList.AddRange(cluster.indices); maxParentErr = Mathf.Max(naniteCluster.parentErrer, maxParentErr); pageClusters.Add(naniteCluster); } LODBounds partBounds = boundsMerge(clusters, buildPartCluster,true); part.selfSphere = new Vector4(partBounds.center.x,partBounds.center.y,partBounds.center.z,partBounds.radius); part.MaxParentLODError = maxParentErr; p.parts[j] = part; partIndex++; } p.clusterArray = pageClusters.ToArray(); p.indiceArray = tempIndiceList.ToArray(); p.clusterMip = mipLists.ToArray(); } string fileName = AssetDatabase.GetAssetPath(mesh); string extension = Path.GetExtension(fileName); fileName = fileName.Replace(extension, ""); //Build page int totalVerts = 0; for (int i = 0; i < pageArray.Length; i++) { var page = pageArray[i]; var clusterArray = page.clusterArray; var indiceArray = page.indiceArray; Dictionary<int,int> indicesMap = new Dictionary<int,int>(); List<Vector3> tempVerts = new List<Vector3>(vertices.Length); List<Vector3> tempNormals = new List<Vector3>(vertices.Length); List<Vector2> tempUVs = new List<Vector2>(vertices.Length); List<int> newIndices = new List<int>(totalIndexCount); for (int c = 0; c < clusterArray.Length; c++) { refvar cluster = ref clusterArray[c]; var indexStart = cluster.indiceIndex; var indexEnd = indexStart+cluster.indiceCount; for (int index = indexStart; index < indexEnd; index++) { int vertIndex = indiceArray[index]; int newIndex; if (!indicesMap.TryGetValue(vertIndex,out newIndex)) { newIndex = newIndices.Count; indicesMap.Add(vertIndex, newIndex); tempVerts.Add(vertices[vertIndex]); tempNormals.Add(normals[vertIndex]); if (uvs.Length == 0) { tempUVs.Add(Vector2.zero); } else { tempUVs.Add(uvs[vertIndex]); } newIndices.Add(newIndex); } indiceArray[index] = newIndex; } } page.vertexStride = 5;//pos3 + uv2 page.vertexData = newfloat[tempVerts.Count * page.vertexStride]; page.vertexCount = tempVerts.Count; for (int v = 0; v < tempVerts.Count; v++) { int vertexIndex = v * page.vertexStride; page.vertexData[vertexIndex + 0] = tempVerts[v].x; page.vertexData[vertexIndex + 1] = tempVerts[v].y; page.vertexData[vertexIndex + 2] = tempVerts[v].z; page.vertexData[vertexIndex + 3] = tempUVs[v].x; page.vertexData[vertexIndex + 4] = tempUVs[v].y; } totalVerts +=tempVerts.Count; string newPath = fileName + "_p"+i +".asset"; AssetDatabase.CreateAsset(page, newPath); } AssetDatabase.Refresh(); Debug.Log("mesh Vertx:"+vertices.Length +" mesh Nanite:"+ totalVerts + " cluster:"+totalClusterCount + "part:"+ buildPartCount +" page:"+pageArray.Length); NaniteMesh naniteMesh = ScriptableObject.CreateInstance<NaniteMesh>(); { naniteMesh.subMeshCount = subMeshCount; naniteMesh.pageArray = new NaniteMeshPage[pageArray.Length]; for (int i = 0; i < pageArray.Length; i++) { string newPath = fileName + "_p" + i + ".asset"; naniteMesh.pageArray[i] = AssetDatabase.LoadAssetAtPath<NaniteMeshPage>(newPath); } } var meshBound = mesh.bounds; naniteMesh.boundingSphere = meshBound.center; naniteMesh.boundingSphere.w = meshBound.extents.magnitude; string meshExt = "_mesh.asset"; AssetDatabase.CreateAsset(naniteMesh, fileName + meshExt); AssetDatabase.Refresh(); } 到这里离线部分基本结束，可以得到一个Nanite的资源。当然UE5原文还做了很多操作，如BVH、Encode、编码、压缩、Page的划分、顶点属性优化等，个人认为这些都属于工程细节。 4. 运行时资源 来到Runtime部分，我们需要把这个Nanite Mesh加载上来，方便起见，这里直接引用一下资源在脚本上，偷懒省略加载部分。 把资源、Object、材质信息整合起来，传到GPU的Buffer中。这里做法很不正式还是偷懒来处理。当然也可以用Compute Shader来更新Page数据到GPUBuffer中。 public static List<NaniteRenderer> renderers = new List<NaniteRenderer>(); privatestatic SceneObject[] gpuObjects = new SceneObject[2048]; //cluster -> part -> page publicstruct SceneObject { publicint naniteMeshID; public Matrix4x4 localToWorldMatrix; publicint materialIDOffset; } publicstruct NaniteRes { public Vector4 boundingSphere; publicint partIndex; publicint partCount; } unsafe static void UpdateRenderList() { if(renderers.Count == 0) return; //object update if (renderers.Count > gpuObjects.Length) { gpuObjects = new SceneObject[Mathf.NextPowerOfTwo(renderers.Count)]; } objectCount = 0; maxPartCount = 0; naniteMeshes.Clear(); materialList.Clear(); List<int> materialIndices = new List<int>(); for (int i = 0; i < renderers.Count; i++) { var renderer = renderers[i]; var nMesh = renderer.naniteMesh; foreach (var p in nMesh.pageArray) { maxPartCount += p.parts.Length; maxClusterCount += p.clusterArray.Length; } SceneObject obj = new SceneObject(); obj.localToWorldMatrix = renderer.transform.localToWorldMatrix; //mesh index int index = naniteMeshes.IndexOf(nMesh); if (index < 0) { index = naniteMeshes.Count; naniteMeshes.Add(nMesh); } obj.naniteMeshID = index; //mat indexs obj.materialIDOffset = materialIndices.Count; for (int m = 0; m < renderer.materials.Length; m++) { var mat = renderer.materials[m]; int matIndex = materialList.IndexOf(mat); if (matIndex < 0) { matIndex = materialList.Count; materialList.Add(mat); } materialIndices.Add(matIndex); } gpuObjects[i] = obj; renderer.transformChanged = false; objectCount++; } if(candidateClusterBuffer!=null) candidateClusterBuffer.Dispose(); candidateClusterBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, maxClusterCount *2, sizeof(int)); if(visibleClusterBuffer != null) visibleClusterBuffer.Dispose(); visibleClusterBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,maxClusterCount *2, sizeof(int)); if (objectsBuffer != null) objectsBuffer.Dispose(); objectsBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, objectCount, sizeof(SceneObject)); objectsBuffer.SetData(gpuObjects,0,0,objectCount); if(visObjectsBuffer !=null) visObjectsBuffer.Dispose(); visObjectsBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,objectCount, sizeof(int)); int vertCount = 0; List<NaniteCluster> tempClusters = new List<NaniteCluster>(2048); List<NaniteMeshPart> tempParts = new List<NaniteMeshPart>(2048); List<NaniteRes> naniteRes = new List<NaniteRes>(2048); List<int> tempIndices = new List<int>(2048 * 100); List<float> vertexDataList = new List<float>(); //load page for (int nID = 0; nID < naniteMeshes.Count; nID++) { NaniteRes res = new NaniteRes(); var nMesh = naniteMeshes[nID]; //填充到GPU var pages = nMesh.pageArray; res.partIndex = tempParts.Count; res.partCount = 0; res.boundingSphere = nMesh.boundingSphere; for (int p = 0; p < pages.Length; p++) { var page = pages[p]; var parts = page.parts; int vertOffset = vertCount; int indicesOffset = tempIndices.Count; int clusterOffset = tempClusters.Count; //add all cluster var clusters = page.clusterArray; for (int c = 0; c < clusters.Length; c++) { var cluster = clusters[c]; cluster.indiceIndex += indicesOffset; cluster.vertexOffset = vertOffset; tempClusters.Add(cluster); } //add all part for (int partIndex = 0; partIndex < parts.Length; partIndex++) { var part = parts[partIndex]; part.ClusterStart += clusterOffset; tempParts.Add(part); res.partCount++; } //add page data tempIndices.AddRange( page.indiceArray); vertexDataList.AddRange(page.vertexData); vertCount += page.vertexCount; } naniteRes.Add(res); } //TODO GPU Update Buffer if (naniteResBuffer != null) naniteResBuffer.Dispose(); naniteResBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, naniteRes.Count, sizeof(NaniteRes)); naniteResBuffer.SetData(naniteRes); if (partsBuffer != null) partsBuffer.Dispose(); partsBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,tempParts.Count, sizeof(NaniteMeshPart)); partsBuffer.SetData(tempParts); if (clusterBuffer != null) clusterBuffer.Dispose(); clusterBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, tempClusters.Count, sizeof(NaniteCluster)); clusterBuffer.SetData(tempClusters); if (indiceseBuffer != null) indiceseBuffer.Dispose(); indiceseBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Raw, tempIndices.Count, sizeof(int)); indiceseBuffer.SetData(tempIndices); if(materialIndexBuffer!=null) materialIndexBuffer.Dispose(); materialIndexBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,materialIndices.Count, sizeof(int)); materialIndexBuffer.SetData(materialIndices); if(vertexDataBuffer!=null) vertexDataBuffer.Dispose(); vertexDataBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Raw, vertexDataList.Count,sizeof(float)); vertexDataBuffer.SetData(vertexDataList); } //input object ID => public unsafe static void UpdateNaniteScene() { if (renderListDirty) { UpdateRenderList(); // UpdateRenderListGPU(); renderListDirty = false; } for (int i = 0; i < renderers.Count; i++) { var renderer = renderers[i]; if (renderer.transformChanged) { gpuObjects[i].localToWorldMatrix = renderer.transform.localToWorldMatrix; renderer.transformChanged = false; transformDirty = true; } } if (objectsBuffer != null && transformDirty) objectsBuffer.SetData(gpuObjects, 0, 0, objectCount); } 5. 剔除 这时离线时候已经把Clusters扁平化到数组中了，这些Clusters是可以并行进行剔除的，巧妙之处是他记录了父级的误差和自己的误差，当我们传入误差系数时候就可以独立地判断自己是否被剔除，而和上下级无关。 先从CPU发起剔除Compute Shader的Dispatch。这里因为组织数据时候就知道了所有Object最大的Parts/Cluster数量，所以直接用这个数去Dispatch了。 Objects剔除： 根据Object找到NaniteMesh的Parts进行Culling： ClustersCulling： 6. 软光栅 略。 7. VisibilityBuffer VBuffer主要用来减少Overdraw，着色器直接输出InstanceID、ClusterID、材质ID。然后用这个VBuffer来计算顶点数据来着色。 这个得益于GPUDriven的好处，一个DrawProceduralIndirect就可以绘制所有物体了： 一次DrawProceduralIndirect绘制多个物体 VBuffer存哪些属性，多少位，都是工程细节这里就不考究了。 8. 着色 有了VBuffer就需要逐材质进行绘制，原文是材质ID分Tile组合IndirectDraw画Quad的思想。 需要注意一下这里VBuffer通过三角重心插值求出的UV是不能直接采样贴图的，因为DDXY不对，所以需求重新计算，计算的代码放下面。并且利用SampleGrad（samplerName, coord2, dpdx, dpdy）来采样。 uint MurmurMix(uint Hash) { Hash ^= Hash >> 16; Hash *= 0x85ebca6b; Hash ^= Hash >> 13; Hash *= 0xc2b2ae35; Hash ^= Hash >> 16; return Hash; } float3 IntToColor(uint Index) { uint Hash = MurmurMix(Index); float3 Color = float3 ( (Hash >> 0) & 255, (Hash >> 8) & 255, (Hash >> 16) & 255 ); return Color * (1.0f / 255.0f); } struct FBarycentrics { float3 Value; float3 Value_dx; float3 Value_dy; }; float2 Lerp(float2 Value0, float2 Value1, float2 Value2, FBarycentrics Barycentrics, out float2 dxy) { float2 Value = Value0 * Barycentrics.Value.x + Value1 * Barycentrics.Value.y + Value2 * Barycentrics.Value.z; dxy.x = Value0 * Barycentrics.Value_dx.x + Value1 * Barycentrics.Value_dx.y + Value2 * Barycentrics.Value_dx.z; dxy.y = Value0 * Barycentrics.Value_dy.x + Value1 * Barycentrics.Value_dy.y + Value2 * Barycentrics.Value_dy.z; return Value; } /** Calculates perspective correct barycentric coordinates and partial derivatives using screen derivatives. */ FBarycentrics CalculateTriangleBarycentrics(float2 PixelClip, float4 PointClip0, float4 PointClip1, float4 PointClip2, float2 ViewInvSize) { FBarycentrics Barycentrics; PixelClip.y = 1 - PixelClip.y; PixelClip.xy = PixelClip.xy * 2 - 1; const float3 RcpW = rcp(float3(PointClip0.w, PointClip1.w, PointClip2.w)); const float3 Pos0 = PointClip0.xyz * RcpW.x; const float3 Pos1 = PointClip1.xyz * RcpW.y; const float3 Pos2 = PointClip2.xyz * RcpW.z; const float3 Pos120X = float3(Pos1.x, Pos2.x, Pos0.x); const float3 Pos120Y = float3(Pos1.y, Pos2.y, Pos0.y); const float3 Pos201X = float3(Pos2.x, Pos0.x, Pos1.x); const float3 Pos201Y = float3(Pos2.y, Pos0.y, Pos1.y); const float3 C_dx = Pos201Y - Pos120Y; const float3 C_dy = Pos120X - Pos201X; const float3 C = C_dx * (PixelClip.x - Pos120X) + C_dy * (PixelClip.y - Pos120Y); // Evaluate the 3 edge functions const float3 G = C * RcpW; constfloat H = dot(C, RcpW); constfloat RcpH = rcp(H); // UVW = C * RcpW / dot(C, RcpW) Barycentrics.Value = G * RcpH; // Texture coordinate derivatives: // UVW = G / H where G = C * RcpW and H = dot(C, RcpW) // UVW' = (G' * H - G * H') / H^2 // float2 TexCoordDX = UVW_dx.y * TexCoord10 + UVW_dx.z * TexCoord20; // float2 TexCoordDY = UVW_dy.y * TexCoord10 + UVW_dy.z * TexCoord20; const float3 G_dx = C_dx * RcpW; const float3 G_dy = C_dy * RcpW; constfloat H_dx = dot(C_dx, RcpW); constfloat H_dy = dot(C_dy, RcpW); Barycentrics.Value_dx = (G_dx * H - G * H_dx) * (RcpH * RcpH) * (2.0f * ViewInvSize.x); Barycentrics.Value_dy = (G_dy * H - G * H_dy) * (RcpH * RcpH) * (-2.0f * ViewInvSize.y); return Barycentrics; } 到这里其实基本完成了，利用IntToColor函数，可以对ClustersID或者IndexID对三角形或Cluster进行可视化。 三、总结 不得不说Nanite技术真是太强大了，但是也有很多工程细节需要处理，本文只是实现了其中一小部分。整体像是处理图片的Mipmap过程。 参考 22.GPU驱动的几何管线-nanite (Part 2) | GAMES104-现代游戏引擎：从入门到实践 [UnrealCircle]Nanite技术简介 | Epic Games China 王祢 Karis_Nanite_SIGGRAPH_Advances_2021_final.pdf Nanite-GPU-Driven UE5 Nanite源码入口： Engine\Source\Runtime\Renderer\Private\Nanite\NaniteCullRaster.cpp （渲染流程入口） Engine\Shaders\Private\Nanite\ （GPU的Shader入口） Engine\Source\Developer\NaniteBuilder\Private\ （离线生成Nanite资源入口） 这是侑虎科技第1939篇文章，感谢作者傻头傻脑亚古兽供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/tian-cai-ya-gu-shou 再次感谢傻头傻脑亚古兽的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
1）.so mmap计算工具内存翻倍现象 2）iOS报告中的Low Memory是如何被促发的 这是第461篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From 问答社区 Q：结合UWA DAY分享的.so mmap内存分析流程，我发现在我们项目中libil2cpp.so有个奇怪的现象，想要问问大家是否知道是怎么一回事： 这里列出来libil2cpp.so占据了4段内存，其中第一段：770e779000-771c606000，和第二段开始到第四段结束地址（78bfac9000-78cd956000）的内存容量完全一致。而且，命令结果显示它们映射到的文件以及offset也都完全一致。我高度怀疑这两段内存的内容也是一致的，很有可能是两段虚拟内存映射到同一段物理内存。但是这个情况只有libil2cpp符合，libunity并不符合。包括我用后来我们打的包，执行同样命令，分析结果，还是符合这个现象。这是否意味着libil2cpp的内存占用实际上是上面统计值的一半？ 补充一点：我用我们自己打的包，查看proc/pid/maps，结合readelf命令，发现后面三个段已经映射了所有libil2cpp中的LOAD段了。 附：包含文中提到的“.so mmap内存分析流程”的课程： Unity移动游戏性能优化全解析与小游戏转化的实战经验 A：试了几个不同的包，有的确实会出现第一段等于后面几段之和的情况： 也有几段互不相同没有加和关系的情况。这两种包的运行时数据和拆包得到的so硬盘占用大小也都基本对得上： 这是因为，UWA DAY PPT中用来计算内存的Python代码会粗暴地计算所有地址的插值并无脑求和，如下图： 但在出现”第一段等于后面几段之和的情况”的包中，前两段的Offset都是偏移0，此时不能强行求和。可以考虑根据这种规律调整代码或人为重新算一下就对了： 欢迎大家转至社区交流： https://answer.uwa4d.com/question/696ddb25244ce21ce9ec096f From UWA AI 问答 Q：iOS平台上[UIApplicationDelegate applicationDid ReceiveMemoryWarning]调用只在1.8GB左右频繁触发，内存更高以后反而触发率降低了，是什么原因？ A：该现象符合iOS内存管理机制的分层压力响应策略，根本原因在于：applicationDidReceiveMemoryWarning并非在内存使用达到绝对阈值时触发，而是由系统基于当前可用内存余量、后台进程竞争状态及设备实际内存压力动态判定的“相对性警告”。 具体解释如下： 1. 触发逻辑本质是“内存压力等级”，而非“绝对用量” iOS不以固定内存值（如1.8GB）为硬触发点，而是通过vm_pressure_level监控全局内存压力。当系统检测到： 可用内存页急剧减少（尤其活跃/非换页内存不足）； 后台App被大量驱逐释放内存； 文件缓存或压缩内存已达上限； → 系统向前台应用发送applicationDidReceiveMemoryWarning，提示其主动降载。 2. 1.8GB 高频触发的典型场景 此区间常对应： 应用已加载大部分资源（纹理、Mesh、Audio），但尚未触发Unity的Resources.UnloadUnusedAssets()或AssetBundle卸载； 原生堆（Native Heap）与托管堆（Mono Heap）持续增长，GLES驱动内存池（如MTL/OpenGL纹理缓存）接近饱和； 系统仍保留一定后台缓冲空间，此时压力信号敏感且频繁。 3. 更高内存（如>2.2GB）后触发率下降的合理原因 ⚠️ 并非系统“变宽松”，而是进入更危险阶段： 系统已启动强制干预：终止后台App、压缩inactive pages、回收文件缓存； 若应用继续申请内存，不再发送警告，直接触发SIGKILL（OOM Killer）； 此时applicationDidReceiveMemoryWarning反而减少 —— 因系统已无“协商余地”，转向静默强杀。 验证与优化建议 使用Xcode Instruments → Activity Monitor查看Real Memory + Pressure Level曲线，确认是否伴随vm: pressure上升； 在OnLowMemory回调中立即执行： Resources.UnloadUnusedAssets() + Texture2D.DestroyImmediate()清理未引用纹理； 切换至低配资源（如ASTC 4x4替代8x8）； 持久化关键游戏状态（防被杀丢档）； 限制峰值内存：通过UWA GOT Online的GPU/Resource/Mono报告，定位内存大户（如超大图集、未卸载AB、Mono GC堆膨胀）。 该行为是 iOS 内存保护机制的正常表现，高频警告恰说明应用处于“可挽救临界区”，需优先优化资源生命周期管理。 可以参考以上AI回答。而且实际上看起来这个警告只在1.8GB左右触发，也即内存更高时反而不触发的原因。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=bfb1e3df-0610-4f7f-b1c7-5b5a2498bbd7 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
如何使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