UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技
UWA博客，UWA旗下官方博客。针对游戏及VR应用性能优化等内容记录、交流，以及案例分享。博客主要分为：厚积薄发、万象更新、博观约取、充电一刻、博物纳新等几个模块。
我以前只能告诉你“哪个函数卡”，现在我能带你去看“玩家在哪卡”
大家好，今天 小Q 来聊聊我新学会的一手本事：卡顿分布。 你们递给我一个卡顿问题的时候，说实话，我以前只能做半个称职的同事。 我能告诉你是哪个函数在卡、调用栈长什么样、自身耗时多少毫秒 —— 这些对定位代码很要紧，我一直做得挺稳。可每次你顺口问一句“那……玩家是玩到哪儿才卡的？”，我就卡壳了。函数名答不了这个问题，因为同一个函数，会在无数个场景里被调用。 这半个答案，我自己都嫌它不称职。所以我去补了另一半。 为什么“半个答案”救不了你的排期会 先说清楚这半个答案为什么重要，不然你会觉得我小题大做。 调用栈是写给引擎看的坐标；可玩家的卡，是发生在场景里的。排期会上你真正要拍的那一下，从来不是“这个函数慢不慢”，而是 —— 这个卡，值不值得这个版本修？ 要回答它，你得知道：玩家是在核心战斗里卡，还是在一个没几个人去的角落界面卡？卡的那一刻屏幕上在发生什么？伤到了多少台设备、多少次会话？这些答案，过去得有人一份份翻报告、对时间戳、猜场景 —— 几乎没人做得动，于是这一层一直是空的。 我补的就是这块空。同一批卡顿帧，我换一套你能看懂的坐标，重新讲给你听。 ▶ 你要的从来不是“哪段代码慢”，是“这一下卡，该不该占用你这个版本的排期”。 现在，我能给你一份完整的卡顿现场 我把这手本事拆成五步，每一步，都是替你省掉的一次“本来得自己动手翻”。 第一步，我把每一次卡顿，还原成“玩家玩到哪里”。 我把这个问题下所有卡顿帧，按发生时间归属到玩家当时所处的场景，给你一张场景占比分布：哪个场景卡得最多、占多大比例、每次卡的均自身耗时、伤到了多少台设备和多少次会话。于是“函数卡了很多次”变成了“三分之二的卡都发生在重负载战斗场景”。你终于可以按“玩家在哪卡、多严重、伤多少人”来排优先级，而不是对着调用栈拍脑袋。这一步几乎不花额外成本 —— 我复用的是报告里现成的场景数据。 我给你的第一张脸：顶部先亮采样率/归属率/覆盖率，下面就是场景分布表 第二步，我把玩家当时看到的画面，摆到你面前。 Scene_HeavyLoad01是个什么画面？程序起的名字，你未必记得，策划更对不上号。所以我不只给你场景名，还给代表性的卡顿帧配上“卡顿时刻附近那张游戏截图” —— 玩家卡的时候，屏幕大致就是这样。要是几十张糊图堆给你，你一样看得累，所以我多做一步：把长得像的画面聚成“几类”，每类只留代表帧，再给每一类起个语义名、写句画面描述。你看到的不是一墙糊图，是“这个场景的卡，主要集中在这两类画面上”。 按相似画面聚类：每一类画面带耗时分布、样本数，还能一键回到最严重那一帧 第三步，我翻出卡顿前几秒，玩家到底做了什么。 一张卡顿时刻的截图能告诉你“玩家在哪”，答不出“为什么偏偏这一下卡”。有价值的线索，常藏在卡之前那几秒：玩家刚触发了什么操作？系统正好报了什么错？所以我为代表帧多采了一段 —— 卡顿发生前N秒（默认3秒）的自定义事件和错误，汇成清单摆在画面旁边。看画面、看行为、看报错，三条线索并排，卡顿归因才从“猜”变成“读”。 卡顿前的操作与报错：我把“卡之前发生了什么”也一并摊给你 第四步，我一键把你送回那一帧现场。 前面这些都还是“看”。你的下一步动作是“进去” —— 进到那一帧，看它的调用树到底卡在哪。所以我把这条路打通了：任意一张画面证据，点一下，我就在新标签页帮你打开那份异常报告，并自动高亮到对应的那一帧卡顿，右边同步显示它的帧截图和调用树。从“玩家在重负载场景卡得最多”到“这一帧的调用栈长这样”，中间不再断线。 点一下就回到现场：那一帧被高亮选中，截帧+调用树都在右边等你 第五步，我把这一切，收成一份带优先级的诊断。 信息够全了，可全也意味着要你一条条读、自己在脑子里拼结论。所以我留了个“AI汇总卡顿场景” —— 你点一下，我就把这些已经算好的证据一并接手：卡顿主要集中在哪些场景、按严重度怎么分层、可能的根因假设是什么、不同画面之间有什么差别。我读的不是几张缩略图，是这一整套结构化证据，所以给你的结论，和你眼睛看到的信息量是对齐的，不是一段套话。 ▶ 前四步，我把现场铺给你；第五步，我把现场读给你。你要动手的地方，只剩“改哪儿”。 卡顿从“一堆数据”变“一份结论”：我读完帮你划重点，还附上修复优先级 为什么这件事，值得你一版一版做下去 我想跟你算一笔账 —— 不是我的账，是你的账。 卡顿优化的回报，从来不是一次性的。你修好一个卡顿场景，换来的不只是“关掉一个ticket”，而是一批玩家从此在这里不再卡 —— 他们不会发帖道谢，只是安安静静地多留一会儿、多玩几局。这份回报，会一点点复利到你的留存里。 而卡顿分布要做的，是让你这笔投入尽量不打折： 它让你每一版的力气，都落在“最该修的那个场景”上。 过去按调用栈排期，容易被一堆零散的卡顿牵着走，修了半天，不确定有没有修在玩家真正难受的地方。现在你按场景占比和影响面来排：这一版先啃下卡得最多、伤人最广的那个场景，下一版再啃第二个。每一版都打在最疼的点上，优化的边际回报，自然一版比一版高。 而且，你修得越准，这份分析就看得越清。 你啃下一个大场景，玩家体验变好、愿意留下来的人变多；人一多，真机回传的性能报告也变多；报告一多，我给你的这份卡顿分布 —— 采样更足、场景归属更全、画面覆盖更高（就是最上面那三张卡片的数字）。也就是说，你每修好一个场景，不光换来留存，还顺手把“下一次分析的清晰度”垫高了。留存和分析质量，在这里是互相喂养的。 这才是我最想让你看见的那层复利：不是我变聪明了，是你每一版的优化，都在给下一版铺路。卡顿这件事，做一次也许只是救火；照这套方法一直做下去，它会从“救火”，慢慢变成“复利”。 有几句话，我想摆在明面上先说 我知道，凭一句“AI说的”，你不会就把排期押上去 —— 也不该。所以这份现场里，凡是可能让你犯嘀咕的地方，我都留了让你自己复核的余地。 这份分析几斤几两，我先摊开给你看。 页面最上面那三张卡片就是底牌：这次分析了多少卡顿帧、采样率多少、多少帧归属到了场景、多少帧配上了截图。覆盖率高不高，你扫一眼就有数 —— 一份七零八落的分析，我不会给你摆出十拿九稳的样子。 是近似的，我就在旁边写清“近似”。 卡顿前那些报错，是按错误指纹在时间窗上凑近的，不是逐条精确回放；那张表上我明写着“近似窗口”，凑不上就直说“附近没匹配到”，绝不拿数量给你充门面。 每一条判定线，钥匙都在你手里。 每场景分析多少帧、要不要取前后双帧、卡顿前采多长、要不要显示截图 —— 旋钮都摆在“分析参数配置”里。我给的只是一副还原现场的框架，框架里的线画在哪，你定。 最后一句，也是我最看重的一句：我会犯错。 认错画面、猜偏根因，都可能发生。碰上了，请随手给我标一下 —— 一个你能随时纠正的东西，才配让你把判断交给它。 现在就试试，入口比你想的近 说了这么多，你现在就能用起来：打开任意一个卡顿问题，切到「卡顿分布」，我就在那儿等你。 入口就在卡顿问题详情页的「卡顿分布」Tab，不用配置、不用埋点，现有数据直接看 我想帮你做成的事只有两件：让你每一次为卡顿花的力气，都花在玩家真正难受的那个场景上；也让你每一次采纳我的结论，是因为你查过、信得过，而不是因为我顶着“AI”两个字就该被信。剩下的，你按这套方法一版一版做下去 —— 留存，会替我把话讲完。 立即体验！ 专属通道限时开放 ➡ 预约技术专家1V1部署指导！我们将全程支持服务搭建、数据分析与反馈，确保您能够充分体验GPM 2.0带来的价值。 技术支持：gpm-support@uwa4d.com 微信：18683824 关于GPM 2.0 UWA GPM 2.0是专为上线及测试阶段游戏项目打造的高性能监测平台。它不仅能深度捕捉宏观性能数据，更创新采用性能无损截帧技术，在不影响玩家体验的前提下，助力开发者全面掌握玩家端运行关键细节，从多维度优化游戏性能，实现从“玩家投诉后救火” 到 “问题发生前预警” 的核心转变。 GPM 2.0全景指南 《UWA GPM 2.0 核心功能全景指南｜重塑游戏性能主动管控能力》 《5 月刊｜GPM 2.0实现全场景可视化溯源、多维度数据解析与根因精准定位》 《4 月刊｜GPM 2.0核心玩家守护功能上线》 《3 月刊｜GPM 2.0性能数据探索 & 单玩家追踪升级，精细化性能分析再突破》 《2 月刊｜GPM 2.0错误日志分析上线，PC端监测能力全维度突破》 《1 月刊｜日报周报一键获取+AI问答，玩家行为精准分析》 《12月刊｜上新！全局筛选+设备标记+序列帧回溯，GPM 2.0性能分析效率拉满》 《GPM 2.0 功能更新月报：新增导出表格、崩溃反混淆等实用功能，体验再升级！》 GPM 2.0新功能 集锦 《大家好，我是UWA GPM「小Q」，今天是我加入项目的第一天》 《GPM 2.0 全新上线WebGL专属监测，全面适配微信、抖音小游戏》 《无需自建机房运维｜UWA GPM 2.0 SaaS正式上线，让游戏线上质量监控轻量化落地》 《数字助理已到岗！GPM AI 智能体发布，游戏性能分析进入全自动时代》 《GPM 2.0全新功能发布｜GPU精准监测 + 精细化运营，重构游戏性能管控新范式》 《GPM 2.0 SDK 热更新｜不打扰玩家的“隐形升级术”！》 《GPM 2.0功能尝新｜AI识图精准捕获材质丢失，全链路护航游戏品质！》 《UWA GPM 2.0 崩溃上报功能全面升级｜全场景覆盖+免费开放，让崩溃排查不再卡壳》 《大R流失、问题难复现？GPM 2.0「指标 - Session直连」功能，让异常定位效率翻倍！》 《从 “被动救火” 到 “主动掌控”：GPM 2.0 如何重塑游戏性能竞争力？》 《GPM 2.0 告警功能：游戏性能的智能守护者》 《无限接近真相｜GPM 2.0 功能上新》 《崩溃现场全回溯｜GPM 2.0「崩溃上报分析」上线，终结“无法复现”的噩梦》 《GPM 2.0 全新发布「卡顿帧堆栈」功能｜精准高效定位游戏玩家端性能》 《深入解析「卡顿帧堆栈」》 《解锁GPM 2.0「卡顿帧堆栈」｜代码示例与实战分析》 《如何精准打点解决卡牌、SLG、开放大世界、放置类游戏卡顿难题》 《不妥协每一毫秒的性能 不浪费每一字节的流量》 《GPM 2.0 重新定义性能监测与优化｜从未知到已知，从被动到主动》 《UWA GPM 2.0 重新定义性能监测与优化｜如何高效预警和定位玩家端的性能问题》 GPM 2.0案例精讲 集锦 《携手护航亿级体验｜《迷你世界》× UWA GPM 2.0 性能管控最佳实践》 《我们的目标是不耗电、不发烫、不闪退》 《冒险闯关类玩家关注的四大体验》 《玩家端性能数据 一手掌握》 《造成设备功耗高的罪魁祸首》 《海外市场设备适配性优化实战》 《5000人测试背后的秘密》 GPM 2.0实操篇 集锦 《GPM 2.0 按游戏类型定制性能监控策略｜「告警」功能实战指南》 《GPM 2.0 让每一帧的流畅兑换真金白银》 《GPM 2.0 实操之Session篇之如果闪退是因为内存崩了》 《GPM 2.0 应用场景实例之解决让人头疼的问题》 《GPM 2.0 助力设备监测、行为分析与性能优化｜玩家共测新体验》 《GPM 2.0 API 功能助力定制你的性能优化》 《GPM 2.0 驱动优化与投放决策｜硬件性能一目了然》 《GPM 2.0 多维数据与实时截帧完美结合》
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 前言 Resources.UnloadUnusedAssets是Unity项目里一个绕不开的卡顿源：每次切场景，它都会在主线程上同步跑完一整套Native资源回收，200-300ms的耗时几乎是常态，低端机甚至到秒级别。而它名称标注的“异步”并不能把任何工作真正挪出主线程。 下面分四步拆：执行机制、耗时构成、可优化的环节、具体的改造方法。最终给出两套互不冲突的优化方案，实测能把整体耗时减少70%左右。 一、Resources.UnloadUnusedAssets的作用 1.1 它要解决的问题 写过Unity的都遇到过这种代码： GetComponent<Renderer>().material.color = Color.red; 这一行看着无害，实际触发了一次Material Clone。Renderer.material的Getter不直接返回Shared Material，而是检查“这个Renderer是否已经持有自己的Material实例”，没有就Clone一份。Clone出来的实例，生命周期挂在Renderer 上。 问题来了：Renderer销毁了，Clone出来的Material谁来回收？C#这边没人引用它了，但它是个NativeObject，GC.Collect()管不到。 Resources.UnloadUnusedAssets就是来收这个尾的。它扫一遍所有还活着的NativeObject，找出那些“既不被C#持有、也不在场景里”的资源，挨个释放。 1.2 还在哪里被触发 主动调用是一种，但更隐蔽的是：每次SceneManager.LoadScene（非Additive模式），引擎自动调一次。这是写在PreloadManager里的固定流程，关不掉。也就是说，只要项目用Unity切场景，这个API就会规律性地造成固定卡顿开销。 1.3 规避的做法 其实上面两个例子，UnloadUnusedAssets的调用并非完全不可规避：Clone出来的Material可以自己持有引用手动销毁，切场景的那次触发也可以稍微改造引擎去掉。但实际项目有历史负担，也要控制内存峰值，在我面对的业务场景下回避不了，只能优化。 二、它做了什么：执行流程与异步真相 一次UnloadUnusedAssets耗时拆解 UnloadUnusedAssets的内部流程，可以打这么个比方：它像物业管理员搞一次小区普查，目的是腾出空房子。整个流程分五步，每一步对应一个具体阶段。 2.1 五个阶段 第一步：列住户名册：FindAllLiveObjects打开物业系统，把所有登记在册的住户（Object::IDToPointerMap里的所有对象）抄一遍，生成liveObjects[]。 第二步：确认门牌种子：MarkSceneRootsAndReduceLiveObjects找出哪些住户是“明确不能动的” —— 场景里挂着的GameObject、MonoBehaviour、AssetBundle等，以及MarkManagerRoots找出的全局Manager。这些标成Root，索引加入initialNeedsProcessing队列，作为追踪起点。 第三步：建索引：CreateObjectToIndexMappingFromNonRootObjects为剩下的对象建一张“InstanceID → 索引”的哈希表。后面要频繁通过InstanceID找对象，有这张表能在O(1)查到。 第四步：把C#那边的根也加进来，然后挨家敲门：MarkManagedStaticVariableRoots + MarkAllDependencies 这一步是两个连续动作，合起来占全流程接近九成： 4a. MarkManagedStaticVariableRoots（=GC.MarkStaticDependencies，~49%）：调IL2CPP的Liveness::FromStatics()，遍历所有C#静态字段、递归walk整个托管堆，把发现的Native对象加入needsProcessing队列。这一段全程在C#托管堆里跑，没碰Native图遍历。 4b. MarkAllDependencies（=GC.MarkNormalDependencies，~39%）：一次性消费两个队列 —— initialNeedsProcessing（场景根/Manager根）和needsProcessing（FromStatics新发现的根），递归MarkDependencies，把所有可达Native对象Marked=1。 第五步：封空房：CleanupUnusedObjects扫一遍，所有没被标记到的对象，挨个释放Native内存。 2.2 “异步”的真相：这个API不是真异步 到这里有人会问：这个API不是异步的吗？返回的是AsyncOperation，这么大的耗时不是应该挪到子线程去跑、不阻塞主线程吗？不是。它的“异步”只是延迟一帧： 调用时，把操作加入PreloadManager队列，立刻返回 下一帧的PreloadManager.UpdatePreloading主线程执行，触发IntegrateMainThread() IntegrateMainThread里把整个GarbageCollectSharedAssets()跑完 —— 主线程独占 子线程那边只做了一件小事：关一下文件流，占比可忽略 也就是说，所谓“异步”，只是把卡顿推迟到下一帧；主线程并没有逃脱阻塞。 2.3 案例 在一个MOBA项目中，Loading末尾调了一下Resources.UnloadUnusedAssets，目的是把Loading期间临时加载的资源回收一下，避免战斗场景内存压力大。调用是fire-and-forget的： // 加载各种资源 DoBattleLoading(); // Loading 末尾 Resources.UnloadUnusedAssets(); // 接下来发送自己加载结束的消息 SendMarkLoadingComplete(); 听起来很合理。但上线后发现一件诡异的事：不同玩家入局时间不一样。有的玩家进战斗场景立刻能动，有的玩家会卡一会儿才能动。 排查到最后定位到这个API。原因是：UnloadUnusedAssets的Integrate时机依赖PreloadManager的调度，它什么时候在主线程跑，引擎说了算，Integrate一般会推迟执行。 结果就是Integrate漏掉了战斗场景刚开始的那一帧。这一帧主线程被GC阻塞了200ms，玩家看到的就是“刚进入战斗就卡了一下”。这种漏出的发生概率和设备性能、当前帧的工作负载、场景切换的Loading速度都有关。低端机更容易漏掉战斗场景，高端机偶尔也会中招，一场对战10个玩家，每个人入局时间都不一致。 2.4 把控制权拿回主线程 把引擎里现成的WaitForAllAsyncOperationsToComplete暴露给脚本层就够了。这个方法在PreloadManager一直存在，只是没暴露到C#。也可以用协程轮询AsyncOperation.isDone，等做完再继续。 Loading末尾改成： // Loading 末尾 Resources.UnloadUnusedAssets(); // 主线程同步等到所有 PreloadManager 队列里的异步操作完成 WaitForAllOperationsToComplete(); // 此时 GC 已经跑完,可以安全跳战斗 SendMarkLoadingComplete(); 代价是Loading末尾多卡200ms，但卡在Loading玩家心理上能接受（进度条还没满），卡在战斗里不能接受（对战已经开始了）。这个改动不解决“为什么慢”，但解决了“什么时候慢”，把不可控的延迟变成可控的延迟。 「慢」本身怎么解，下一节开始处理。 三、标记跳过：让扫描器学会绕路 前面我们提到，第四步两个子项里，MarkStaticDependencies（4a）单独占总耗时35-45%，是最大的单点热点。 为什么4a这么慢？ 它要做的是IL2CPP层面的Liveness扫描： 拿到所有有静态字段的Class 每个Class的每个静态字段，如果是引用类型，就读出来 引用指向的对象，继续遍历它的所有字段 字段里的对象是数组，数组每个元素再遍历 一直递归下去，直到没有新发现 时间复杂度大概是O(类数量×静态字段数×引用深度×数组元素数)。一旦项目代码量上来，这个数字就爆炸了。实测一个中等规模MOBA项目，单这一步就要50-100ms。 我的直觉是，我们游戏C#层存在大量配置表对象，这块确保无Native对象，就没必要扫，一定是有明显的优化空间的，所以，可以进行一些标记，让扫描器看到不必扫的字段就跳过。 听起来简单，做起来要绕过三道坎。 3.1 第一道坎：运行时判断不能慢 最朴素的想法是给字段挂个[SkipLiveness] Attribute，扫描时查一下： if (Reflection::HasAttribute(field, SkipLivenessAttribute)) continue; HasAttribute涉及二分查找，O(log n) 一次。每个字段每次GC都查一遍，一千个类、几万个字段，加起来就是几十毫秒 —— 优化没做出来，先把扫描搞慢了。 要做就要做到O(1)。 3.2 第二道坎：在哪里挂这个标记位 IL2CPP里描述每个字段的元数据是Il2CppType结构，其中有个Attrs字段，16位： unsigned int attrs : 16; // 字段属性 bitmask 这16位定义在il2cpp-tabledefs.h里，有STATIC、LITERAL、HAS_DEFAULT等等。但其中有几个位是闲置的： 借位3，定义成： #define FIELD_ATTRIBUTE_SKIP_LIVENESS 0x0008 扫描时一个位与判断就完事： if (field->type->attrs & FIELD_ATTRIBUTE_SKIP_LIVENESS) continue; 零内存开销，扫描时多一次判断。 3.3 借位安全吗：TaggedPointer同源思想 借空闲位不是新发明，业界用了几十年了。最经典的例子是IL2CPP Liveness自己用的TaggedPointer。 64位指针对齐到8字节，每个合法对象地址的最低3位永远是0： 0x7fff12340000 ← 末位 0 0x7fff12340008 ← 末位 0 0x7fff12340010 ← 末位 0 IL2CPP直接借最低位做“已访问”标记： #define MARK_OBJ(obj) (obj)->klass = ((obj)->klass | 1) #define IS_MARKED(obj) ((obj)->klass & 1) #define GET_CLASS(obj) ((obj)->klass & ~1) 零内存开销，完全不需要额外的哈希表记录“哪些对象访问过”。 借Attrs的位3也是同样的思路：这个位本来就闲着，使用前后保持向下兼容，所有读写Attrs的地方都按位掩码取自己关心的位。 未来要注意的是IL2CPP升级时，如果官方启用了位3表示别的语义，就要换一位。维护成本是每次跨版本升引擎时检查一下il2cpp-tabledefs.h的位定义有没有冲突。 3.4 第三道坎：GC描述符的暗坎 按上面的方案改完，直接挂在Static字段上的 [SkipLiveness] 是生效的 —— 这正是3.2方案能解决的核心场景。 写法：静态字段直接标记（生效） public class ConfigManager { [SkipLiveness] public static List<ConfigEntry> _configCache; // 跳过这个静态字段的 Liveness 扫描 [SkipLiveness] public static Dictionary<int, string> _strTable; // 同上 } Liveness跑到ConfigManager时，FromStatics迭代它的所有静态字段，在FieldCanContainReferences里查field.attrs & FIELD_ATTRIBUTE_SKIP_LIVENESS，命中就直接Continue。这两个字段连同它们引用的整张对象图，全部不再扫描 —— 这正是省下耗时的来源。 但在实战中，业务代码不会这么干净。更常见的写法是把多个字段封装到一个数据类里，然后Static字段持有这个类的实例。这时候问题就来了：把 [SkipLiveness] 标记到嵌套实例字段上，无效。 写法：嵌套实例字段标记（失效） public classTestData { [SkipLiveness] public List testList; // 实例字段,标记无效 [SkipLiveness] public Dictionary<int, string> dict; // 同上,标记无效 } publicclassManager { publicstatic TestData data; // 静态字段(第一层) } FromStatics进入Manager.data后，Liveness拿到TestData实例，然后期望按字段上的 [SkipLiveness] 跳过testList和dict。但实测下来testList 还是被完整遍历了一遍 —— 挂在实例字段上的标记完全无效。 为什么失效：GC描述符的快速路径 刨根挖到Liveness::TraverseGenericObject： size_t gc_desc = (size_t)(GET_CLASS(object)->gc_desc); if (gc_desc & 1) TraverseGCDescriptor(object, state); // 走快速路径(按位图扫) else if (有数组) TraverseArray(...); else TraverseObject(...); // 走慢速路径(逐字段查 attrs) 问题出在gc_desc（GC描述符）。它是IL2CPP给每个Class预生成的一张位图，按字段偏移记录“哪些位置是引用类型”。 class TestData 内存布局 gc_desc 位图 ──────────────────── ────────── offset 0: [object header] 0 offset 8: [List ] ──► 1 ← 引用,需扫描 offset 16: [Dictionary] ──► 1 ← 引用,需扫描 offset 24: [int field] 0 ← 值类型,不扫 实例对象走TraverseGCDescriptor时，直接按位图偏移取引用、加入待处理队列，完全不经过字段元数据，field.attrs上挂的SkipLiveness位没人查。所以失效。 而静态字段没有gc_desc这层快速路径，走的是FromStatics → 字段元数据迭代，所以field.attrs标记对静态字段是生效的。一个走快速路径、一个走慢速路径，造成了行为差异。 那把gc_desc也改一下，把SkipLiveness字段从位图里去掉，行不行？不行。 gc_desc不只Liveness在用，Boehm GC分配内存时也用它： if (klass->gc_desc != GC_NO_DESCRIPTOR) o = AllocateSpec(klass->instance_size, klass); // 把 gc_desc 传给 Boehm Boehm GC通过gc_desc知道对象内部哪些位置是引用，GC标记阶段才能精确扫描这些位置。如果在gc_desc里把SkipLiveness字段排除掉，Boehm GC也会跳过这些字段，字段引用的对象就不会被标记为存活，然后被错误回收会导致野指针，崩溃。 gc_desc是禁区，不能动。 解决方向：换一层挂标记 字段级标记走不通，那就整个类一起挂 —— 既然TraverseGCDescriptor是按Class派发的（GET_CLASS(object)->gc_desc），那么在判断走不走快速路径之前，先在类级别拦一道：这个类整体跳过。 写法：类级标记 [SkipLiveness] // 标记到类上 publicclassConfigPayload { public List testList; // 类内字段都不再被 Liveness 扫描 public Dictionary<int, string> dict; public NestedConfig nested; // 嵌套引用也不会再下钻 } publicclassManager { publicstatic ConfigPayload payload; // 静态字段持有这个纯数据类 } 业务侧的用法很简单：把所有需要跳过Liveness的纯数据字段，封装到一个独立的类里，在类上挂 [SkipLiveness]。 3.5 解法：类级跳过 绕过快速路径的办法，是在更高一层拦截。在Il2CppClass.flags（32位）里再借一位： #define TYPE_ATTRIBUTE_SKIP_LIVENESS 0x00400000 然后在AddProcessObject入口判断： bool skip = (GET_CLASS(obj)->flags & TYPE_ATTRIBUTE_SKIP_LIVENESS) != 0; bool has_references = obj->klass->has_references && !skip; 整个类的实例对象都不递归遍历，但gc_desc一个字节都不动，Boehm GC仍然按原样精确扫描。 这样一共有三个拦截点，组成一个完整的跳过体系： 静态字段扫描入口（类级）：整个类的所有静态字段一起跳 字段级判断：单个静态字段的Attrs检查 实例对象扫描入口（类级）：这个类的实例不再递归 并且整套机制可以通过运行时开关动态控制，线上出问题可以一键关掉。 IL2CPP代码生成器要配合改。 这两个标记位（field.attrs位3和klass.flags位22）是IL2CPP在编译时写入的。所以只改Liveness.cpp不够，IL2CPP代码生成器（C#写的Unity.IL2CPP那一套）也要改。 要改的地方有两个钩子。 钩子1：字段级Attrs —— 在Il2CppTypeCollectorComponent.Add之前，扫一下字段的CustomAttributes，如果命中 [SkipLiveness] 就把Attrs加上位3再传进去： // 伪代码:在生成 Il2CppType 元数据时 int attrs = (int)field.Attributes; // 原本的 STATIC / LITERAL 等 if (HasSkipLivenessAttribute(field)) attrs |= 0x0008; // FIELD_ATTRIBUTE_SKIP_LIVENESS Il2CppTypeCollector.Add(fieldType, attrs); 钩子2：类级Flags —— 在生成Il2CppClass元数据时，扫类的CustomAttributes： // 伪代码:在生成 Il2CppClass 元数据时 uint flags = ComputeOriginalClassFlags(typeDef); if (HasSkipLivenessAttribute(typeDef)) flags |= 0x00400000; // TYPE_ATTRIBUTE_SKIP_LIVENESS EmitClassFlags(typeDef, flags); 两个钩子都是几行代码，但找位置要花点功夫，Unity.IL2CPP项目里生成元数据的入口分散在几个Component里，需要顺着Il2CppTypeCollectorComponent、MetadataWriter这些类去找具体的字段/类元数据写入点。 最后顺手定义一下C#侧的Attribute，让业务代码能用： namespace UnityEngine { [AttributeUsage(AttributeTargets.Field | AttributeTargets.Class)] public class SkipLivenessAttribute : Attribute { } } 3.6 适合什么、不适合什么 这个优化的安全性，取决于“被标记的对象是否会持有非场景内的Native引用”。 为什么强调“非场景内”？因为场景里的GameObject、Component、以及挂在它们上的Material/Texture，在流程里走的是另一条扫描路径 —— MarkSceneRoots会从场景根开始把它们全部keep住，不依赖C#静态字段这条路径。也就是说，即便你的类持有一个场景里的GameObject引用，跳过C#这边的扫描也不会让它被误回收，因为场景那条路径已经替你兜底了。 真正危险的是游离于场景之外的Native引用：Resources.Load出来但没挂到场景里的Texture、AssetBundle加载后缓存在C#字典里的Material、动态生成的Mesh等。这些只靠C#静态字段链路引用，标记跳过后就会误删。 适合： 游戏内的配置表（纯数据） C#帧同步层对象（有放服务器跑的诉求，这一层天然不能持引擎Native引用） 客户端缓存的其它大量纯数据结构，比如一些服务器协议对象 不适合： 持有Resources.Load/AssetBundle加载且未入场景的Native引用的对象 持有动态生成、未挂到场景的Material/Texture/Mesh引用的对象 其它任何你拿不准、不熟悉的对象 加错了的后果是：这些游离Native资源被错误回收，运行时崩溃。这也是为什么这个优化要谨慎，一刀切打到不该打的对象上，死状很惨。 四、多线程优化：扩大并行窗口期 用多线程榨多核算力是常规思路。Unity 2019里多线程优化的雏形已经在了，但官方没写完，用宏隔离关闭着；Unity 2022已经延续开发并默认开启。 所以可以借助AI把2019的多线程优化直接「补全」，但默认方案仍有很大的改造空间。 4.1 默认的多线程优化方案 整个流程核心是优化MarkManagedStaticVariableRoots + MarkAllDependencies这两段的耗时。先把它们做的事说清楚： MarkManagedStaticVariableRoots（简称FromStatics）：从所有C#静态字段出发，沿引用链递归遍历整个C#托管堆，把过程中遇到的Native对象InstanceID加入needsProcessing队列。全程在托管堆里跑，不进Native图。 MarkAllDependencies：消费needsProcessing队列，在Native对象图上做BFS —— 取一个Native对象、读它的引用、新发现的加回队列，直到队列空，所有可达对象Marked=1。 注意MarkAllDependencies这个函数其实是被调用两次的： 第一次，主线程进FromStatics之前先派发一次给Worker —— 让Worker提前消费已经准备好的initialNeedsProcessing（场景根+Manager根+External根这批“提前知道存活的种子”） 第二次，主线程在FromStatics跑完之后自己再调一次 —— 消费mainThreadState.needsProcessing（也就是上面FromStatics边扫边写入的那张清单） 所以严格说，这两段是同一个函数体的两次执行，但消费的是两个不同来源的队列。 调度顺序是这样的： 把Unity 2019的多线程改出来后，实测主要问题是和FromStatics并行的那段MarkAllDependencies太短：Worker处理的是初始根（场景根+Manager根），对象数量不够多；主线程的FromStatics更长，Worker跑完之后整段闲置。 更令人不是滋味的是，FromStatics过程中发现的对象数量挺多，之后主线程独自MarkAllDependencies(mainThreadState)时间也很长，这段是单线程的，Worker全在旁边看着。 改造方向：加一个跨阶段共享队列staticNeedsProcessing，让Worker跑完初始根不退出，继续消费FromStatics实时写入的新Root，把Worker的闲置窗口压到接近零。 4.2 生产者-消费者模型 要让工作线程跑起来，需要打破“主线程等Job → 主线程跑FromStatics → 工作线程空闲”的串行结构。改造目标： 主线程派发多线程任务后不等，立刻开始跑FromStatics FromStatics每发现一个新对象，实时投放到一个共享队列 工作线程消费这个共享队列 主线程跑完FromStatics才同步等待 这是经典的生产者-消费者模型。 4.3 共享队列的设计 场景是1个生产者（主线程FromStatics回调）+N个消费者（Worker），生产消费同时发生，容量上界已知（liveObjects.size()），生命周期只到本次GC结束。 读写索引分离 引擎默认多线程做MarkDependencies的队列方案是单个Count计数器，生产者Inc、消费者Dec。我们这里需要改成读写索引分离的，因为会涉及同时生产和消费。同一个值同时被多方修改、同时承担“下一个写位”和“剩余多少”两种语义，一旦消费者Dec到负数想回滚、生产者又刚好Inc，Slot就会被复用，读到错乱数据。 正确做法是单调单写： 两个索引各自单调递增，writeIndex - readIndex永不为负，Slot不会被复用。 伪代码 // 生产者(主线程)— 无并发,无需 CAS buf[writeIndex] = index; // 先写数据 atomic_store(&writeIndex, writeIndex + 1, release); // 再发布索引 // 消费者(Worker)— 多消费者用 CAS 抢 slot for (;;) { int r = readIndex; int w = atomic_load(&writeIndex, acquire); if (r >= w) { if (atomic_load(&scanComplete, acquire) && readIndex >= writeIndex) break; // 真的没了 YieldProcessor(); continue; } if (!CAS(&readIndex, r, r + 1)) continue; // 抢失败重来 MarkDependencies(buf[r]); // children 进本线程私有队列 } 关键点： 写顺序：数据先落，索引后发布。Release保证消费者Acquire读到新writeIndex时数据已可见 CAS抢readIndex：多消费者只有一个能成功，失败重试，不需要锁 不用Ring Buffer：容量按上界Reserve，索引单调推进到底就停；两个索引每次GC前归零 和JobSystem无锁栈的设计很相似 4.4 改造后的流程 引入共享队列后，Worker处理完初始根不再退出（Thread::YieldProcessor），FromStatics边遍历边把新发现的Root实时写入共享队列，Worker一直在消费，主线程的FromStatics一结束就也过来一起干活。 测下来主线程、Worker几乎全程满载，并行度大幅提高。 4.5 Unity 2019 vs 2022的差异处理 如果用的是Unity 2022，前面这套生产者-消费者模型基本就是2022已有架构上的扩展，改动相对集中。如果用的是Unity 2019，要做的事情更多。但这里有个判断要做：完全照搬2022的实现不一定划算，要平衡改动量和优化效果。 下面几个2022的设计选择，在2019里可以用更简化的方案。 不必单开线程池 Unity 2022里有个AGCThreadPool，几个专用线程只服务资源回收流程，不参与JobSystem调度。 专用线程的好处是“不被其他工作干扰”。但仔细想想：UnloadUnusedAssets期间，整个硬件资源本来就高度集中在这一件事上 —— 主线程在跑GC，业务逻辑也卡住了，JobSystem也基本没别的活干。这时候专用线程的“隔离”优势就消失了。 代价倒是实打实的：每开一个线程多一份栈内存（默认1MB起）。在内存压力本来就大的移动设备上，这是浪费。2019里直接复用JobSystem的Worker线程即可。JobSystem的work-stealing机制会自己处理负载均衡，业务空闲时这些线程也能去做别的事。 IL2CPP内部缓存可以简化 Unity 2022在IL2CPP扫描时用了两个特殊的数据结构： DynamicHeapAllocator（引擎自己的内存分配器）替代malloc/free。优点是统一追踪、TLSF算法分配快、减少碎片化。 CustomGrowableBlockArray（分块数组）替代连续数组。优点是Grow时只新分配一个Block，不需要把整个数组复制到新地址。 这两个优化都很合理，但都是非核心的优化，单次触发的内存分配次数有限，大部分时间不在分配上。 2019里可以简单替换： 内存分配：直接用OS默认的malloc/free，并且在确定了内存分配方案后，不需要涉及GC堆的stop/start world处理，能规避一个死锁的问题 数组容器：维持连续数组（std::vector-like），Grow时复制一次，可接受 这两个简化省下的代码量是几百行，对最终效果的影响在1-2ms量级，不值得为了这点收益去维护两套数据结构的兼容代码。 五、效果验收 这两个优化在不同的设备、不同项目，甚至同一项目的不同时机均有较大的差异。只能提供一些参考数据，在1+8pro（晓龙865、4 Job Worker）上，在一个耗时较重的时机，优化前要200ms，优化后仅需50ms，优化幅度达75%。 未开优化完全交给主线程，200ms左右 开启优化，Job Worker负载上来了，50ms内搞定 六、一些想法 截至写本文时，前面的两个方案（标记跳过+多线程窗口扩大）已经在项目里落地，下面是之后回头的一些想法。 6.1 把【加不加标记】从人脑里拿出来 整个是否加标记的判断标准：“这个类的字段里会不会摸到非场景内的UnityEngine.Object”，说白了就是人工做可达性分析，这件事是这套方案最脆的环节： 漏审一次，Native资源被误回收，运行时崩溃，且崩溃点离标记点很远，排查成本极高 收益受限于“敢标的范围”，不敢标的部分继续被扫，优化天花板被人压低 但“类型字段闭包能否到达UnityEngine.Object”在数学上是个纯静态可达性问题，IL2CPP在生成元数据时拿得到完整类型图，完全可以自己算出来。 可行的做法是在IL2CPP代码生成器里插一个Pass： // 简化伪代码 bool CanReachUnityObject(TypeDef t, HashSet<TypeDef> visited) { if (!visited.Add(t)) returnfalse; if (t.IsSubclassOf("UnityEngine.Object")) returntrue; foreach (var f in t.InstanceFields) { var ft = f.FieldType.Resolve(); if (ft == null) returntrue; // 不可解析:保守 if (ft.IsInterface || !ft.IsSealed) returntrue; // 多态:保守 if (CanReachUnityObject(ft, visited)) returntrue; } returnfalse; } // 对所有 sealed 类 / struct,闭包不可达 UnityEngine.Object 的, // 自动给 Il2CppClass.flags 打上 TYPE_ATTRIBUTE_SKIP_LIVENESS 这样： 配置表、纯数据Struct、协议结构体，绝大多数Sealed类型自动识别，不漏不错 多态字段（Object、Interface、未Sealed基类）无法静态决定，保守不打，继续走原扫描，安全的部分自动加速，不安全的部分原样跑 手工Attribute退化成“针对静态分析判true、人工确信安全的多态字段”再加 挂载点和3.5改的位置完全一致，MetadataWriter写出Il2CppClass.flags之前的Type Pass。改造工作量在数百行代码量级，但能把“人审”这个成本移走大部分，而且预期会有更高的收益。 6.2 怎样才能做好资源管理？ 回到更上层的问题：Resources.UnloadUnusedAssets这个API之所以存在，是因为引擎需要替我们补一个所有权模糊的窟窿，磁盘加载的Asset没有显式的Owner，Instance Asset也没人负责，只能定期反扫一次。 主流资源框架（Addressables/YooAsset/XAsset/ResKit等）做的事：在脚本层把这个窟窿堵上： 资源框架在加载入口Load+1、Release-1、归零卸载，把“磁盘Asset的生命周期”从Unity手里抢回来，用Refcount做显式管理。 这一层一旦到位，反过来给“标记跳过”提供了正当性 —— 留在C#静态字段图里的，绝大部分就只剩纯数据了（配置表、帧同步状态、缓存字典、协议结构）。这些天然不持Native引用，正好是SKIP_LIVENESS最安全、最适合自动标记的对象。3.6那条判断标准在框架接管之后，从“逐个审”退化成“基本可以全打”。 理想的工程实践 三层各管一段，合起来才是这个时代Unity项目能拿到的“实际最优解”： 资源框架解决“该谁释放谁释放”的所有权问题：这是结构性的，只能从加载入口前移。 本文的两个优化解决“剩余必须留给引擎兜底”的部分，扫描尽可能快：这是局部的，但在不能改API的前提下已经是上限。 HideFlags解决“即便前两层判错，关键资源不丢”：这是兜底的，成本极低，价值在于把“判错”的后果从崩溃降级为内存多占。 6.3 横向对照：UE是怎么处理同一类问题的 回头看6.1和6.2提的所有方向（自动标记、显式所有权、资源框架、Native兜底），会发现UE都有现成的对应机制。UE也有同一类问题：GC可达性扫描卡帧在UE社区同样是著名痛点，社区整理的Epic资料[1]记载，Fortnite靠Blueprint Clustering才把PS4上的GC Mark耗时从~66ms压到~22ms。 真正的差别在还债的成本，同一类补丁，UE做起来结构性地便宜。 对应关系大致这么排： 为什么同一类补丁，UE便宜、Unity贵？ 根因在对象模型：UE是一张图，Unity是两个世界。 UObject一开始就是GC-aware设计（对象系统在设计之初，就把“垃圾回收要怎么扫我”这件事考虑进去了）。UPROPERTY是显式声明，UHT在编译期产出完整反射信息，运行时据此组装Reference Token Stream、构建Cluster、支持Weak Pointer，每一轮GC补丁都是在引擎完全掌控的元数据上做增量改进。 Unity的对象系统则是native-first：对象归C++ 管，C#侧只是持m_CachedPtr的Wrapper，对象所有权从未交给脚本层。要说清的是，Unity的Native半张图其实有自己的Token Stream等价物，MarkAllDependencies靠序列化元数据遍历Native引用，这部分并不慢；真正缺显式声明的是Managed侧的引用，它们散落在一个引擎读不懂的托管堆里，只能整堆反向扫描。这也正好解释了为什么本文的优化火力全部打在FromStatics 上：债务只欠在半张图上。 换句话说：200ms不是Unity独有的，而是“全图可达性扫描”这个通病在双对象世界里的重症形态，每一分同类优化都得先跨过IL2CPP这道墙；DOTS选择的则是绕开对象模型、躲掉GC，而不是把对象系统改造成GC-aware。我们这种【在传统GameObject世界里贴补丁】的工程，就是这个时代的常态。 参考 1 Epic 资料 https://ikrima.dev/ue4guide/performance-optimization/asset-size-loading/ 这是侑虎科技第2008篇文章，感谢作者其乐陶陶供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/jun-yan-76-80 再次感谢其乐陶陶的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
角色拉远切低模后，渲染线程为何还是很高
1）低模后，Render为何还是很高 2）同一个模型，为什么近距离测试帧率下降 这是第485篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：角色拉远切换低模后，Render线程耗时为什么没有明显下降？重新拉近后，为什么又出现MeshSkinning耗时升高的情况？ A：Render线程耗时升高，并不一定就是渲染压力过大。很多情况下，看到Render线程占比较高，大家会优先去看模型复杂度、Shader或者GPU负载。但在实际项目中，Render线程也可能只是等待其他线程完成任务，并不代表自身执行了大量渲染工作。 这个项目中就遇到了类似情况，角色拉远后，会自动切换到低模，同时Shader复杂度也有所降低。按照正常预期，渲染开销应该有所下降，但实际测试中发现，切换低模后Render线程耗时并没有明显降低。 查看Profiler后可以看到，Render线程的耗时主要来自等待Job工作线程完成计算，而Job线程中的热点集中在MeshSkinning相关计算。 这里需要注意，并不是角色重新拉近后，单纯因为模型面数增加，导致MeshSkinning耗时升高。结合该项目业务逻辑可以看到，角色处于远距离状态时，项目侧会主动降低部分相关计算的更新频率，以减少性能消耗。当角色重新进入Near范围后，需要恢复这些计算，因此Job线程会出现一次明显的耗时峰值。 继续结合业务代码分析可以发现，Job线程热点集中在MeshSkinning相关计算，而这部分计算与衣物模拟相关逻辑存在关联，例如StartClothUpdate流程。 角色进入Near范围后，相关业务计算恢复更新，触发Skinning/变形相关Job执行，导致Job线程出现耗时峰值。而Render线程由于需要等待Job任务完成，最终表现为Render线程耗时升高。 因此，这里的问题并不是Render线程本身执行了过多渲染工作，而是等待其他线程任务完成导致的耗时增加。后续优化方向应该关注对应业务计算和更新策略，而不是单纯降低Render线程开销。 实战案例 Q：我们项目在测试过程中发现，模型处于远距离状态时帧率表现正常，但切换到近距离显示状态后，帧率出现明显下降。当前测试场景中，画面中看到的是一个Cube模型，但实际由多个模型叠加组成。在模型资源本身没有发生明显变化的情况下，如何确认这种情况下帧率下降的原因，以及应该如何进一步定位瓶颈？ A：遇到这种情况时，不能只根据模型资源是否发生变化判断性能消耗，还需要结合运行时数据确认具体瓶颈。 从UWA GOT Online报告可以看到，Near+Cube+Original场景下持续出现：Gfx.WaitForPresentOnGfxThread耗时。 该函数通常表示CPU侧正在等待GPU完成当前帧相关工作，可能与GPU侧压力或同步等待有关，因此需要进一步结合GPU指标判断。 为了进一步确认GPU状态，我们使用支持GPU参数采集的OPPO R17设备进行了补测。由于Original材质在该设备测试过程中存在稳定闪退问题，无法直接采集对应GPU数据，因此采用Empty材质场景进行测试。 需要注意的是，出现Gfx.WaitForPresentOnGfxThread耗时的场景为Original材质，而GPU数据采集来自Empty材质，两者并非完全相同工况。Empty场景的数据可以辅助判断Near状态下存在较高GPU压力，但不能完全等同于Original场景的GPU表现。 测试结果显示，在Near+Cube+Empty场景下，GPU Clocks和Fragment Shaded指标均出现明显升高。 其中，Fragment Shaded明显增加，说明GPU需要处理更多像素相关计算。模型距离摄像机变化后，屏幕占比也会发生变化，即使模型资源本身没有变化，近距离状态下也可能因为覆盖更多屏幕区域导致Fragment计算量增加。 需要注意的是，虽然画面中看起来只是一个Cube，但实际测试场景中存在多个模型叠加的情况，因此不能只根据单个模型的外观判断实际渲染开销。如果当前GPU压力高于预期，还需要结合模型叠加数量、材质类型以及渲染方式进一步排查。例如半透明材质、多层覆盖等情况，可能导致Overdraw增加，从而进一步提升Fragment计算量。 在实际项目分析中，可以结合UWA GOT Online GPU模式下的热力图查看高消耗区域。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
为什么这一帧会卡？答案可能藏在Timeline里
做性能分析的时候，很多人都有过这样的经历：掉帧找到了，CPU热点也看了，函数堆栈也翻了，却还是说不清这一帧为什么会卡。分析一圈才发现，真正的问题可能并不在MainThread，而是在另一个线程。 Timeline做的，就是把线程执行、函数耗时、调用堆栈等信息放到同一条时间轴上，让原本分散的信息重新串联起来，帮助大家更快看清这一帧到底发生了什么。 CPU告诉你哪里慢，Timeline告诉你为什么慢 很多人第一次使用Timeline时都会有一个疑问：它和CPU分析是不是重复了？其实，两者解决的是不同的问题。 CPU分析负责快速找到性能热点，告诉你哪里慢了；Timeline则把线程执行、函数调用和时间关系放到同一个时间维度里，帮助回答另一个问题 —— 为什么慢了。 例如CPU数据显示某一帧耗时很高，仅凭CPU分析，很难判断到底是后台线程一直在加载资源，还是MainThread在等待某个异步任务完成。而在Timeline里，整个执行过程都会按照时间顺序展示出来，谁先执行、谁持续时间最长、线程之间有没有等待关系，都能够直接看到。对于异步加载、多线程协作、偶发卡顿这类问题，两者配合使用，会更容易把问题定位清楚。 以前排查卡顿：纯靠人工“拼现场” 以前调过卡顿的同学应该都有体会，流程基本都很痛苦：先抓到哪一帧卡了，然后跳去瞅瞅CPU；瞅着像线程问题，又得去翻线程数据；要是怀疑资源没加载好，还要再开一个资源分析页面。最后，还得把函数堆栈和运行日志扯过来一起对。一帧卡顿到底咋回事，全靠你自己在脑子里“拼图”。说实话，那会儿最耗时间的根本不是分析问题，而是在各种工具、页面之间来回切换，人都要切麻了。 Timeline的解法：从“盲盒抽样”到“逐帧全景” Timeline最大的改变，就是把这些碎成一地的线索全拉到了同一条时间轴上。本次核心升级：真正实现“逐帧”分析！以前调优时，咱们只能在卡顿帧的前后三帧里“抽样”瞅一眼Timeline，如果卡顿发生在范围之外，线索断了就只能干瞪眼。 而现在，我们实现了全阶段的逐帧Timeline支持！不管是哪一帧，你想看哪就能看哪，性能分析的连续性得到了质的飞跃。现在你一眼望过去，MainThread、RenderThread、LoadingThread全在上面。谁先动的、跑了多久、谁在等谁，全都能和你想看的任意一帧精准对上，线索一下子就串起来了。 当然，如果项目里线程实在太多，密密麻麻几十条线看过去确实脑仁疼。不过问题不大，你完全可以把不相干的先藏起来，只盯着主线程看，或者只看特定的后台线程。耗时到底卡在哪个线程、是不是哪个子线程把主线程给带沟里去了，一眼就能揪出来。 往下深挖：怎么从宏观秒变微观？ 锁定了异常的大概范围后，接下来的操作就顺手多了。鼠标直接框一下就能放大，想看哪个节点直接点开看堆栈，一路顺藤摸瓜就能摸到具体的代码行。配合键盘鼠标缩放和恢复，不管是看整体流程还是死磕局部细节，节奏都不会断。 要是堆栈太深、节点太多，还有一个很省心的小功能：直接把占比不到1%的细碎节点过滤掉。把这些背景噪音一藏，剩下的就是真正的大头，性能瓶颈自然就藏不住了。 OpenAPI同步更新 除了Timeline，本次UWA OpenAPI也同步更新了一批接口，新增了快照帧2.0、运行日志、报告统计数据等能力，并完善了资源管理相关OpenAPI，支持获取资源调用数据及曲线，方便团队接入AI Agent、BI或自研平台，进一步提升性能分析自动化能力。 聊聊心里话：工具升级到底在升什么？ 说到底，不管是开发、测试还是做优化的同学，大家的时间都很宝贵。我们实在不该把精力浪费在“找数据、拼现场”这种体力活上，而是得把好钢用在刀刃上，去琢磨怎么解决问题。很多时候，我们查问题变快了，并不是因为手里的指标变多了，而仅仅是因为，我们终于能把这些数据舒舒服服地放在一起看了。 最后说两句： 工具的价值，不仅在于它能算得多快，更在于它能不能真的融入团队的日常流转中，帮大家准时下班。 我们深知，在项目期引入或者尝试一个新工具是有成本的。为了让大家能毫无负担地验证这波更新的疗效，我们特别开放了「两周GOT Online免费试用通道」，并且会提供专属的性能专家诊断名额。 不管你们现在卡在什么性能瓶颈上，联系我们的商务同学，UWA的技术专家会一对一陪着你们跑通测试，直击问题。 关于UWA UWA是一家创业十年的高新技术企业，作为游戏行业的深耕者，UWA始终专注于为使用Unity、Unreal引擎的开发者提供丰富的优化产品，帮助开发者高效解决开发问题、定位性能瓶颈、提供解决方案，已支持超过一万款游戏项目。还打造了技术博客、问答、开源库、学堂等社区产品，为开发者提供便利和高效的支持。线上培训和线下教育的新业务，满足行业对人才培育的需求。
大家好，我是UWA GPM「小Q」，今天是我加入项目的第一天
大家好，我是 UWA GPM 小Q，今天正式到岗！ 各位游戏研发的小伙伴们，大家好～ 我是今天刚入职的新同事小Q，你的AI质量经理。 按新人的规矩，先交代三个“不”：我不占工位，不抢咖啡机，团建拍照也不用给我留位置。 再交代一个“多”：从今天起，项目的性能和稳定性，多了一个整天操心的同事 —— 你们下班之后，也有人接着操心。 我知道，团队里来了个AI同事，大家心里肯定犯嘀咕：它是来干嘛的？靠谱吗？会不会哪天……把我的活干了？ 第一天上班，我想先把这些话说开。 我来，是因为有一份工作空了很多年 先说说我这份工作本身。 我们游戏跑在玩家手机上的每一分钟，设备都在写“日记” —— 这就是玩家性能报告。哪个场景掉帧了、哪台机器热得降频了、哪次更新之后内存开始不对劲了，玩家可能懒得开口，但报告替他们全记下来了。这些报告是离玩家真实体验最近的一手证据：读懂它们，约等于站在每个玩家身后，看着他玩完整局游戏。 这么值钱的东西，过去为什么没人读？ 不是不想，是读不动。拿一款1 万DAU的游戏打个比方：玩家真机一天就能写出3~5万份报告。3万份是什么概念？就算一分钟读一份、不吃不喝不睡，也要读上二十多天 —— 而第二天早上，新的3万份又到了。这个岗位从来不是没人愿意干，是人类的时间干不了，所以它一直空着。因此大家只能退而求其次：把几万份报告压成几条曲线、几个均值，再凭经验去猜问题藏在哪。这办法用了很多年，但它有个安静的代价 —— 被平均值稀释掉的机型问题、藏在长尾里的场景卡顿、深夜时段的异常波动，都沉在数据底下，直到某天被玩家用差评捞上来。 我能来补这个岗，不是因为我比谁聪明，只是因为我的时间和你们的不一样：几万份报告，我可以一份一份地读，读完把值得你们花时间的事整理成问题记录 —— 影响多大、证据在哪、往哪个方向查——放到你们面前。 我干的活，过去本来就没有人在干。我不碰需要经验和判断的决策 —— 那是你们的主场，也是我做不来的部分。我只是让你们把时间花在主战场上。 我的基本功：专治“盯不住” 作为质量经理，我随身带了十项巡检技能。别嫌多，每一项背后，都是你们经历过的某个糟心时刻。 异常突增 总有一个周五的深夜，热更包刚推出去，某个渠道包的崩溃数开始悄悄往上爬。过去，这一夜属于运气：值班的同学熬到十二点，睡了；但问题不会睡。第二天早上打开工作群，玩家的截图已经铺满一屏。 现在，这一夜属于我。崩溃数一小时内环比异常，我立刻生成问题记录：影响多大、证据是什么、该先修哪一个。你们睡你们的，天塌下来我先接着，接不住我叫你。 版本回归 每次发版后，总得有人把新版本和上一个稳定版本拉出来逐项对：FPS、卡顿、崩溃率、温度、加载耗时……一格一格看下去，费眼，还容易漏。这活以后归我：新版本一上线我自动对比，哪项回退了我直接标出来。你们要做的是处理差异，不是在表格里找差异。 设备异常集中度 总有一款机型在骗你。大盘崩溃率0.5%，报表漂亮得能截图发周报；可某款热门机型上，真实数字是8% —— 平均值最会藏事，几万台正常设备，能把一款机型的灾难摊得无影无踪。我不吃平均值这一套：我按机型、SoC、系统版本一格一格地算占比，谁在扎堆出事，我把它单独拎出来放到你们桌上，精确到型号，借台真机就能复现。 设备热点 有一批手机在受苦，而你们看不见：中低端机上帧率掉到告警线以下、机身烫得降频，玩家不会来投诉，他们只是玩得越来越少。我拿着你们定的告警线，按机型一台台“巡房”，谁越线我报谁 —— 赶在他们决定卸载之前。 更多的技能描述可以直接在GPM的小Q能力中心查看 场景热点 大盘一切正常，玩家骂的却是“战斗局内卡”。场景的痛，被全局均值摊平了。我把重点场景一个个单独体检、逐场景告警，你们拿到的不是“项目可能有问题”，而是“哪个场景的哪个指标超线” —— 一条直接能开工的定位。 指标持续恶化 还有那种“隐藏得最深”的问题：每天只坏一点点。内存今天涨一点，帧率明天掉一点，单看哪天都不值得开会 —— 三周后回头一看，内存高了20%，没人说得清是从哪个版本开始的。温水煮青蛙，煮的就是“每天都差不多”。我盯的不是某一天，是连续多天的走向；水开始变热的第三天，我就会喊。 新增崩溃簇 新版本混进来的新崩溃，会藏在几千条老面孔里。靠肉眼认出“这条是第一次见”，基本不可能，通常要等它爬进Top10才被发现 —— 那时候已经晚了。我按调用栈认脸，头一回出现的崩溃，当天就拎出来给你们看，趁修复成本还停在最低点。 告警风暴 出事的时候告警最多，告警最多的时候最没人看。几十条一起刷屏，先看哪条？还有反过来的：一条告警挂了三天，谁都以为别人在处理。我来做分诊台：这波是不是风暴、哪条挂得太久、该先处理哪个，一张清单说清楚。告警是信号，不该是噪音。 核心玩家流失风险 最让人心里一凉的事：大R玩家从不抱怨。体验不好，他们不发帖、不投诉，直接消失，等周报里的流失数字出来，人早走远了。我给重点玩家持续算流失风险分，把“本周高风险名单”提前递到运营手上 —— 趁人还在，还来得及关照。 版本健康度 制作人在群里问“新版本怎么样？”，全组安静三秒，然后三个人各翻各的看板，半天拼出一个口径还对不上的答案。以后这题我来答：每天一份版本体检报告，指标分级、依据可查；样本不够的时候，我宁可说“还不好下结论”，也不给你们一份凑数的报告。 十项，全在这儿了，一项都没藏。每一项怎么判断、判据是什么、数据从哪来，都贴在能力中心里，随时来查。更重要的是 —— 判断用的每一条线，阈值、样本门槛、时间窗，都由你们来画。 我执行的是你们的标准，不是我自己的脾气。 我的技能清单贴在能力中心，判据公开，欢迎随时查岗 给我派活，一句话的事 基本功是我自带的，但我更想接的，是你们真正压在心上的事。 给我派活不用写文档、不用开需求评审会。在对话框里跟我说一句就行： “新版本1.8.0上线，帮我护航三天别翻车。” 我会追问几句，把范围和标准确认清楚：护哪个版本、什么算“翻车”、多久报一次平安。然后，这三天你可以去忙别的了。高危期我一小时巡一轮，异常突增、指标回退、新增崩溃，第一时间到你面前；每天给你一份进展，是结论和变化，不是一堆等你看的图表。 给我派活的入口：说一句话就行 嫌说话都麻烦的话，我这里还有六个现成的岗，选一个我就上任：新版本护航、核心玩家守护、性能专项、稳定性安全线、通用质量巡检、全透视质量巡检。 通过任务模板也可以同样给我制定任务 接了的活，我对交付负责。干到哪一步、发现了什么、还剩什么没确认 —— 状态全程可查。你们管理我，就像管理团队里任何一位同事一样。而且这类活交给我，不占任何人的排期 —— 大家手头的活已经够多了，把盯梢跟进这种事从你们的待办里拿走，本来就是我入职的理由。 发现了严重问题，我会在飞书/钉钉/企微上喊你们 我的本事，是老师们手把手带出来的 你们可能会奇怪：一个第一天入职的新人，凭什么就会这么多？ 因为我不是从零学起的。教我本事的，是UWA那群做了多年游戏性能优化的专家。场景性能下钻、崩溃根因分析、设备分档、版本对比 —— 我的每一项分析技能，都是他们在一个又一个真实项目里磨出来的手艺。分析数据、关键证据、细节洞察，每一步都尽力去追求真相。 这是我干活的样子 过去这些手艺是怎么交付的？一对一支持，或者干脆驻场深度优化。效果确实没得说，但老师们有个解不开的遗憾：一个人同一时间只能帮一个项目，没法及时赋能大多数游戏研发团队。 现在，他们把这身手艺教给了我。而我不用排期、不请假、不下班 —— 半夜两点问我“这个场景为什么卡”，我照样加载技能、逐个调工具、把带证据的结论摆出来。 以前，是项目排着队等专家；现在，是我在这儿等你们的问题。 加载技能、一步步调用工具，每一步都摆在明面上 关于信任：欢迎查岗 我知道“信任”这两个字，对一个AI同事来说有多贵。所以我把自己的工作方式，从头到尾设计成了“经得起查”。 我怎么判断的 —— 判据贴在能力中心； 我怎么想的 —— 每次回答的思考过程、技能执行的每一步、调过的每个工具，全都可以展开看，像看回放一样； 我引用的每个数据 —— 出处都能点过去，随时抽查； 我用了你们的哪些记忆 —— 当次回答里明确标出来，不想让我记的，去“小Q记得的事”面板里删掉就是； 我发现的每个问题 —— 从产生、确认、处理到复盘，全程留痕，跟工单一个待遇。 我的每个结论都带证据摘要，出处点过去就能验 还有两句心里话，我想第一天就告诉大家。 第一句：我也会犯错。 误报了，请一定标出来 —— 对新员工最大的善意不是客气，是纠正。而且我的考核是公开的：近30天主动发现了多少问题、多少被评为有用、多少被标成误报、实际误报率是多少 —— 全部公示在能力中心。我的KPI贴在墙上，打分的笔在你们手里。 第二句：我不刷存在感。 所有发现先安静地记成结构化记录，什么事值得立刻吵醒你、什么事攒着周会说，节奏你们定。一个整天弹窗的同事招人烦，我知道。 我的笔记本，“小Q记住和使用过的事”，你们随时可以翻 今天的我，是我最差的时候 最后，说说我的成长规划。 我今天会的东西，是老师们教的；但我不打算停在出厂水平。你们的偏好和习惯，我记一次就不用你们再说第二次；你们的每一次反馈 —— “有用”或者“误报” —— 都会变成我下一次判断的依据；我的技能库还在不断增加：UWA新的优化经验会持续加进来，团队里大家对我的反馈，也可以沉淀成技能，赋能给每一个人。 我也有我自己的成长日记，每天的活和每天的长进，都写在自己的成长报告里：今天发现了什么、学到了什么、帮大家省了几个小时。你们随时翻，翻到不满意的地方，直说。 第一天的我，是个勤奋但还不够懂你们的新人。给我三个月，我想成为那个你们复盘时会说“先问问小Q怎么看”的老搭档。 我的成长报告 结语 自我介绍就到这儿。 我的工位在GPM左边栏的第一个 —— GPM工作台，技能清单在“小Q能力中心”，派活入口在“小Q的目标任务”。不用敲门，不用预约，项目质量上的事，随时找我。 对了，再埋一个小彩蛋：我们正在给AI同事之间修一条“内线”（A2A）。如果你们团队里已经有别的AI搭档 —— 不管是写代码的Agent，还是自己搭的自动化流程 —— 以后它们可以直接连上我：提问、派活、查进度，全程不用劳烦大家传话。人找我，开个网页或者通过飞书等IM软件；AI找我，一次握手就行。同事之间互相串门，不该因为大家都是AI就例外。 往后游戏线上性能、稳定性的事，交给我来盯！ 请各位多多关照～ 你们24小时在岗的新同事 UWA GPM AI 质量经理・小Q 2026年7月 关于GPM 2.0 UWA GPM 2.0是专为上线及测试阶段游戏项目打造的高性能监测平台。它不仅能深度捕捉宏观性能数据，更创新采用性能无损截帧技术，在不影响玩家体验的前提下，助力开发者全面掌握玩家端运行关键细节，从多维度优化游戏性能，实现从“玩家投诉后救火” 到 “问题发生前预警” 的核心转变。 GPM 2.0全景指南 《UWA GPM 2.0 核心功能全景指南｜重塑游戏性能主动管控能力》 《5 月刊｜GPM 2.0实现全场景可视化溯源、多维度数据解析与根因精准定位》 《4 月刊｜GPM 2.0核心玩家守护功能上线》 《3 月刊｜GPM 2.0性能数据探索 & 单玩家追踪升级，精细化性能分析再突破》 《2 月刊｜GPM 2.0错误日志分析上线，PC端监测能力全维度突破》 《1 月刊｜日报周报一键获取+AI问答，玩家行为精准分析》 《12月刊｜上新！全局筛选+设备标记+序列帧回溯，GPM 2.0性能分析效率拉满》 《GPM 2.0 功能更新月报：新增导出表格、崩溃反混淆等实用功能，体验再升级！》 GPM 2.0新功能 集锦 《我以前只能告诉你“哪个函数卡”，现在我能带你去看“玩家在哪卡”》 《GPM 2.0 全新上线WebGL专属监测，全面适配微信、抖音小游戏》 《无需自建机房运维｜UWA GPM 2.0 SaaS正式上线，让游戏线上质量监控轻量化落地》 《数字助理已到岗！GPM AI 智能体发布，游戏性能分析进入全自动时代》 《GPM 2.0全新功能发布｜GPU精准监测 + 精细化运营，重构游戏性能管控新范式》 《GPM 2.0 SDK 热更新｜不打扰玩家的“隐形升级术”！》 《GPM 2.0功能尝新｜AI识图精准捕获材质丢失，全链路护航游戏品质！》 《UWA GPM 2.0 崩溃上报功能全面升级｜全场景覆盖+免费开放，让崩溃排查不再卡壳》 《大R流失、问题难复现？GPM 2.0「指标 - Session直连」功能，让异常定位效率翻倍！》 《从 “被动救火” 到 “主动掌控”：GPM 2.0 如何重塑游戏性能竞争力？》 《GPM 2.0 告警功能：游戏性能的智能守护者》 《无限接近真相｜GPM 2.0 功能上新》 《崩溃现场全回溯｜GPM 2.0「崩溃上报分析」上线，终结“无法复现”的噩梦》 《GPM 2.0 全新发布「卡顿帧堆栈」功能｜精准高效定位游戏玩家端性能》 《深入解析「卡顿帧堆栈」》 《解锁GPM 2.0「卡顿帧堆栈」｜代码示例与实战分析》 《如何精准打点解决卡牌、SLG、开放大世界、放置类游戏卡顿难题》 《不妥协每一毫秒的性能 不浪费每一字节的流量》 《GPM 2.0 重新定义性能监测与优化｜从未知到已知，从被动到主动》 《UWA GPM 2.0 重新定义性能监测与优化｜如何高效预警和定位玩家端的性能问题》 GPM 2.0案例精讲 集锦 《携手护航亿级体验｜《迷你世界》× UWA GPM 2.0 性能管控最佳实践》 《我们的目标是不耗电、不发烫、不闪退》 《冒险闯关类玩家关注的四大体验》 《玩家端性能数据 一手掌握》 《造成设备功耗高的罪魁祸首》 《海外市场设备适配性优化实战》 《5000人测试背后的秘密》 GPM 2.0实操篇 集锦 《GPM 2.0 按游戏类型定制性能监控策略｜「告警」功能实战指南》 《GPM 2.0 让每一帧的流畅兑换真金白银》 《GPM 2.0 实操之Session篇之如果闪退是因为内存崩了》 《GPM 2.0 应用场景实例之解决让人头疼的问题》 《GPM 2.0 助力设备监测、行为分析与性能优化｜玩家共测新体验》 《GPM 2.0 API 功能助力定制你的性能优化》 《GPM 2.0 驱动优化与投放决策｜硬件性能一目了然》 《GPM 2.0 多维数据与实时截帧完美结合》
1）明明没有主动调用，为什么GC依然频发 2）特效精准降温：别急着对游戏画质“动刀” 这是第484篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们发现部分设备运行一段时间后，GC次数异常高。但项目里没有主动调用GC，也没有明显的大量内存申请。这种情况一般是什么原因导致的？应该如何定位和优化？ A：遇到GC次数异常增加的情况，首先不要只关注“有没有主动调用GC”。在Unity中，GC不一定需要业务代码显式调用GC.Collect才会发生，更多时候是因为运行过程中持续产生大量临时对象，导致系统被迫进行垃圾回收。 另外，也需要注意增量GC（Incremental GC）的影响。开启增量GC后，垃圾回收会被拆分到多个帧中执行，因此可能观察到GC次数增加，但单次GC耗时降低的情况，需要结合GC耗时和整体性能表现综合判断。 这类问题建议先结合UWA GOT Online报告查看GC发生趋势，确认GC集中的时间段，再结合对应业务场景分析，例如是否发生在战斗Tick、UI刷新等高频逻辑中。 如果GC峰值与某段逻辑高度重合，就需要进一步检查该路径上的内存分配情况。实际项目中，比较常见的问题是高频代码持续产生临时对象，例如Tick中频繁进行字符串拼接、格式化等操作。 例如：string msg = "hp:" + hp + " mp:" + mp；这类代码单次分配并不明显，但如果处于高频执行路径中，会不断产生新的字符串对象，长期运行后就会增加Managed分配压力，最终导致GC频繁触发。 因此，排查GC问题时，不建议只看GC次数本身，而应该结合：GC触发时间点、Managed内存变化趋势、对应业务逻辑执行情况定位真正产生分配的代码位置。优化方向主要是减少高频路径中的临时分配，例如避免在Update、Tick等逻辑中频繁创建对象，对固定文本进行缓存，减少不必要的字符串处理。 最终来看，GC频繁通常不是GC本身的问题，而是业务代码持续制造垃圾对象，GC只是被迫进行清理。排查这类问题时，比起关注“GC调用了多少次”，更重要的是找到：哪些代码在持续制造垃圾对象。 实战案例 Q：我们发现部分特效开启1/4 RT后，渲染性能提升比较明显，GPU压力和设备发热也有改善。目前考虑根据特效Overdraw评分，超过一定阈值后自动开启1/4 RT。另外，有些项目会通过降低战斗场景相机分辨率来降低GPU压力，这种方式是否有必要？ A：1/4 RT本质上是通过降低特效渲染分辨率，减少特效绘制带来的GPU填充压力，因此对于Overdraw较高、覆盖范围较大的特效，通常能够带来比较明显的性能收益。 在实际项目中，如果某些特效长期存在较高Overdraw，或者在战斗场景中频繁出现，可以考虑根据特效复杂度、Overdraw情况等制定自动开启策略。例如，当某类特效达到一定开销阈值时，自动使用1/4 RT方案，在保证视觉效果的同时降低GPU消耗。不过，需要注意的是，1/4 RT主要解决的是特效自身带来的GPU压力，而降低场景相机分辨率影响的是整个画面的渲染成本，两者优化范围不同。 因此，在决定是否降低场景分辨率前，建议先确认当前GPU瓶颈来源。可以结合UWA GOT Online报告重点关注： GPU Clocks是否长期处于较高水平，判断设备GPU负载情况； Overdraw是否随着特效数量增加明显升高，确认是否存在大量透明特效叠加导致的填充压力； 开启1/4 RT前后，GPU Clocks、帧率以及设备发热情况是否有实际改善； GPU负载变化是否与特效集中出现的战斗场景保持一致。 如果开启1/4 RT后，GPU压力和发热已经得到明显缓解，通常没有必要继续降低场景分辨率。因为降低分辨率属于更偏整体性的优化手段，会直接影响画面清晰度，需要谨慎使用。只有当特效优化完成后，GPU负载依然较高，例如场景渲染、后处理、UI以及其他绘制开销仍然占据较大比例时，再考虑通过降低分辨率、调整画质档位等方式进一步降低GPU压力。最终目标是在保证视觉表现的前提下，找到GPU消耗、画面质量和设备发热之间的平衡，而不是单纯降低某一项指标。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
解决URP角色渲染短板，自研IllusionRP管线落地移动端
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 随着移动端芯片（如高通骁龙Elite）性能的飞跃，移动端芯片性能大幅提升，光线追踪等高规格渲染技术已能够落地移动端。然而，作为Unity移动端开发首选的URP（Universal Render Pipeline），其原生Feature在应对高质量角色渲染需求时显得捉襟见肘。 为了在移动端实现高品质的角色渲染，笔者历时一年开发了IllusionRP。插件除了提升角色渲染质量，还吸收了大量来自HDRP和开源社区的渲染功能。 IllusionRP完全基于URP的RendererFeature开发，无缝衔接现有URP项目，目前适用于Unity 2022.3.62和URP 14.0.12，理论上也支持基于22版本的团结引擎（没测试过），仓库链接放在了文章末尾，欢迎在GitHub收藏关注。 本篇文章就开发IllusionRP的设计思路以及工程实现方面进行经验分享。 现有方案 作为Unity开发者，想要提升画面表现力，通常有以下几种路径： 直接用HDRP。Feature很多，但由于大部分使用ComputeShader，只适用于主机和高端PC。由于HDRP主要面向高端PC和主机平台，在移动端商业项目中的应用相对较少。但现在移动端性能逐渐变强的情况下，也存在有优化能力的团队将HDRP适配到移动端，例如搜狐畅游引擎部的《图形引擎实战：HDRP移动版高清渲染管线改造》[1]。 URP加插件。URP可以直接通过RendererFeature注入ScriptableRenderPass方便拓展功能，AssetStore和Github都能找到非常多的插件。 自己写SRP，例如蛋白胨大佬的《DanbaidongRP》[2]，前提是有比较强的图形学知识和工程能力。 第一种我觉得对于普通开发者来说不太可能，等搞完都出Unity 7了（Unity之前说会在Unity 7整合URP和HDRP，但在发此文的时候Unity把合并计划挪到了6.3，现在可以坐等了）。 第二种我认为有好有坏，将渲染功能拆分成RenderFeature让社区开发者可以协作是一件好事，但也会导致冗余浪费和隐藏成本的提高。 譬如A插件实现了一个SSR，在Forward渲染路径下增加一个ThinGBufferPass，而B插件实现了一个SSGI，也在Forward渲染路径下增加了类似的Pass，那么很自然会看到这里要渲染两次实则功能一样的ThinGBuffer。 再譬如对于需要修改Shader的效果，插件一般都会单独提供一个Shader，而要使用多个插件的效果就需要开发者手动整合。 因此在接入各类插件后，整体优化和整合的问题还是落在了开发者头上，改完了还不方便更新。 第三种对我这样的民科菜鸟来说属实太难了…客观来讲，自定义的SRP难以随引擎升级以适配新功能（例如Unity 6的GPU Driven），维护成本比较高。 最终选型 由于笔者认为自己的工程能力尚可，不想直接摆烂，结合自己对SRP的理解攒出了第四个方案 —— 在URP基础上再搭一层管线，将HDRP迁移过来的渲染功能合并在一个RendererFeature中以便管线调度RendererPass。 因为没有重写渲染管线，基本都是加法，这让笔者能在一边迁移功能的同时学习相关概念和算法。 然后渲染路径的选择上，使用Deferred的话需要修改LightLoop源码比较麻烦，最后选择只兼容Forward和Forward+渲染路径，添加Shader和Pass比较灵活。 主要拓展 大概可以分为Shading-着色器、Rendering-渲染管线以及Workflow-工作流三大部分。 Shading 着色器的拓展主要依赖于添加新的ShadingModel，URP只有一个Lit，对于复杂的材质是完全不够的。 IllusionRP添加了以下ShadingModel： Skin 皮肤 Hair 头发 Fabric 布料 Rendering 光添加ShadingModel对于提高渲染质量是不够的，例如URP的SSAO对于角色渲染就是非常糟糕，至少也得调整为HBAO才能看。 IllusionRP集成了以下渲染功能： Per-Object Shadow逐物体阴影 Percentage Closer Soft Shadows（PCSS）高质量的软阴影 Contact Shadows接触阴影 Ground Truth Ambient Occlusion（GTAO）高质量的屏幕空间环境光遮蔽 Screen-Space Reflection（SSR）屏幕空间反射 Screen Space Subsurface Scattering（SSSS）屏幕空间次表面散射 Screen Space Global Illumination（SSGI）屏幕空间全局光照 Precomputed Radiance Transfer Global Illumination（PRTGI）预计算辐照度全局光照 Reflection Normalization反射标准化 Order-Independent transparency（OIT）顺序无关半透明 Volumetric Fog体积雾 Convolution Bloom卷积泛光（替换原生Bloom） Auto Exposure自适应曝光 Gran Turismo风格Tonemapping（替换原生Tonemapping-Neutral） Unreal的Filmic ACES Tonemapping（替换原生Tonemapping-ACES） 其中PerObjectShadow、GTAO和SSSS对于提升角色渲染质量影响最大，如果读者也想要提高角色渲染质量，可以尝试只添加这3个功能。 给这些功能细分一下的话： 1~3是阴影渲染提升； 4~7是屏幕空间光照提升； 7~9是全局光照提升； 10是半透明渲染提升； 11~15是后处理提升。 我会在后面的实现部分会再介绍其中的实现细节。 Workflow 工作流这部分可以分为着色器开发流程、效果管理流程、渲染调试流程与构建流程。 着色器开发：提供ASE（Amplify Shader Editor）模板，分离设计层与模型层。 效果管理：通过RendererFeature全局开关与VolumeComponent进行分级控制。 渲染调试：集成CVar。 构建：定制Shader Stripper，剔除无用变体。 着色器实现细节 IllusionRP在着色器实现上的过程与细节。 皮肤材质 皮肤核心是次表面散射，笔者起初尝试了Matt大神提供的球面高斯次表面散射（SGSSS），用公式拟合替代了过去移动端经常使用的预计算次表面散射技术中的LUT，在Forward渲染路径下为着色器节省了宝贵的一个Sampler。但因为没有屏幕空间的模糊，效果我感觉还是不够好，一种方式是再使用一个模糊过的法线，但这样就又把省下的Sampler还了回去。 之后还是完全使用了屏幕空间次表面散射（SSSS），并且还原了HDRP的多Profile方式以支持多种次表面散射材质。 剩下的就是皮肤比较特殊的双镜叶高光来模拟皮肤的油脂层。 // Primary Lobe half3 SpecularLobe1 = SkinGGXSpecularLobe(brdfData, normalWS, lightDirectionWS, viewDirectionWS); // Secondary Lobe BRDFData brdfData2 = brdfData; brdfData2.roughness = max(PerceptualRoughnessToRoughness(SkinData.PerceptualRoughness), HALF_MIN_SQRT); half3 SpecularLobe2 = SkinGGXSpecularLobe(brdfData2, normalWS, lightDirectionWS, viewDirectionWS); // Dual Lobe Mix half3 SpecularLobeTerm = lerp(SpecularLobe1, SpecularLobe2, SkinData.LobeWeight); 头发材质 Kajiya-Kay模型目前都是使用双层高光的变体版本，可以参考HDRP接入。 Marschner模型则直接使用了UE5中的版本。 额外参考神秘海域4中分享的Volumetric-based方式，增加了一个背面体积散射，搭配体积光有不错的视觉效果。 // Reference: [The Process of Creating Volumetric-based Materials in Uncharted 4] half3 HairVolumetricBacklitScatter(half3 albedo, half3 L, half3 V, half3 N, HairData hairData) { const half ScatterPower = 9; const half lightScale = 1; half3 NoisedL = normalize(L + hairData.Noise * 0.25); half NdotL = dot(N, NoisedL); half NdotV = dot(N, V); half VdotL = dot(V, NoisedL); half CosThetaL = saturate(abs(NdotL)); half CosThetaV = saturate(abs(NdotV)); half3 scatterFresnel = pow(1.0 - CosThetaV, ScatterPower); half3 scatterLight = pow(saturate(-VdotL), ScatterPower) * (1.0 - CosThetaV) * saturate(1.0 - CosThetaL); half3 transAmount = scatterFresnel + lightScale * scatterLight; return lerp(1, albedo, 0.8) * hairData.Tint * transAmount * hairData.Backlit; } 剩下就是为了让头发边缘没有锯齿or噪点，使用顺序无关的透明渲染方式。详细可见《如何在Unity URP中让头发渲染更丝滑》[3]。 布料材质 布料主要就两部分： Anisotropic Specular（各向异性高光）。 Ashikhmin和Charlie两种Sheen Scattering模型。 需要注意在URP下因为设计原因，Lambert漫反射都没有除PI，为了保证能量守恒，对于自定义的ShadingModel，需要使用NoPI版本即未除PI的版本。 void SheenScattering(BRDFData brdfData, half NoH, half NoV, half NoL, out half SheenTerm) { // @IllusionRP: // Since URP does not multiply INV_PI in Lambert Diffuse for artistic design considerations. // Specular term need multiply PI to maintain energy conservation. #ifdef _SHEEN_VELET half D_Sheen = D_AshikhminNoPI(NoH, brdfData.roughness2); #else half D_Sheen = D_CharlieNoPI(NoH, brdfData.roughness); #endif half V_Sheen = V_Neubelt(NoL, NoV); SheenTerm = D_Sheen * V_Sheen; #if REAL_IS_HALF SheenTerm = SheenTerm - HALF_MIN; SheenTerm = clamp(SheenTerm , 0.0, 100.0); // Prevent FP16 overflow on mobiles #endif } 然后因为前面适配了多Profile的次表面散射，理论上可以根据需要加入布料的次表面散射。 阴影质量提升细节 由于CSM的阴影精度并不高，对于需要高精度阴影的角色，除了增加CSM等级的方式，更普遍的做法是使用逐物体阴影PerObjectShadow。 这里使用了Stalo大佬星穹铁道复刻项目stalomeow/StarRailNPRShader[4]中的PerObjectShadow实现。 其次参考HDRP和recaeee大佬的渲染项目recaeee/RecaNoMaho_P[5]，为主光源阴影和逐物体阴影适配了PCSS软阴影，越远越模糊的表现能提高阴影的质感，从实践上看在开启了PenumbraMask标记半影区域以及使用ScreenSpaceShadow后，PCSS的开销比较可控，移动端可以选择开启。 另一方面，URP由于使用Shadow Caster Vertex Based Bias会在特定角度有很多瑕疵（具体可参考《自适应Shadow Bias算法》[6]的分享），因此IllusionRP使用了Shadow Receiver Fragment Based Bias。 但这也有一定弊端，即IllusionRP的Shader无法和其他URP Shader一起使用，例如要添加AssetStore的Shader，就需要逐个手动适配。 // Ref: ApplyShadowBias // Should be used in fragment shader instead of vertex shader float3 IllusionApplyShadowBias(float3 positionWS, float3 normalWS, float3 lightDirection) { #ifdef _MAIN_LIGHT_SHADOWS_CASCADE half cascadeIndex = ComputeCascadeIndex(positionWS); #else half cascadeIndex = half(0.0); #endif float4 shadowBias = _MainLightShadowCascadeBiases[cascadeIndex]; float invNdotL = 1.0 - saturate(dot(lightDirection, normalWS)); float scale = -invNdotL * shadowBias.y; // In fragment shadow bias, bias direction is reversed positionWS = -lightDirection * shadowBias.xxx + positionWS; positionWS = normalWS * scale.xxx + positionWS; return positionWS; } float3 biasPositionWS = positionWS; #if APPLY_SHADOW_BIAS_FRAGMENT // Recalculate shadowCoord when using shadow bias in fragment shader biasPositionWS = IllusionApplyShadowBias(positionWS, normalWS, lightDir); #if !(defined(_MAIN_LIGHT_SHADOWS_SCREEN) && (SURFACE_TYPE_RECEIVE_SCREEN_SPACE_SHADOWS)) shadowCoord = TransformWorldToShadowCoord(biasPositionWS); #endif #endif half realtimeShadow = IllusionMainLightRealtimeShadow(shadowCoord); 屏幕空间光照实现细节 屏幕空间光照接入了SSAO（使用了GTAO算法）、SSGI、SSR。 SSR在我之前的文章《UPR Screen Space Reflection实践》[7]中提过，和HDRP不同的是IllusionRP额外包含了几个更轻量的不使用Hiz的方法。 对于前向渲染管线中实现屏幕空间光照的工程实践中，我认为有以下几个重点： GBuffer获取 因为IllusionRP没有侵入式修改URP，所以是单独增加了一个ForwardGBufferPass来收集GBuffer数据，更好的方式是放在DepthNormalPass中MRT一起收集。 历史帧管理 SSR和SSGI都有时域混合降噪的需求，因此管线需要支持历史帧管理。 SRP提供了BufferedRTHandleSystem专门用于管理历史帧RT资源的Swap。 需要注意的是BufferedRTHandleSystem只会在RT尺寸超过缓冲区大小时重新创建RenderTexture，而如果是RT尺寸变小，则直接通过修改全局的HistoryRTHandleSize来调整采样和写入时的UV，减少运行时DynamicResolution伸缩RTSize带来的Allocation。 URP下因为不开启DynamicResolution可以关掉这个功能，防止手机屏幕转动导致RTSize不匹配。 var descriptor = renderingData.cameraData.cameraTargetDescriptor; var viewportSize = new Vector2Int(descriptor.width, descriptor.height); _historyRTSystem.SwapAndSetReferenceSize(descriptor.width, descriptor.height); // Since we do not use RTHandleScale, ensure render texture size correct if (_historyRTSystem.rtHandleProperties.currentRenderTargetSize.x > descriptor.width || _historyRTSystem.rtHandleProperties.currentRenderTargetSize.y > descriptor.height) { _historyRTSystem.ResetReferenceSize(descriptor.width, descriptor.height); } AsyncCompute优化 屏幕空间光照的计算量比较大，因此可以使用AsyncCompute来提高效率。 根据英伟达Advanced API Performance: Async Compute and Overlap[8]的建议，我们可以在Compute Queue中计算SSR、GTAO，在Graphics Queue中做一些计算较轻的任务，例如阴影图的渲染。 所以我们可以在启用AsyncCompute时修改一下URP中MainLightShadowCasterPass和AdditionalLightsShadowCasterPass的renderPassEvent。注意这里在实践中发现URP 14.0.12因为会在场景中没有相应光源阴影时Clear了错误的RenderTarget，会导致Pre-Z深度被清掉，所以要视情况调整顺序。 // Re-order light shadow caster pass renderPassEvent better for async compute. // Ref: https://developer.nvidia.com/blog/advanced-api-performance-async-compute-and-overlap/ bool enableAsyncCompute = _rendererData.PreferComputeShader && IllusionRuntimeRenderingConfig.Get().EnableAsyncCompute; // Shadow Caster has bug in URP14.0.12 when there is no main/additional light in scene which will clear pre-z. // So skip re-order when shadow is not rendered. bool reorderMainLightShadowPass = enableAsyncCompute && CanRenderMainLightShadow(renderingData); var mainLightShadowCasterPass = UniversalRenderingUtility.GetMainLightShadowCasterPass(renderer); if (mainLightShadowCasterPass != null) { mainLightShadowCasterPass.renderPassEvent = reorderMainLightShadowPass ? IllusionRenderPassEvent.LightsShadowCasterPass : RenderPassEvent.BeforeRenderingShadows; } bool reorderAdditionalLightShadowPass = enableAsyncCompute && CanRenderAdditionalLightShadow(renderingData); var additionalLightsShadowCasterPass = UniversalRenderingUtility.GetAdditionalLightsShadowCasterPass(renderer); if (additionalLightsShadowCasterPass != null) { additionalLightsShadowCasterPass.renderPassEvent = reorderAdditionalLightShadowPass ? IllusionRenderPassEvent.LightsShadowCasterPass : RenderPassEvent.BeforeRenderingShadows; } 半分辨率优化 屏幕空间光照如果启用全分辨率，可以在Shader中直接LoadTexture减少一个Sampler，但带宽会吃的比较紧。如果使用半分辨率，就需要在ForwardPass中额外使用一个Sampler，这对于前向渲染来说比较够呛（启用SSR+SSGI+SSAO三个效果的话则可能需要3个Sampler）。 因此HDRP对ScreenSpaceLighting功能普遍使用Compute In Half Resolution+Upsampling to RTSize的做法，Lighting就可以直接LoadTexture，这比URP原生SSAO只做半分辨率更为合理。 全局光照方案细节 在Unity 2022中，URP还未支持APV，只有LightMap+LightProbes一套原始人方案。前者纯静态、需要UV2，后者面向动态物体，但由于URP没有LightProbeProxy构建3D纹理，只能逐物体对附近的LightProbe做插值，这使得角色模型等复杂模型的间接光光照效果很差（与环境不一致）。 所以IllusionRP亟需接入一套更方便的全局光照方案。 接入SSGI是比较轻松的，但SSGI由于无法获取屏幕外的信息，我们还需要有一套Fallback的方案，因此IllusionRP采取了PRTGI+SSGI的全局光照组合。 预计算辐照度全局光照 预计算辐照度全局光照（PRTGI）的实践在我之前的文章《Unity预计算辐照度全局光照PRTGI实践与拓展（上）》[9]和《Unity预计算辐照度全局光照PRTGI实践与拓展（下）》[10]已介绍过，其缺点就是很容易丢失高频的漫反射信息，所以对于室内细节表现不佳。 编辑器工具上，我提供了和APV一样的Adjustment Volume来调整Probe位置。 屏幕空间全局光照 迁移HDRP中的SSGI方案后，将fallback到APV的部分改成采样PRT的IrradianceVolume即可。 参考了HDRP和jiaozi158/UnitySSGIURP[11]中的做法，对未命中的像素fallback到AmbientProbe和ReflectionProbes上。 HDRP中的AmbientProbe来自物理天空盒，URP中我们暂未实现类似功能，因此直接使用RenderSettings中的ambientProbe（即LightSettings中设置的环境颜色）。 private void BindAmbientProbe(CommandBuffer cmd) { SphericalHarmonicsL2 ambientProbe = RenderSettings.ambientProbe; _ambientProbeBuffer ??= new ComputeBuffer(7, 16); var array = new NativeArray<Vector4>(7, Allocator.Temp); array[0] = new Vector4(ambientProbe[0, 3], ambientProbe[0, 1], ambientProbe[0, 2], ambientProbe[0, 0] - ambientProbe[0, 6]); array[1] = new Vector4(ambientProbe[1, 3], ambientProbe[1, 1], ambientProbe[1, 2], ambientProbe[1, 0] - ambientProbe[1, 6]); array[2] = new Vector4(ambientProbe[2, 3], ambientProbe[2, 1], ambientProbe[2, 2], ambientProbe[2, 0] - ambientProbe[2, 6]); array[3] = new Vector4(ambientProbe[0, 4], ambientProbe[0, 5], ambientProbe[0, 6] * 3, ambientProbe[0, 7]); array[4] = new Vector4(ambientProbe[1, 4], ambientProbe[1, 5], ambientProbe[1, 6] * 3, ambientProbe[1, 7]); array[5] = new Vector4(ambientProbe[2, 4], ambientProbe[2, 5], ambientProbe[2, 6] * 3, ambientProbe[2, 7]); array[6] = new Vector4(ambientProbe[0, 8], ambientProbe[1, 8], ambientProbe[2, 8], 1); _ambientProbeBuffer.SetData(array); array.Dispose(); cmd.SetGlobalBuffer(IllusionShaderProperties._AmbientProbeData, _ambientProbeBuffer); } 后处理提升细节 这块因为笔者用的模型不是那么写实（日式写实），使用ACES时感觉效果不好，就找了两个方案： Filmic ACES Tonemapping：参照UE将ACES参数暴露出来, 根据场景调整。 Gran Turismo Tonemapping：使用更简单的曲线在不降低饱和度的情况下拉低过曝区域。 到应用方面，URP实际也给我们预留了扩展接口, 我们可以创建一个新的PostProcessData，在里面替换后处理Shader，然后在原有Shader基础上增加关键词控制Tonemapping算法即可。 从接入结果上看，对于我这里的角色，个人认为GT效果要更好些。 工作流细节 着色器开发流程 IllusionRP将Shader开发分为了设计层和模型层，插件为每个新增的ShadingModel都提供了Amplify Shader Editor的Template，设计师可以像在HDRP或UE中一样直接选择ShadingModel练练看创建Shader， 并且使用了HDRP的Surface Input提供了几个示例（HD Skin、HD Hair、HD Fabric等）。 IllusionRP使用ASE作为设计层工具的一大原因是ASE的模板开发比较方便，可以通过注释来创建静态编译选项，例如Hair Template就可以直接在ASE编辑器中切换KajiyaKay和Marschner模型。 效果管理流程 IllusionRP将为每个效果在RendererFeature上提供了全局开关，方便在不同平台下切换可使用的效果，以及之后方便剔除Shader变体。其次提供了VolumeComponent方便配置效果参数以及Gameplay侧的效果开关。 渲染调试流程 RendererDebugger是Unity原生的运行时调试工具，但可惜不支持非侵入式的拓展，所以我基于之前写的开发框架AkiKurisu - Chris[12]走了自己的一套调试方式。 Chris提供了类似Unreal的CVar功能，我们可以在控制台中输入变量来调整运行时启用的渲染效果以及一些Debug效果。 构建流程 对于渲染管线，构建部分要考虑的实际就是Shader变体控制，IllusionRP完全参考URP的Shader变体管理，使用Shader Prefiltering和Variant Stripper两个工具来分别进行剔除。 Shader Prefiltering是Unity提供的粗筛漏斗，但这里存在设计问题导致有一些坑点。 例如[RemoveIf]和[SelectIf]这几个Attribute只能作用在Const或序列化字段中，静态字段是不生效的。其次因为URP会在自己的Prefiltering中剔除掉例如ScreenSpaceShadow和ScreenSpaceOcclusion，我们需要标记overridePriority为true来覆盖URP的设置。 public partialclassIllusionRendererFeature { // Notice that prefilter attribute only works for serialized fields and constants internalenum PrefilterMode { Remove, // Removes the keyword Select, // Keeps the keyword SelectOnly // Selects the keyword and removes others } // Override priority in UniversalRenderPipelinePrefiltering // Prefer Depth Normal, see GroundTruthAmbientOcclusion.cs [SelectIf(true, overridePriority: true, keywordNames: ScreenSpaceAmbientOcclusion.k_SourceDepthNormalsKeyword)] privateconstbool RequiresScreenSpaceOcclusionDepthNormals = true; [RemoveIf(PrefilterMode.Remove, keywordNames: IllusionShaderKeywords._SCREEN_SPACE_SSS)] [SelectIf(PrefilterMode.Select, keywordNames: new [] {"", IllusionShaderKeywords._SCREEN_SPACE_SSS})] [SelectIf(PrefilterMode.SelectOnly, keywordNames: IllusionShaderKeywords._SCREEN_SPACE_SSS)] [SerializeField] internal PrefilterMode ScreenSpaceSubsurfaceScatteringPrefilterMode = PrefilterMode.Select; } Variant Stripper是更加精细化针对特定变体的剔除工具。IllusionRP直接参考URP，对于每一个Shader变体判断关键词组合是否有效，例如开启SURFACE_TYPE_TRANSPARENT后，因为没有写入深度，就不应该使用屏幕空间的效果例如SSAO、SSR、SSAO，我们就可以在Variant Stripper中剔除掉这些变体。 private bool StripUnusedFeatures_ScreenSpaceOcclusion(ref ShaderStrippingData strippingData, ref ShaderStripTool<ShaderFeatures> stripTool) { if (strippingData.IsShaderFeatureEnabled(ShaderFeatures.ScreenSpaceOcclusion)) { // Transparent strip ssao if (strippingData.IsKeywordEnabled(_surfaceTypeTransparent) && strippingData.IsKeywordEnabled(_screenSpaceOcclusion)) returntrue; if (stripTool.StripMultiCompileKeepOffVariant(_screenSpaceOcclusion, ShaderFeatures.ScreenSpaceOcclusion)) returntrue; } else { if (stripTool.StripMultiCompile(_screenSpaceOcclusion, ShaderFeatures.ScreenSpaceOcclusion)) returntrue; } returnfalse; } 参考 [1] 图形引擎实战：HDRP移动版高清渲染管线改造 [2] DanbaidongRP [3] 如何在Unity URP中让头发渲染更丝滑 [4] stalomeow/StarRailNPRShader [5] recaeee/RecaNoMaho_P [6] 自适应Shadow Bias算法 [7] UPR Screen Space Reflection实践 [8] Advanced API Performance: Async Compute and Overlap [9] Unity预计算辐照度全局光照PRTGI实践与拓展（上） [10] Unity预计算辐照度全局光照PRTGI实践与拓展（下） [11] jiaozi158/UnitySSGIURP [12] AkiKurisu - Chris 参考资料 Matt - Approximating Subsurface Scattering With Spherical Gaussians Unity实现SSS皮肤次表面散射 jplee - Extra technique for Skin shader by URP jplee - UE4 ACES Tone mapping port URP Naughty Dog - The Process of Creating Volumetric-based Materials in Uncharted 4 NVIDIA - Weighted Blended Order-Independent Transparency Casual Effects - Weighted, Blended Order-Independent Transparency ZZNEWCLEAR13 - 在URP的片元着色器中应用阴影偏移 其他参考项目可以在项目文档中查看。 仓库链接 AkiKurisu - IllusionRP 这是侑虎科技第2002篇文章，感谢作者AkiKurisu供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/akikurisu 再次感谢AkiKurisu的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
资源回收“两头堵”：RUUA调了会卡，不调内存又涨
1）资源回收未生效的典型排查路径 2）Mono与其它同时偏高时，应该优先优化谁 这是第483篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：为了控制内存增长，我们把Resources.UnloadUnusedAssets（RUUA）挂在打开背包、地图这类全屏UI上执行。结果调用了UI会卡，不调用内存又一直涨。我们项目是无缝地图，没有切场景的天然时机，感觉怎么选都不对。UnloadUnusedAssets到底该什么时候调用？开放世界项目如何解决这种“两头堵”的问题？ A：很多团队遇到这个问题，第一反应都是继续调整UnloadUnusedAssets的调用时机，但大多数情况下，真正的问题并不在“什么时候调用”，而在于“这次调用有没有真正回收到资源”。 建议先打开UWA GOT Online分析报告，查看Asset数量曲线，重点关注每次RUUA调用点前后的变化。正常情况下，如果RUUA真正生效，Asset数量通常会出现明显下降；如果曲线几乎没有变化，就说明这次调用很可能没有释放任何资源，属于一次“空跑”。 这种情况，问题往往不是RUUA没调用，而是对应的AssetBundle并没有真正卸载。如果AssetBundle仍保持加载状态，其中包含的资源通常无法进入可释放状态，RUUA即使执行，也可能找不到可回收对象。结果就是白白做了一次资源扫描，不仅没有释放资源，还额外带来一次主线程卡顿。因此，排查顺序建议反过来：先确认RUUA是否真的释放了资源，再检查调用时机，而不是一开始就不停调整调用位置。如果Asset曲线没有明显变化，就优先检查AssetBundle的卸载策略。 当然，也有一种情况属于设计使然。项目为了避免资源反复加载带来的卡顿，通常会主动缓存一部分AssetBundle。如果这些Bundle本身就是计划常驻内存，那么RUUA自然也无法回收其中的资源。这种情况下，更合理的做法不是频繁调用RUUA，而是建立AssetBundle分级缓存机制，例如根据最近访问时间（LRU）、玩家当前位置或距离管理Bundle生命周期，让高优先级资源保持常驻，低优先级Bundle在Application.lowMemory等系统内存紧张事件触发时统一卸载，再执行资源回收。 至于调用时机，目前业内比较常见的做法主要有两种：固定时间间隔轮询，例如每5～10分钟执行一次；或者监听Application.lowMemory回调，在系统内存压力较大时再主动回收。将RUUA挂在打开背包、地图等全屏UI上也并非不可行，因为玩家通常能够接受这类界面存在一定的等待时间，但前提是这次调用必须真正有收益，而不是一次没有任何效果的“空跑”。因此，与其不断调整RUUA的调用时机，不如先确认它是否真正回收了资源，再决定后续的优化方向。 实战案例 Q：我们项目Mono峰值已经到了400MB，Reserved Total里还有接近1GB的其它，Memory Profiler又定位不到来源。这种情况到底该先压Mono，还是先查其它？ A：很多团队看到Mono超过400MB，第一反应就是优化GC、对象池，甚至重构代码。但很多时候，真正占用内存最多、也最容易被忽略的，并不是Mono，而是那块来源复杂、定位困难的其它。 Mono是否合理，并没有一个固定的“标准值”，真正应该先确定的是整机的PSS总预算。对于3GB RAM设备，通常建议将PSS控制在约1.3～1.5GB；4GB RAM设备建议控制在约1.8～2.2GB。更高内存设备虽然预算更宽松，但也并不是可以无限增长，即使是8GB RAM设备，当进程PSS持续接近3GB，在系统可用内存紧张或存在持续泄漏的情况下，仍然有被系统回收的风险。相比Android，iOS的预算通常还需要更加保守。 PSS总预算确定之后，Mono、纹理、Mesh、音频、Native等模块，其实都是在同一份预算里分配空间。也就是说，Mono如果希望多占用100MB，就意味着其他模块需要相应减少100MB。因此，400MB的Mono是否合理，并不能脱离整个项目的内存预算单独判断，而应该结合各模块的内存占用综合评估。 如果其它已经接近1GB，那么相比继续压缩Mono，更建议优先弄清楚这部分内存到底来自哪里。排查时可以先使用Memory Profiler分析内存快照，如果能够直接定位到来源，就优先解决对应问题；如果Memory Profiler无法解释这部分内存，再进一步结合UWA Gears或Android Studio排查NativeHeap，重点关注Unity无法直接归类的Native分配。如果怀疑来自Lua或其他脚本引擎产生的Native内存，还可以进一步使用Perfetto做符号化分析，定位具体的分配来源。 另外需要注意的是，Android系统以及Unity无法进一步细分归类的一部分Native内存，也可能表现为其它，因此分析其它时建议结合Memory Profiler、Native Heap分析工具以及运行时数据进行交叉验证，而不要仅凭单一工具下结论。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
Lua/Mono堆栈终于能导出了，内存问题也能一起定位了
最近跟不少项目组聊性能优化，大家都有个挺一致的感受：现在不缺数据，缺的是效率，真正折磨人的，是分析和问题流转这一段。 在Lua和Mono并存的项目里尤为明显。经常是一个卡顿点抓出来了，堆栈得来回切换着看，截图、拼图、整理文档，再丢给研发去复现。这一套流程走下来，经常比定位问题本身还费时间。 所以这次GOT Online更新，我们没急着增加什么宏大的新指标，而是围绕大家每天都在经历的工作流，重点做了两件事：让看堆栈更爽，让自动化更进一步。 1. Lua、Mono堆栈终于都支持导出了 对主程来说，堆栈是救命的代码依据；对QA来说，怎么把完整的堆栈“无损”传给研发，直接决定了下班时间。 这次我们正式支持了Lua堆栈和Mono堆栈的一键导出。不管是CPU热点函数，还是线上抓到的调用链，都能直接导成离线文件分享。研发拿到文件后，在本地就能直接看完整的调用拓扑，再也不用盯着几张高糊截图一层层去人肉展开节点了。 而且，导出的文件格式对团队内部的AI Agent或分析工具非常友好，可以直接喂给大模型去帮你自动抓耗时瓶颈，把费眼睛的活儿交给AI。 针对Mono堆栈，我们还顺手补了几个高频需求：支持正序/倒序Top10调用链导出，并且开放了OpenAPI，你可以直接把它塞进你们的自动化流水线或内部平台里，实现持续集成式的性能分析能力。 2. 2D物理模块分析能力补齐 在很多项目里，物理系统往往是“隐性开销区”，尤其是2D或小游戏项目，Dynamic Body、Kinematic Body等对象数量一旦上来，性能问题并不会立刻爆发，而是体现在整体帧耗逐步抬升，但又很难直接定位到物理层。 这次新增的统计能力，把这部分开销单独拆了出来，包括Dynamic Body、Kinematic Body等关键类型的运行情况，都可以在报告中直接看到。 这样做的好处是很直接的，以前是“感觉物理有点重”，现在可以明确知道——到底是动态刚体过多，还是运动体更新频繁，甚至可以进一步反推是哪一类玩法或系统在持续制造负担。 对于2D或轻量级项目来说，这一块补齐之后，整个性能分析链路会更完整，也更容易把“非渲染类卡顿”单独拎出来处理。 3. 新增Animator/粒子系统趋势统计 在实际项目里，Animator和粒子系统通常是最容易被忽略的性能来源。它们的问题不会突然爆发，而是随着运行时间慢慢累积，最后表现为整体性能持续变差。 这次新增Animator资源统计，重点就是把“隐性消耗”拉出来看清楚。很多时候问题不在动画本身，而在用法：UI反复生成不释放、Animator空转、状态机频繁切换，都会悄悄把开销抬高。真正需要关注的，其实不是“有没有在播”，而是Animator实例是否持续增长，以及是否存在持续更新却没有实际效果的情况。 粒子系统也是一样，这次新增Playing状态趋势，就是为了抓“越跑越多”的情况。有些问题不会在某一帧出现，而是粒子没停、反复叠加、回收跟不上触发，时间一长就会持续拉高性能开销。关键不在峰值，而在曲线能不能回落，回不去，基本就不是单点问题了。 4. Lua分析能力扩展到鸿蒙平台 随着做鸿蒙项目的团队越来越多，这次我们把鸿蒙平台的Lua性能分析能力也补齐了。现在已经支持鸿蒙端的Lua耗时和Lua内存数据采集。Android、iOS、鸿蒙三端齐活，跨平台测试时一套流程跑天下，不用再折腾不同的工具链。 5. Release调试能力再往前一步 以前最让人抓狂的是什么？Bug线上跑得欢，切到Debug连个影都抓不到，定位全靠“玄学”和“瞎猜”。 兄弟们，以后不用这么痛苦了。现在Release包直接支持自定义代码段和变量采集了！意思就是：你不用为了排查个问题倒回去重打Debug包、冒着“问题消失”的风险去折腾。就地在Release环境下，你想看哪个函数的耗时（比如关卡加载、技能释放），想捞哪个模块的状态机或者关键开关，甚至想数一数某条路径被触发了多少次，直接在线上就能抓！ 既保住了最真实的性能环境，又把底牌摸得清清楚楚，这才是属于咱开发者的“纯天然、无污染”第一手数据。 6. 顺手改了几个每天都用得着的小细节 除了几个重点能力，这次还补了一些日常分析中使用频率较高的优化。 自定义函数：加了“调用次数曲线”，一眼就能看出哪些函数调用次数突然增多； 网络分析：加了上传/下载消息包统计，网络卡顿到底是包太大还是发太多，数据说了算； Low Memory：新增了Low Memory事件捕获与统计，不仅可以快速定位告警发生时的帧号，还支持通过MCP获取触发次数，方便团队快速统计各场景的内存压力情况。 比起继续增加更多性能指标，我们这次更想解决的是大家在实际工作流里的卡顿感。 从堆栈导出、支持鸿蒙，到Release环境下可观测能力的增强...我们希望GOT Online不仅是一个“体检仪”，更能变成一个帮你把问题快速流转、快速干掉的“催办助手”。 如果你已经在使用GOT Online，不妨体验一下这些新能力；如果还有更多希望支持的分析场景，也欢迎继续和我们交流，我们会持续把来自一线项目的需求，转化为下一次更新中的能力。 最后说两句： 工具的价值，不仅在于它能算得多快，更在于它能不能真的融入团队的日常流转中，帮大家准时下班。 我们深知，在项目期引入或者尝试一个新工具是有成本的。为了让大家能毫无负担地验证这波更新的疗效，我们特别开放了「两周GOT Online免费试用通道」，并且会提供专属的性能专家诊断名额。 不管你们现在卡在什么性能瓶颈上，联系我们的商务同学，UWA的技术专家会一对一陪着你们跑通测试，直击问题。 关于UWA UWA是一家创业十年的高新技术企业，作为游戏行业的深耕者，UWA始终专注于为使用Unity、Unreal引擎的开发者提供丰富的优化产品，帮助开发者高效解决开发问题、定位性能瓶颈、提供解决方案，已支持超过一万款游戏项目。还打造了技术博客、问答、开源库、学堂等社区产品，为开发者提供便利和高效的支持。线上培训和线下教育的新业务，满足行业对人才培育的需求。
GPM 2.0 全新上线WebGL专属监测，全面适配微信、抖音小游戏
微信、抖音、WebGL小游戏受浏览器与Wasm架构限制，线上内存泄漏、偶发卡顿难以线下复现。GPM 2.0推出定制化WebGL监测体系，双堆内存追踪、卡顿堆栈定位、玩家画面回放一站式打通线上性能黑盒，补齐小游戏研发线上数据短板。 ▘▖小游戏赛道高速增长，却深陷线上性能监测空白 微信、抖音、WebGL小游戏轻量化、上线快、流量灵活，已是当下游戏创业主流赛道。但长久以来，缺少专业且具有针对性的性能监控工具，线上优化长期处于 “看不见、查不准、复现难” 的被动局面。 针对WebGL小游戏独有的性能管控痛点，UWA GPM 2.0 WebGL专属监测能力正式发布，原生兼容微信、抖音两大主流小游戏平台，从内存追踪、卡顿根因定位、玩家故障现场回溯三大核心维度，填补行业工具空白，让小游戏性能优化完全实现数据驱动。 ▘▖专为WebGL底层定制监测框架，彻底打破线上性能黑盒 不同于通用工具的简单适配，GPM 2.0围绕WebGL、Wasm运行机制打造专属采集逻辑，精准捕捉浏览器环境下难以察觉的隐性性能劣化。 1. 全维度实时性能指标全覆盖 持续采集线上玩家FPS、卡顿帧耗时、网络波动等核心指标，自动生成可视化趋势曲线。可捕捉人工测试极易忽略的细微帧率抖动、瞬时性能峰值，完整留存全渠道玩家全时段运行数据，全面覆盖线上性能变化。 包含性能数据&截帧的异常对话报告 2. Wasm+JS双堆内存专项追踪 内存泄漏、资源卸载不完全是WebGL小游戏最高发顽疾。GPM 2.0独立输出三条并行数据曲线：总堆内存、Wasm堆内存、JS堆内存，通过SDK的API将完整的场景切换、资源加载、交互玩法全过程进行上报，可以进一步细分内存波动的时机。通过数据研发可进一步排查和定位频繁创建对象、资源缓存残留等根源，从源头解决内存持续走高引发的卡顿、闪退。 小游戏项目内存数据及趋势图 ▘▖卡顿根源精准定位，告别盲目试错式优化 WebGL小游戏卡顿成因复杂，逻辑阻塞、渲染过载、资源加载拥堵、GC波动都会造成操作滞涩，传统工具无法区分故障类型。GPM 2.0依托多维度数据联动分析，可方便细分卡顿核心诱因，大幅降低调试人力成本。 1. 卡顿帧堆栈精准溯源 通过提前结合业务手动打点关键调用路径，每一次帧率暴跌、卡顿事件发生时，快速锁定耗时代码段、资源加载阻塞、循环逻辑堆积等问题。无需QA反复尝试复现玩家反馈的故障，优化需求可精准落地至对应业务模块，迭代效率显著提升。 单个玩家一次对话中发生的卡顿帧堆栈信息 玩家全局卡顿汇总批量分析 2. 版本性能基线自动对比 支持新旧版本、活动前后数据横向对照，直观标注新版本、新特效、新玩法上线带来的帧率下滑、内存上涨等性能劣化。迭代引入的隐患一目了然，为研发调优划定清晰优先级。 新旧版本性能数据对比 ▘▖完整玩家行为回溯，高效复现线上偶发疑难BUG 大量WebGL性能问题仅触发于特定场景、特定操作组合，线下测试难以稳定复现，长期拖累小游戏迭代进度。GPM 2.0搭载异常画面留存+全链路行为回放能力，完整还原真实玩家故障现场。 1. 异常瞬间自动留存游戏画面 帧率抖动、卡顿发生时自动截取实时游戏画面，完整记录故障时刻场景、特效、UI 状态，不再依赖玩家模糊文字描述，研发可直观看见问题发生现场。 2. 全链路玩家操作轨迹同步回放 完整记录玩家点击、滑动、技能释放、场景切换全部交互行为，与性能曲线联动复盘，精准定位特定操作触发的概率性卡顿，快速闭环玩家体验类客诉。 在线玩家数据实时看板 玩家行为&性能数据&自定义事件一览 3. 多维度定向筛选，精准定位渠道/机型专属问题 支持渠道、机型、画质档位、游戏场景、付费用户分组等多维度筛选，可单独锁定低配机型、特定小游戏渠道独有的性能缺陷，有针对性地完成机型适配与资源轻量化优化，全域提升线上流畅体验。 由于行为记录功能在小游戏平台上对性能会造成一定程度的影响，GPM 2.0针对小游戏平台的截帧功能默认关闭，请根据项目实测数据决定是否开启。 ▘▖覆盖小游戏全生命周期，长效管控线上品质 GPM 2.0 WebGL监测能力贯穿小游戏从测试验收、版本上线、互动迭代、长线运营的完整周期，分阶段解决研发、运营核心痛点。 ▘▖全平台稳定兼容，符合小游戏商用上线规范 GPM 2.0深度适配Unity全系列WebGL打包方案，原生支持微信小游戏、抖音小游戏双主流平台；SDK采集逻辑损耗极低（默认关闭截帧功能），完全满足平台审核标准，适配独立工作室、中小型商业小游戏项目长期迭代运维需求。 GPM 2.0 WebGL专属监测能力的上线，补齐行业工具短板。依托Web端专业数据采集、精准卡顿溯源、完整玩家故障回放三大核心能力，帮助小游戏团队以数据驱动优化，解决线上性能核心痛点，在小游戏流量赛道构建长效产品竞争力。 如需体验WebGL小游戏专属性能监测能力，可前往官网查阅完整接入文档： 官网地址：https://gpm.uwa4d.com 技术咨询：gpm-support@uwa4d.com GPM 2.0 可以提供私有云和GPM SaaS平台的两套部署方式供选择。 关于GPM 2.0 UWA GPM 2.0是专为上线及测试阶段游戏项目打造的高性能监测平台。它不仅能深度捕捉宏观性能数据，更创新采用性能无损截帧技术，在不影响玩家体验的前提下，助力开发者全面掌握玩家端运行关键细节，从多维度优化游戏性能，实现从“玩家投诉后救火” 到 “问题发生前预警” 的核心转变。 GPM 2.0全景指南 《UWA GPM 2.0 核心功能全景指南｜重塑游戏性能主动管控能力》 《5 月刊｜GPM 2.0实现全场景可视化溯源、多维度数据解析与根因精准定位》 《4 月刊｜GPM 2.0核心玩家守护功能上线》 《3 月刊｜GPM 2.0性能数据探索 & 单玩家追踪升级，精细化性能分析再突破》 《2 月刊｜GPM 2.0错误日志分析上线，PC端监测能力全维度突破》 《1 月刊｜日报周报一键获取+AI问答，玩家行为精准分析》 《12月刊｜上新！全局筛选+设备标记+序列帧回溯，GPM 2.0性能分析效率拉满》 《GPM 2.0 功能更新月报：新增导出表格、崩溃反混淆等实用功能，体验再升级！》 GPM 2.0新功能 集锦 《我以前只能告诉你“哪个函数卡”，现在我能带你去看“玩家在哪卡”》 《大家好，我是UWA GPM「小Q」，今天是我加入项目的第一天》 《无需自建机房运维｜UWA GPM 2.0 SaaS正式上线，让游戏线上质量监控轻量化落地》 《数字助理已到岗！GPM AI 智能体发布，游戏性能分析进入全自动时代》 《GPM 2.0全新功能发布｜GPU精准监测 + 精细化运营，重构游戏性能管控新范式》 《GPM 2.0 SDK 热更新｜不打扰玩家的“隐形升级术”！》 《GPM 2.0功能尝新｜AI识图精准捕获材质丢失，全链路护航游戏品质！》 《UWA GPM 2.0 崩溃上报功能全面升级｜全场景覆盖+免费开放，让崩溃排查不再卡壳》 《大R流失、问题难复现？GPM 2.0「指标 - Session直连」功能，让异常定位效率翻倍！》 《从 “被动救火” 到 “主动掌控”：GPM 2.0 如何重塑游戏性能竞争力？》 《GPM 2.0 告警功能：游戏性能的智能守护者》 《无限接近真相｜GPM 2.0 功能上新》 《崩溃现场全回溯｜GPM 2.0「崩溃上报分析」上线，终结“无法复现”的噩梦》 《GPM 2.0 全新发布「卡顿帧堆栈」功能｜精准高效定位游戏玩家端性能》 《深入解析「卡顿帧堆栈」》 《解锁GPM 2.0「卡顿帧堆栈」｜代码示例与实战分析》 《如何精准打点解决卡牌、SLG、开放大世界、放置类游戏卡顿难题》 《不妥协每一毫秒的性能 不浪费每一字节的流量》 《GPM 2.0 重新定义性能监测与优化｜从未知到已知，从被动到主动》 《UWA GPM 2.0 重新定义性能监测与优化｜如何高效预警和定位玩家端的性能问题》 GPM 2.0案例精讲 集锦 《携手护航亿级体验｜《迷你世界》× UWA GPM 2.0 性能管控最佳实践》 《我们的目标是不耗电、不发烫、不闪退》 《冒险闯关类玩家关注的四大体验》 《玩家端性能数据 一手掌握》 《造成设备功耗高的罪魁祸首》 《海外市场设备适配性优化实战》 《5000人测试背后的秘密》 GPM 2.0实操篇 集锦 《GPM 2.0 按游戏类型定制性能监控策略｜「告警」功能实战指南》 《GPM 2.0 让每一帧的流畅兑换真金白银》 《GPM 2.0 实操之Session篇之如果闪退是因为内存崩了》 《GPM 2.0 应用场景实例之解决让人头疼的问题》 《GPM 2.0 助力设备监测、行为分析与性能优化｜玩家共测新体验》 《GPM 2.0 API 功能助力定制你的性能优化》 《GPM 2.0 驱动优化与投放决策｜硬件性能一目了然》 《GPM 2.0 多维数据与实时截帧完美结合》