UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技
UWA博客，UWA旗下官方博客。针对游戏及VR应用性能优化等内容记录、交流，以及案例分享。博客主要分为：厚积薄发、万象更新、博观约取、充电一刻、博物纳新等几个模块。
流式加载后，为什么GPU仍在裁剪大量三角形
1）流式加载后，为什么GPU仍在裁剪大量三角形 2）开启级联阴影后，三角形数量为什么明显增加 这是第488篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q1：大地图已经做了流式加载，只加载角色附近区域，但GPU里仍有大量三角形被裁剪。为什么还会出现这种情况？ A：可以先看UWA GOT Online报告中GPU Input Primitive的数据，继续拆分Culled Primitives的具体构成。 以报告中第830帧为例，Culled Primitives约95.8万，其中Facing & XY Plane Culling Primitives约43.2万，Z Plane Culling Primitives约21万，Coverage Culling Primitives约31.6万。 Facing & XY Plane Culling里包含背面剔除，这本身属于正常的渲染流程，而且目前无法继续区分其中有多少来自背面剔除、多少来自XY方向超出范围，所以不能直接把这部分都当成无效开销。 Z Plane Culling可以优先检查场景里是否有覆盖范围比较大的Mesh。比如一块Mesh横跨较大的空间，其中一部分几何已经超出摄像机视锥的前后范围，但整块模型仍然被提交给GPU，这种情况下可以评估大块Mesh是否需要适当拆分。 Coverage Culling则可以重点检查LOD。如果模型已经离相机很远，但仍保持近距离时的高面数，三角形投射到屏幕后会非常小，其中一部分甚至覆盖不到有效采样点，最后被GPU裁掉。可以检查远距离LOD是否降得足够。 Q2：项目里的角色支持换装，如果给不同衣服都做LOD成本比较高，这种情况下角色LOD还需要做吗？ A：可以先确认角色对当前三角形数量的影响有多大，再决定是否值得做。比较简单的方式是在相同场景、相同视角下做一组开关测试，一组正常显示角色，一组把角色关闭，再对比Primitive相关数据。 角色关闭后三角形数量和裁剪数据明显下降，说明角色对当前几何压力的贡献较大，可以进一步评估角色LOD；数据变化不明显，则优先把精力放在场景模型上，继续检查大块Mesh和场景LOD。 实战案例 Q：高画质开启级联阴影后，三角形数量从几十万涨到100万以上，这部分增长和级联阴影有关吗？ A：有关系。级联阴影会让部分物体重复参与阴影绘制，同一个物体除了正常渲染，还可能需要参与额外的阴影绘制，所以GPU需要处理的几何量也会增加。 高画质下Triangle数量 中画质下Triangle数量 从GOT Online报告数据来看，两档画质下的几何量差异比较明显。中画质下大部分场景维持在几十万，峰值约62.1万；高画质下则多次接近或超过100万，峰值约130.6万。 不过，高画质下可能还同时调整了其他渲染配置，所以不能把这部分增长全部归到级联阴影上。可以固定场景和摄像机视角，只调整级联阴影相关配置，再对比Triangle或Input Primitive的变化。 中画质GPU Fragment Shaded 除了三角形数量，也可以结合GPU Fragment Shaded看GPU侧的压力。从报告中可以看到，Fragment shaded均值约332.2万，峰值约966.1万，说明除了几何量以外，还可以继续关注片元侧的处理量。 高画质下GPU压力增加时，不建议只看三角形数量。可以结合Triangle、Input Primitive和Fragment Shaded等数据一起判断。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
内存数据不再逐页翻！GOT Online开启“精准降本增效”模式
对很多项目组来说，“内存”永远是性能优化里避不开的硬骨头。但每次分析报告时，寻找各种内存指标、整理不同岗位的内存数据，往往耗费了大量的无用时间。GOT Online此次更新，主打的就是把核心内存数据集中呈现，并全面增强了对内存细分资源的定位能力。 除了针对内存分析的重磅升级，本次还同步上线了函数Top10耗时点和函数组数据一键导出等实用功能。 聚合常用指标：按需定制专属面板 做性能排查时，程序、美术、测试打开的是同一份报告，但关注点完全不同。哪怕都在美术团队，场景、角色、特效需要盯着的数据也差了十万八千里。 结果就是每次打开报告，都得重新翻页、找指标、做对照。数据明明都在，时间却花在了“去哪里找”上。好的性能工具，从不强求全员看同一份大表，而是让每个人打开报告的第一眼，都能直奔自己的“主战场”。 GOT Online本来就支持自定义模板，这次进一步把内存分析模块中的指标也放进了面板。以后经常看的内存数据，直接集中搬到自定义面板里，大家按自己的工作内容建立模板，搞场景的看场景，搞特效的看特效。关注的数据一眼就能拿到，不用再在大大小小的页签里绕圈子。 结合同步更新的场景性能表，还能进一步看清各个场景区间的细分指标。像 DrawCall 均值/峰值、不透明与半透明 DrawCall 分布等数据一目了然，甚至超标项还会直接高亮预警。看到哪个场景区间有问题，点击对应行的“查看场景数据”就能秒级跳转深挖，排查效率直接拉满。 深挖Unreal资源：三步锁定内存大户 自定义面板解决了“内存数据怎么看更快”的问题。但一旦发现内存异常飙升，接下来还是得回答那个最现实的问题：到底是哪类资源占得多？最后又落到了哪个具体的资产上？性能排查最怕凭空盲猜，缩短排查路径的本质，就是尽可能减少这种无效猜想的过程。 GOT Online之前已经支持Unity项目的资源内存分析，这次把这项能力也补齐给了Unreal项目。现在Unreal项目也可以直观查看资源内存趋势、数量变化和不同资源的占用分布。纹理、网格、动画、音频、材质、Shader、RenderTexture和粒子系统等，都能分类查。 以前看到内存涨了，大家第一反应只能是猜：“是不是纹理爆了？还是RenderTexture没释放？”现在可以先看类型分布，纹理占用明显抬高，就先查纹理；RenderTexture数量不对，就继续盯RenderTexture。 看清类型后，点进具体资源列表，资源名称、内存占用峰值、数量、生命周期直接展现。如果是纹理，还能结合尺寸、格式、Mipmap和Streaming等属性继续判断。这样排查路径就彻底顺了：先看内存怎么变 ➔ 再看是哪一类 ➔ 最后追到具体资源。报告不会替项目直接下结论，但至少不用对着一条上涨曲线干猜了。 标出耗时Top10：快速定位最慢帧 一条耗时曲线上可能有成千上万个数据点，真正值得关注的，通常只是少数几个卡顿峰值。以前排查偶发问题，沿着曲线来回翻，大部分时间看到的都是正常数据。但性能优化的黄金法则，永远是先干掉最显著的瓶颈。别把有限的精力浪费在那些平稳正常的帧上。 现在，只需点击按钮，就会直接在曲线上标出Top10最慢的点。哪些位置拖了后腿，一眼就能挑出来，再结合对应帧、线程和上下文去深挖。当然，Top10只是耗时最高的位置，不代表它一定就是卡顿根因。但它至少把范围缩小了。与其从头到尾逐帧找，不如先盯住这10个最可疑的位置，排查效率显然高得多。 导出一键完成：告别手动搬运数据 很多团队都会按照项目特点建立“函数组”，把长期关注的函数整理在一起，用来做版本复测、设备对比或问题回归。麻烦的是，报告一多，这些函数组和对应耗时数据也会散在不同地方。后面想做对比，或者交给其他同事继续分析，往往还得手动重新整理一遍。 性能优化本身就足够消耗脑力了，完全没必要再把时间消耗在重复的数据搬运上。GOT Online现在支持一键导出所有函数组以及对应耗时数据。导出结果既有整体汇总（总耗时均值、自身耗时均值、耗时占比等），也有组内每个函数的详细耗时和调用数据。 同时，这项数据也已经同步支持OpenAPI拉取。不仅可以手动导出Excel，还能直接接进团队内部的自动化平台或对比工具。版本复测时，把不同报告的导出结果拿来一比，或者用接口自动跑对比脚本就行，不用再傻傻地逐个函数组去抄数据了。 场景横向对比：一眼找出表现异常 长时间运行的测试报告里，往往穿插了多个场景。真正开始深挖之前，第一步通常不是直接查某个具体函数，而是先找出“哪个场景表现不太对”。 GOT Online针对Unity项目重做了场景概览页，把不同场景的关键指标（FPS、CPU、GPU、内存、耗电和温度）拉到一起横向对比。哪个场景FPS突然掉底，哪个场景内存数据异常，通过图表和列表一眼就能看出来。先把差异摆出来，告诉你哪里值得继续看，至少打开报告后不用对着一堆指标发愣。 更多实用补充：卡顿堆栈与温耗均值 OpenAPI同步补充卡顿数据：新增卡顿帧列表、卡顿堆栈树和场景卡顿堆栈。单份报告可以直接在页面分析；报告数量多了，可以通过接口接入内部自动化平台，批量筛选异常报告。 新增温度和功耗“平均值”：峰值高只能说明设备某个瞬间很忙，均值高才代表持续高负载。现在结合“峰值”和“均值”一起看，更能判断设备是不是持续发热或降频。 性能报告里的数据越来越多当然是好事，但如果每次分析都要在不同页面之间来回翻、重新找指标、手动整理表格，再完整的报告也会让人看得很累。 这次GOT Online的更新，做的事情其实很纯粹：把大家最关心的内存和常用数据放得更近一点，把值得关注的异常提前挑出来。真正高效的研发体验，从来不是一味地堆砌数据量，而是让最关键的信息在你需要的时候触手可及。排查顺手了，优化效率自然就上去了。 最后说两句： 工具的价值，不仅在于它能算得多快，更在于它能不能真的融入团队的日常流转中，帮大家准时下班。 我们深知，在项目期引入或者尝试一个新工具是有成本的。为了让大家能毫无负担地验证这波更新的疗效，我们特别开放了「两周 GOT Online 免费试用通道」，并且会提供专属的性能专家诊断名额。 不管你们现在卡在什么性能瓶颈上，联系我们的商务同学，UWA 的技术专家会一对一陪着你们跑通测试，直击问题。 关于UWA UWA是一家创业十年的高新技术企业，作为游戏行业的深耕者，UWA始终专注于为使用Unity、Unreal引擎的开发者提供丰富的优化产品，帮助开发者高效解决开发问题、定位性能瓶颈、提供解决方案，已支持超过一万款游戏项目。还打造了技术博客、问答、学堂等社区产品，为开发者提供便利和高效的支持。线上培训和线下教育的新业务，满足行业对人才培育的需求。
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 前言 UE：5.7.1 GASP：5.7 本文是以Mover版本角色来进行浅析GASP中旋转的实现，包括Strafe移动，AimOffset，TurnInPlace。 同时也会结合分析Lyra和ALS的一些实现方案。 1. Debug与基础RotationMode 1.1 Debug 为了更方便查看旋转相关的Debug信息，我们需要打开Widget中的Draw Shapes，就会看到下图这样很多的箭头，其中有意义的部分为： 圈内黑色大箭头：Root根骨骼朝向 圈外黑色小箭头：角色（碰撞体）朝向 亮粉色箭头：TargetOrientation旋转偏移前的目标朝向 暗粉色箭头：TargetOrientation+RotationOffset旋转偏移后的目标朝向 橙色箭头：MovementIntent，角色的期望运动方向 绿色弧线：当前MovementDirection移动朝向的范围 1.2 RotationMode 在GASP中，旋转模式分为OrientToMovement、Strafe和Aim，如果你是用过ALS的，那么它们是与VelocityDirection、LookingDirection和Aiming一一对应的。 OrientToMovement：角色将旋转至面向移动方向 Strafe：角色在移动时将大体面向瞄准方向旋转 Aim：角色将始终以最小偏移量精确面向控制器旋转方向，也算是Strafe模式 RotationMode主要是由玩家的按键输入以及游戏模式决定，在GASP中可以通过鼠标中间切换Strafe和Movement两种模式，并且可以按住右键进入Aiming模式。在常见的开放世界游戏中大多都是默认使用OrientToMovement模式，而当玩家拿起弓箭或者枪械瞄准时则会进入Strafe之中。 其中OrientToMovement比较简单，角色朝哪边移动就转向哪边即可，就不过多介绍。 2. Strafe基础 2.1 BlendSpace做法存在的问题 记得我最早接触Strafe移动还是在跟着一些UE4新手教程做战斗系统的时候，当做到了弓箭瞄准，或者类似只狼锁定目标时，就需要出现玩家始终面朝某个方向，却向着其他方向移动的情况。但是新手教程中的做法往往很简单，例如下图这种，用一个Direction来表示离面朝方向偏差多少角度，然后一个1D BlendTree混合。也有稍微好一点点的会使用2D BlendTree，使用LR/FB来进行混合。 1D因为不是环形的，在遇到从左后切换到右后时，需要经过-180度到180度把中间状态全都走一遍的情况，表现十分奇怪。而2D无论是四向在融合的地方都可能出现预料之外的表现。对于一些融合表现不好的地方可以选择补全八向动画。 若使用RootMotion动画，混合后会出现移速衰减问题，例如前后、左右单方向移动速度均为100，在往左前走的时候速度就只有50 √2 ，但是我们的期望肯定是斜向移动也是100，最简单的做法通过调整动画播放速度，来调整移动速度，但是会比较鬼畜。也可以考虑用程序补偿，缩放一下，或者直接用程序位移。 2.2 程序化多方向动画 二维的BlendTree也并不是要固定四个方向的动画，可以根据需要灵活调整为8向，10向都是OK的，GASP则是用到了四个动画F\B\LL\RL，但GASP并不是通过BlendTree混合出来的，使用BlendTree融合在没有动画的角度融合的角色往往会不尽人意，其使用的是OrientationWarping，同样是程序化修改在小范围内效果还是可以的。Lyra更是暴力地只用一个动画就OrientationWarping了360°出来。 OrientationWarping能使脚根据运动方向向不同方向移动。让下半身运动匹配移动方向，而上半身保持不变，以此实现向不同角度进行Strafe。他的实现逻辑是先旋转Root，再反向依次旋转Spine，例如下图中就是使用向正右方移动，但是被Warp了45度到了右前方。 2.3 腿部穿插 无论是BlendSpace还是OrientationWarping这些方法都无法解决脚步交叉的问题。而脚步打绕常出现于交叉脚的时候去往另一个Pose融合，但是切换过去的脚前后相反，直接融合过去就会穿插，比如下图本来是向右走的左脚在前，交叉时切回向后的右脚在前，腿就穿了。 为了解决这个问题，一个比较常见的做法就是分扇区，将前向和后向分别作为不同的BlendTree，在每个扇区中都让同一只脚保持在前方，同时保证同样播放进度时脚都是同一的前后位置，这样在扇区内Blend的时候就不会出现前腿要放到后方的穿插情况。同时为了避免在边缘时扇区频繁切换还会加入DeadZone，则让每个扇区的范围扩大一些，有一些重叠区域。 当融合发生时，融合前后的Pose一只脚在前，另一个Pose脚在后就会出现融合时穿插的问题，但其实要修复这个也非常简单，只需要在合适的时机进行融合也可以解决，要么相同脚在前，要么处于非交叉状态，正如下面视频展示。 我们可以选取当他到了什么时间节点我才允许切换过渡，但是这样会影响手感，更好的做法是使用SyncGroup让切换过去的时机对应。 除此之外还可以通过衔接动画方式来解决，通过过渡动画替代动画直接融合，来规避脚部穿插的情况，这样还可以改善在切换过程中仍然是两个直接动画融合可能让表现不符合预期的问题。 3. GASP的Strafe 3.1 MovementDirection分扇区 在GASP中，为了避免脚步打绕穿插的问题，同样也用到了分扇区的做法。 在最新的工程中他们还开放了一个实验性功能，可以针对于Aim和Strafe分别选择不同的扇区风格，可以是分前后左右四个扇区，也可以只有两个或者一个。 但是如果你打开枚举类型会发现，并不是只有F\B\L\R这四个，而是F\B\LL\LR\RR\RL这六个。如果你看过ALS你就会感觉非常的熟悉，和ALS的RF\RB类似，RF代表角色向右运动但是胸口朝右，RB表示角色向右移动但是胸口朝左（背部朝右）。而GASP中也是一样，第一个字母L/R表示向哪边移动，第二个L/R表示胸口的朝向，例如RL就表示朝右移动，背部朝左（胸口朝右）如下图所示： 对于MovementDirection的选取也很简单，根据当前运动朝向处于哪个范围内就选择哪个，但是并不是左右胸口朝向不同的都上了，在当前Mover版本的实现中，采用了LL作为L，RL作为R，组成了F\B\L\R四个部分。（不过看动画也预留了RR和LR，后续可能会扩展成ALS那样六向。） 通过FL\FR\BL\BR四个角度值，就可以区分出四个扇区，如下图所示，同时在每个扇区时都会稍微扩大当前扇区范围以此来形成一个DeadZone，避免在边缘疯狂跳转。 在Debug模式中的绿圈就是表示当前扇区的范围，根据我们选择的Strafe Style不同，扇区覆盖的区域就会有所变化，就实现了控制扇区数量。例如在F\B模式中，FL和BL是相同的值，所以就不存在进入L扇区的情况。 3.2 Rotation offset 在操作Mover角色移动时，如果你打开Debug会经常发现有一个暗紫色的箭头，并且角色的朝向会跟随他。让我们偏移我们实际想要的朝向（亮紫色箭头）。这就是RotationOffset，和ALS的Yaw Offset是一个东西。 这东西的计算很简单，就是根据现在的移动方向和期望朝向的夹角来采样，比如上图的情况，亮紫色的期望朝向TargetOrientation和橙色的运动方向夹角为+135°，并且此时处于B的MovementDirection ，就会采样Curve_RotationOffset_B得到-45的RotationOffset. 关闭RotationOffset后，在同一扇区内动画表现差异不大，但在切换扇区时可能出现衔接问题，因为角色的朝向一直向前会导致选择到错误的衔接动画，这个后面会说。 （这里还有个小细节是GASP的OrientationWarp有个AngleThreshold开的135，为了测试这个我们需要扩大一些，这个值本质是为了防止Orientation过渡，不然在超过135度时会反转成向背面Warp45度造成错误。） RotationOffset的作用主要还是让角色的动画更加自然，下图中一个是带Offset一个不带的，虽然没有截到同一帧动画，但是可以看出动画还是略有差异的。在有RotationOffset的时候，角色移动方向和朝向差距180°，正好和原动画一样，就不需要OrientationOffset来程序化的修改动画，毕竟动画师K好的动画大概率是比融合或者程序化修改后的表现好一些。而在ALS中YawOffset就是为了避免多方向的融合，尽量使用原动画。两者都是为了尽量使用更加自然的原动画。 但是如果是这样的话为什么不一直保持这个RotationOffset呢？这样就不需要BlendTree的融合或者程序化的修改了，能够一直使用原始动画。在下面图片中可以看到GASP在右侧时，移动方向为右下时有45°的偏移，但是朝向右上的时候偏移却为0而非我们预期的-45°。左侧也同理。 如果强行改一下让他的左右扇区都有RotationOffset的偏移，可以看到下面视频的效果，因为有AimOffset的存在，虽然脚步正常，但是此时胸口和头是朝向正前方，然后此时角色朝向又朝斜向移动，表现就略显奇怪。同时因为此时是处于小角度45°，OrientationWarp也不会太过扭曲，所以这个时候GASP就选择使用OrientationWarp来处理这个情况而非Offset。 3.3 衔接动画资源 对于扇区之间的切换，GASP采用了过渡动画的解法，依靠于MotionMatching和Chooser制作了大量的衔接动画。 在GASP中有着大量的这种衔接动画，以Run为例就有密密麻麻的上图这么多，做的非常细节，不仅区分了左右脚还有各种不同的类型。这些Box Spin等貌似是舞蹈的步伐专业术语，但是我们可以从轨迹和作用来区分。 Spin：在运动方向不变情况下，将角色朝向进行改变 Box：在保持朝向不变情况下，运动方向发生改变90度，轨迹为L Pivot：在保持朝向步不变情况下，运动方向发生改变180度 Turn：朝向和运动同时进行改变，并且移动方向和角色朝向始终一致 Switch：运动方向与朝向都不变，但是胸部朝向左右切换 下图是每个类型举了一个例子： 3.4 衔接动画选取 这么多资源，还都有着不同的功能，那么在GASP中是如何选取使用的呢？ 在GASP中是通过Chooser选取一系列合适的动画，再进行MotionMatch选择。 其中选择的条件中需要StateMachineState为Transition to Locomotion才会被选中，而这个状态的进入条件主要就两类：一种是当前状态发生变化，比如最关键的就是movementdirection；另一种就是正在播，但是又快速有了调整导致当前动画不符合，就会再次进入再次触发选择合适的动画。 但StateMachineState并不能作为我们区分这么多动画的依据，主要还是靠的后面三个：FutureFacingDelta、IsPivoting和MovementDirectionRecent。其中FutureFacingDelta、IsPivoting都是根据未来轨迹预测的角度来计算。 FutureFacingDelta是由当前Root朝向和期望朝向的差值计算得到，用来计算角色的朝向在未来发生变化的角度，也就是Debug模式下的暗粉色箭头的变化。通过这个参数就可以区分角色朝向发生变化的Spin，Turn与未变化的Switch，Box，Pivot。同时根据这个参数还可以区分Turn的角度，转向的左右，以及使用哪个Spin动画，例如我在LL情况转回F则是向左转90度，而表中这个动画的值范围是-110到10度，正好满足同时多了+-20的容差。 IsPivoting用来判断运动方向有没有发生变化，它是通过未来轨迹和当前运动方向计算一个方向夹角，如果大于Ground是75度并有一定的移动速度就为True，使用这个就可以将Spin、Switch这类运动方向不变的动画与其他区分开。 在最开始我们介绍过MovementDirection，但它和这个带Recent的参数还不太一样，当我们比如切换到F方向时，是先使用MovementDirection选择当前方向，然后再在这里面选择从哪个方向转过来，除了刚刚的FutureFacingDelta描述转多少度之外，还有MovementDirectionRecent用来表示上一个稳定的状态是什么，这个参数是只有在当前MovementDirection保持0.1s以上才会储存更新。 这样在一些快速切换的时候，角色朝向和运动朝向其实都没有发生变化，Recent这个参数就会让没有动画被选择从而跳过衔接动画直接回到本来的Loop，避免播放了衔接动画但实际上并不需要从那衔接过来的情况。同时也可以为Switch动画提供选择依据，因为方向都没变化只有通过Recent来选择。 所以总结下来，对于之前提到的各个动画类型的示意图来说，FutureFacingDelta决定角色朝向变化，也就是紫色箭头的信息。IsPivoting决定运动方向是否发生变化，也就是橙色箭头的信息。MovementDirectionRecent决定当两者都不变化时，如何选择，也就是区分胸口朝向，绿色箭头信息。三个参数共同作用就解决了动画DataBase选择的问题。 最后再根据MotionMatching来匹配L/R Foot，以及以未来轨迹决定运动轨迹来完成最后从动画库中的选择。 4. AimOffset AimOffset还是比较常规的做法，用了一大堆的Pose组成一个BlendSpaces，用Yaw和Pitch来采样。最后通过MeshSpaceAdditive叠加上去，至于为什么是MeshSpace可以看官方文档的解释： https://dev.epicgames.com/documentation/unreal-engine/aim-offset-in-unreal-engine 但是AO有个细节还是可以关注下，GASP的AO也有平滑，但是它并不是对最后计算完毕的Yaw和Pitch进行平滑，因为这样会导致如下面视频一样（红色箭头代表Head朝向）的情况，因为AO是Root和Aiming方向的插值，Root转动比较快而AO再慢慢平滑的话就会出现偏离目标，头在转身过程中多歪了一些再回正。 解决也很简单，就是对Aiming方向进行平滑，但是计算Root和Aiming的差值不平滑，这样就能够跟上Root避免平滑导致的错误偏移。 5. 原地转身 5.1 ALS与Lyra原地转身回顾 在没有Turn的时候，角色旋转要么一直跟随相机像下面视频一样直接整体旋转却非常不符合正常生物的表现。而如果一直不旋转，只靠着AO去看向目标，在当我们的目标点在身后时，人是没办法把头旋转180°看到后方的，而且一般AO极限也就90°左右了。想一下现实中当我们想要看向另一个方向到达一定角度时，我们就会动一动脚转一下身子，这就是TurnInPlace。 在看GASP怎么做的之前，我们可以回归一下ALS与Lyra的流程。 ALS的角色旋转朝向没有使用CMC自带的ControllerDesiredRotation或者RotationToMovement。所以在移动时候能够根据期望朝向去旋转，而原地时如果没有特殊逻辑则会保持Actor不旋转。 当ALS处于Strafe或者Aiming模式下时，会计算Control和Actor的朝向夹角，当超过一定角度后（Strafe还会要求并且保持一定时间）就会根据方向和角度选择合适的动画播放。每一帧的旋转量会被存在动画曲线中，根据实际旋转角度调整动画播放速度和缩放旋转曲线值以实现一个动画对应多个不同的角度，并每一帧应用这个旋转曲线值就实现了原地旋转。 但是这样会存在一个问题，就是如果游戏帧率很低，那就会跳过一些曲线点导致旋转被应用的不够，ALS是直接强行差了几倍时间就缩放几倍，这样会导致最后有所偏差。 Lyra则比较简单，首先因为Lyra的Actor旋转是始终跟随ControlRotation的，所以Mesh也会跟着动，而为了让Mesh保持不动，Lyra在ABP中会计算一个差值然后反向应用给Root让其保持不动。 在旋转动画Lyra并未考虑转身动画适配不同角度，而且是暴力的直接播放转90度动画，在转身后使用AO去对齐目标。 5.2 DistanceMatching For Turn In Place (修正：这里提到的AdvanceTime和UE的节点不太一样，UE节点是输入Delta，然后根据Dleta进行前进，并不会追逐剩余时间，而我这里直接把AdvanceTime当做了输入Remain，更像是ToTarget带RateLimit。) 对于这个方案是UE官方在Paragon Feature Examples和Bringing a Hero from Paragon to Life with UE4的分享中提到（链接在末尾）。虽然DistanceMatching在熟知的Lyra中被用在停步的距离匹配。这个不只可以匹配移动距离也可以用来做旋转，当使用DistanceMatching做旋转时，我们可以根据想要旋转的角度，以及旋转曲线来匹配合适的时间点。而非基于时间的线性播放来驱动。 Lyra在起步停步动画的时候，用到了两个叫DistanceMatching的节点， 虽然DistanceMatching本质是调整动画播放的时间点，如果使用DistanceMatchToTarget则会直接跳到合适的位置播放，比如停步的时候就可能会是前面的动画不播放而从后面直接开始，另一个AdvanceTime如果去掉PlayRateClamp，其实和ToTarget是一样的，但是因为有PlayRateClamp的存在，也可以理解为是缩放了动画播放速度。但是相比于简单的缩放PlayRate，可能实际运动的节奏和动画的节奏不一定能对上，可以看下图。如果只是缩放播放速度，虽然最终时间对上了，但是过程中也和实际位移对不上就会滑步，就如下图所示。当动画位移和实际位移是一致时候，缩放PlayRate和使用DistanceMatching。在动画位移过大就会减慢速度等一等，当动画过慢就会加速播放追上去。 但是实际在使用过程中，并不会出现实际位移和动画位移相同的情况，在下图中左图是动画位移大于实际位移，右图是动画位移小于实际位移。因为有PlayRateClamp的限制，所以会导致最终动画位移和实际位移有差距从而滑步。 在动画位移较小，使用ToTarget会丢失前面动画，而AdvanceTime会先播放较慢速度，让实际剩余位移追上动画剩余位移。 在动画位移大的时候，使用ToTarget会丢失前面动画，AdvanceTime则会先加速动画让动画的剩余位移减少，追上实际剩余位移。 虽然上面都是在说距离，但是把距离换成旋转角度来理解也是同样的。可以看到AdvanceTime的轨迹总有一段是与实际的位移无法对上，会产生滑步问题。而ToTarget虽然可以比较好的匹配，但是代价是会丢失部分动画。 所以如果用到了Turn上时，就和起步动画类似，我们希望他从头开始播放，并且能够对应角度的旋转，我们会使用AdvanceTime。但是由于避免动画太过鬼畜有PlayRate的限制，还是会在某些时刻与实际旋转产生偏差从而滑步，这就需要配合FootLock来解决。所以DistanceMatching只是解决了如果在PlayRate的范围内，转身结束的时候动画也刚好结束。让动画来匹配逻辑旋转。 5.3 TurnInPlace In GASP 说了ALS、Lyra还有Paragon的方案，回来GASP来看。 首先GASP只有在Aiming状态下才会有TurnInPlace的表现。在Mover组件下，角色的朝向是由输入的OrientationIntent决定。在Aiming原地时，是超过60°时才更新OrientationIntent为AimingRotation的方向。之后通过一个FacingSmoothingTime的参数平滑过去。如果把这个角度限制去掉，那么就和CMC的UseControllerDesiredRotation一样，角色旋转会一直平滑转向控制器方向。 之后通过Root和期望朝向的差值计算得到前面说过的FutureFacingDelta，当这个值超过50°时就会触发shouldTurnInPlace，切换DataBase为原地Turn动画触发Turn。 这里在Aiming模式下60°才会修改角色期望朝向，而一旦改变为60°>50°就会触发TurnInPlace，但是如果我们把触发TurnInPlace的角度扩大呢？让它变为90°就可以看到下面视频中的情况（关闭了OffsetRoot）。还没有触发TurnInPlace但是角色转过去了。除非像最后甩鼠标一下转超过90°不然就永远触发不了Turn，直接滑过去。 但是这并不正确，在没有到达触发Turn角度的时候，我们正确应该是保持在原地不旋转，但是因为我们胶囊体已经转了，想要Mesh不转我们就需要将Root给反算回去，这就是Lyra的做法。而GASP采用了一个新节点来实现，避免了还需要在蓝图各种计算最后再使用ModifierBone节点修改Root。 在OffsetRootBone节点上，RotationMode是除了播放Montage时使用Release模式，其他情况都是Accumulate。在这个模式下，节点会保持Root节点在误差范围内保持不变，不会因为胶囊体作为父级旋转而让Mesh的Root也跟着旋转，在GASP中MaxRotationError值为-1表示无限大。当我们在刚刚那个视频同样条件下开启OffsetRootBone，再旋转60°时候，可以看到胶囊体已经旋转过去，但是我们的Root仍然保持不变。 解决了Root的问题，我们就可以接着看转身动画的逻辑，在shouldTurnInPlace触发后，状态会跳转进入Transition to Idle状态，触发Chooser根据FutureFacingDelta选择合适动画，这里要注意的是UseMM=False，所以会直接正常播放而不会对这个动画进行MotionMatching选择合适的位置。 首先在下图看到，如果直接播放这个动画，我们的角色大概会转80多度的样子，没有符合动画的90°，这是因为在Idle向Turn动画切换的时候有0.4s的融合时间，在融合阶段Turn动画的RootRotation并未全部应用，所以会有一些偏差，关掉之后就是正常的90°。（在ALS之中RotationAmount曲线也同样有这个问题。） 这个时候问题就来了，我们只是一个90°的动画，怎么让他在有融合干扰的情况下还能刚好转到60°的位置或者更多其他不同的角度呢？同时上面我们还说过有OffsetRoot的Accumulate模式，那我在移动时怎么才能正确的转向呢？ 这些问题GASP都使用了Steering节点来解决，这个节点的作用是缩放调整动画的旋转，让Root转向TargetOrientation方向。 因为需要缩放动画，就需要知道未来一段时间动画的位移，所以会有AnimAsset并且会传入AnimatedTargetTime表示向后预测多长时间，如果预测时间不够长则会导致动画缩放不均匀，破坏动画本身的旋转节奏。 因为在Steering生效期间玩家还是可以操作，上一秒希望转30°下一秒可能是135°。所以Steer会动态去追随。 Steering内部的做法和ALS的原理很类似，通过预测实际需要旋转角度和动画的旋转角度进行缩放，比如动画是90°但是转60°，则把每次的旋转量只应用66.7%。但同时他也有DistanceMatching的思想，但并不是用在修改时间，而且修改Root。 例如我需要转60°，有一个动画3s转90°，前两秒他会转20°，最后一秒转70°，动画节奏为先慢后快，但本文预测时长仅设置2s。 所以在开始的时候，预测到了动画有20°，实际有60°。理论应该缩放3倍，但是因为他也有Max/Min ScaleRate的配置，会导致只缩放1.5倍。虽然前2s转了30°，后1s因为有缩放限制最大缩放到0.5倍，转了35°。最终导致实际旋转为65°而非期望的60°，和DistanceMatching的AdvanceTime非常类似。 可能是因为预测时间不足，也可能是因为旋转过程中Target产生了变化导致Root在缩放范围内无法满足，所以除了缩放Root旋转之外，Steering还有校正补偿的部分。同时像RunFLoop这种没有根旋转动画也能正常转向也是因为校正补偿在起作用。 他的实现就是通过弹簧平滑补偿，而ProceduralTargetTime就决定了平滑的误差半衰期，越小就会平滑的越快。 在GASP中，TargetOrientation并不是使用当前的期望朝向，而是根据轨迹预测的。这使得转向节点能够朝向未来的方向旋转，而不总是朝着当前角色的旋转方向旋转，否则可能会导致他落后得太久。 理论上这一个节点应该可以解决原地转身和动画朝向的问题，但是GASP目前做了两个Steering节点，在原地转身的时候第二个也会生效。他们的主要差别在于RootMotionAngleThreshold从-1改为1和Disable Steering Below Speed从10改为-1。 第二个Steering的Disable Steering Below Speed可以避免在原地不动时Steering不生效，RootMotionAngleThreshold是当动画的RootMotion小于这个角度就不会执行RootScale的过程，但看起来对于第二个Steering幅度下应该没什么作用。 5.4 FootLock 无论是上面的哪种方法，在调整旋转量后都会出现滑步的问题。对此的解决方法就是FootLock。在脚步放在地上时将其锁住，让他不会因为角色整体旋转对不上而滑动。 在GASP中叫做FootPlacement，其做法也很常规，就是通过给动画打曲线标记记录哪个时刻脚步是落下的，之后在对应时刻将脚锁住即可。 他的具体实现是放在ControlRig中，在那里面GASP还做了很多小细节来修正一些表现，到这里篇幅已经有点大了，后面的文章再来探究ControlRig中的一些东西。 6. 后记 本文分析的都只是GASP中旋转的主要思路，同时也对比了GASP之外的其他实现方案，如ALS、Lyra等。 但是其中还有大量细节处理逻辑，还有一些比如处于连续转身的情况下避免选择Pivot动画，以及许许多多没有提及到的。 参考 Unreal AimOffset 官方文档 Paragon Feature Examples官方分享 Bringing a Hero from Paragon to Life with UE4 这是侑虎科技第2016篇文章，感谢作者Shadow供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/shadow-21-71-4 再次感谢Shadow的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
1）隐藏视频为何持续占资源 2）“N/A”纹理如何定位 这是第487篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：一段操作提示视频只有在玩家连续5秒未进行指定操作时才会出现，但UWA GOT Online报告显示，用于播放视频的RenderTexture一直常驻，VideoPlayer相关函数也存在明显耗时。视频大多数时间并没有显示，为什么仍然会影响性能？ A：视频没有显示，不代表相关资源和逻辑已经停止运行。GOT Online报告显示，这段提示视频使用了一张全屏分辨率的RenderTexture，同时带有32位颜色格式和32位深度，并且在整个测试过程中一直常驻。也就是说，视频虽然只在特定条件下出现，但为它准备的RenderTexture平时也一直存在。 这张RenderTexture只是用来显示一段短暂出现的提示视频，却使用了全屏分辨率和较高的颜色、深度规格。这里需要确认三个问题：当前分辨率是否有必要；颜色和深度格式是否过高；以及这张RenderTexture是否必须在整段玩法中一直保留。 除了内存占用，VideoPlayer本身也有CPU开销。报告中的平均耗时为1.47ms，但它并不是每一帧都会执行。只看实际产生耗时的区段，单帧开销接近3ms。 项目目标帧率为30FPS，每帧的时间预算约为33ms。接近3ms已经占用了比较明显的一部分，而这段视频大多数时间并没有显示。 排查时可以先确认两个问题：视频隐藏后，VideoPlayer是否还在更新；对应的RenderTexture是否仍然需要常驻。如果它只是一个低频提示，也可以重新评估是否必须使用视频，以及当前资源规格是否过高。 实战案例 Q：项目在内存分析中发现，大量纹理资源的名称显示为“N/A”。这些纹理基本都是RGBA32格式，分辨率不同，有些长期驻留，有些只在特定流程中出现。这些“N/A”纹理是否代表发生了资源泄漏？应该如何判断并定位来源？ A：先别急着凭“N/A”就断定是内存泄漏。观察报告里这批纹理的总内存趋势，它并没有随时间出现阶梯式的持续上涨，而是稳定维持在一个固定值。所以眼下的核心问题不是产生了内存泄漏，而是这批资源丢了名称信息，导致没法直接从报告里看出用途。 报告中可以看到，这些纹理均为未经压缩的RGBA32格式，分辨率大小不一，整体占用也较为可观。继续查看生命周期后，还可以发现其中一些资源一直常驻，另一些资源只在局部流程中出现。通过纹理的分辨率、格式、占用大小和出现时间进行综合分析，可以进一步缩小资源来源范围，判断这些纹理出现的场景和使用方式。 这种无名称纹理，在实际项目里通常出在两个地方：第一类是代码在运行时动态new出来的临时纹理或RenderTexture。比如项目为了做屏幕截图、UI高斯模糊、画中画或者某些材质特效，在C# 侧写了new Texture2D() 或new RenderTexture()，但创建时顺手漏掉了.name的赋值。这部分资源丢进内存快照后，就会统一被标记为“N/A”。 排查时需要把项目里所有动态创建纹理的代码翻一遍，核对它们的创建时机、规格参数和生命周期。如果相关业务已经关闭，需要确认有没有调用Destroy或对应的释放接口。如果每次进出界面这种纹理都会变多，那才是真正的创建与回收没对齐。 第二类是第三方SDK或插件内部自己生成的纹理。比如视频解码插件、WebView渲染、广告SDK，或者是某些动态字库/图集系统，它们在运行时产生的纹理，很多原厂就没有写赋值name的逻辑，报告里自然也就全显示为“N/A”。 插件里的资源没法直接根据名字追代码，必须拿它们出现的时间点去对业务流程。例如某批“N/A”纹理刚好是在放视频、调网页或者打开特定插件界面时弹出来的，就可以直接锁定到对应的SDK上，检查它的内部释放和缓存机制；如果功能关了资源还不卸载，就要看是不是SDK没调Dispose，或者内部保留了Cache。 当然，上面这两类情况只是排查无名纹理时最常遇到的切入点，在没有拿到确凿的代码调用栈和生命周期日志之前，切忌拍脑袋认定是某段逻辑或某个插件干的。对于只在局部流程里出现的纹理，最好拉出对应时间段的测试录像和操作日志，看看当时到底在跑什么功能；对于全程常驻的纹理，则要重新评估它们常驻的必要性。 建议在项目里规范起来：所有运行时动态创建的纹理，应当为name属性赋值。虽然起个名字不省显存，但能大幅降低日后的定位成本。不然下次再遇到性能问题，面对一堆只有分辨率和格式的“N/A”资源，谁也说不清到底是谁留下的。 说到底，遇到大量的“N/A”纹理，第一优先级不是盲目去写代码释放，而是先把“谁建的、什么时候出来的、为什么要一直留着”这三个问题摸透。在资源的归属和生命周期还没搞清楚之前，别轻易把它扣上“内存泄漏”的帽子。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
大家好，今天小Q聊聊「核心玩家守护」，给最重要的那批玩家，每人一份体验档案。 入职自我介绍那天，我说过一句让不少运营同学沉默的话：大R玩家从不抱怨。体验不好，他们不发帖、不投诉，直接消失 —— 等周报里的流失数字出来，人早走远了。 那天我只给了半句承诺：给重点玩家算流失风险、提供高风险名单。今天，我想把这件事的完整答案摆出来。它在GPM左边栏里有个自己的位置，叫“核心玩家守护”。 为什么大盘看板，护不住最重要的那批人 先说清楚这个功能为什么必须存在，不然你会觉得“我已经有性能大盘了啊”。 你现在的监控，几乎全是“面”的：大盘崩溃率、平均帧率、整体卡顿。这些面数据护的是“产品”。可每个项目都有那么一小群人 —— 大R、主播、公会核心 —— 他们是要按“人”来护的。这群人有三个特点： 一是人少。少到什么程度？少到出了事，大盘根本看不见。几十个人的帧率集体崩了，摊到几万DAU的均值里，小数点后两位都不带动一下。 二是要紧。这不用我展开，你们的流水结构自己最清楚。 三是沉默。普通玩家卡了会骂，骂了你还有机会修；这群人不骂。他们只是登录得越来越晚，在线越来越短，然后某一天，不来了。 所以核心玩家守护做的事，一句话：把“面”的监控，翻译成“人”的守护 —— 先把“该重点关照谁”变成一份名单，再把这份名单上每个人的性能、告警、流失风险，变成你看得见、追得上的东西。 大盘均值护得住产品，护不住具体的人。 第一步：把“重点关照谁”变成名单 打开核心玩家守护，第一件事是建分组。不想从零想，我们备了几套现成模板：鲸鱼用户（大R）、中等付费用户（中R）、主播/KOL、公会/战队核心、新进高潜用户 —— 勾选即建。 分组里的每个成员，还能再分三档角色：Core（核心大R）、High（高付费）、Normal（普通VIP）。别小看这三个标签，它们不是装饰 —— 后面你会看到，告警定级、流失率分层、风险评分加权，全都吃这三档。同一个“出事了”，出在Core身上和出在Normal身上，在这套系统里的动静是不一样的。 成员管理：一人一行，Core/High/Normal三档角色，谁命中“内存高、CPU/GPU温度高”的系统标签，名单上直接看得见 名单建好，分组概览页就是你每天的第一眼：每个分组一张卡，未关闭告警按P0/P1/P2摆着，受影响成员按Core/High/Normal分层数着。以我们演示环境里的「重点观察鲸鱼用户（大R）」分组为例：1条P0告警，影响分层Core 3、High 5、Normal 2 —— 一眼就知道今天该先看谁。 分组概览：每组一张卡，P0/P1/P2告警 + Core/High/Normal影响分层，先看谁一目了然 这里的P0不是谁拍脑袋定的。判级规则明明白白：核心成员受影响≥1人、或受影响总数≥3人，就是P0；高付费加核心≥3人、或总数≥8人，是P1；其余P2。这套线是默认值，每个分组都能自己改。告警发出去走飞书、钉钉、企业微信、邮件；处置动作（确认、静默、关闭）就在列表上，最近7天的平均确认时长（MTTA）、平均恢复时长（MTTR）也一并统计 —— 告警不只要发得出去，还要看得出“有没有人接”。 告警处置：P0/P1/P2一目了然，确认/静默/关闭就在行内，MTTA/MTTR顺手帮你记着账 点进告警详情，证据链摆在明面上：命中了哪条规则（“核心用户≥1，当前3”这样连当前值一起给你）、数据窗口是哪段、系统标签几点算的、Top归因是什么、受影响的每一个VIP是谁 —— 每一环都能核，核完直接跳去下钻。 P0不是我拍的，是你画的线算出来的。 告警详情：命中规则带着当前值、Top归因、受影响VIP名单，一条P0从判定到证据全程可查 性能分布看板：这17个人，此刻玩得舒服吗 名单有了，第二个问题就来了：这批人此刻的真实体验，到底什么样？ 注意，这个问题问的不是“平均帧率多少”。平均值在人少的时候尤其会骗人 —— 17个人里3个卡成幻灯片，均值看起来照样体面。所以性能分布看板给你的不是均值，是分布：把这个分组一段时间内的所有会话，按你选的指标切成一个个区间，告诉你每个区间里落了多少。 还是拿「重点观察鲸鱼用户（大R）」举例。最近7天，这组17个人产生了41次会话。FPS分布图上一眼扫过去：七成会话落在45~60帧，舒服；27%落在30~45，将就；但有一小截落在0~15帧 —— 这一截在任何大盘上都不会有存在感，在这里，它就是你今天要查的事。 性能分布看板：41次会话按帧率切区间，掉队的那一小截，在这里藏不住 指标不止帧率。FPS、卡顿率、内存、CPU/GPU/电池温度、功耗、电流，九个维度随你切；统计口径可以按会话数，也可以按人头（Active玩家）算；粒度按小时或按天；还能拉一条基线窗（前一个等长窗口、前7天、前30天或自定义）做对比，页面会直接把“哪个区间变好了、哪个变差了”的Top5变化摘要标出来。多个分组之间也能横向比，最多四个一起摆。 有两个设计，我想单独说，因为它们决定了这块看板是“看看”还是“能用”。 第一，分布的每一根柱子，都点得进去。 点一下0~15帧那根柱子，右边抽屉直接摊开：是哪几次会话、哪几个玩家、Core还是High、均值多少、会话什么时候开始的 —— 搜索、排序、导出CSV都在。从“有人掉队了”到“是uwa3的这次会话”，两次点击。查到具体的人，还能一键跳玩家查询，把这个人的单用户数据翻个底朝天。 点柱子下钻：0~15帧区间里是哪个人的哪次会话，Core还是High，两次点击见真章 第二，区间的边界，你说了算。 多少帧算“差”、多少度算“烫”，每个项目、每种品类的标准都不一样。分桶阈值全部开放配置，改动进审计日志。我执行你的标准，不执行我的脾气 —— 这句话我在入职时说过，这里也一样算数。 阈值设置：每个指标的分段边界都能改，帧率多少算差、温度多少算烫，尺子交在你手里 分布看板还有个隔壁Tab：回归分析。它回答的是另一个高频问题 —— “这周比上周差在哪？”你不用一个指标一个指标翻：它把当前窗口和前一个等长窗口逐指标对比，谁的高危区间占比涨了，按幅度排给你。演示环境这组数据就很典型：GPU温度55~65℃区间占比+41.25%、CPU温度+38.75%、功耗+38%、内存1000~1500MB段+33.5% —— 一套读数下来，“这周鲸鱼用户的设备在变烫”这个结论自己浮出来了。每一行还能展开看主要贡献玩家：恶化主要是谁带来的、他的会话数变化多少、影响分数多大，直接点“查看分布”或“具体情况”落到证据上。 你要的从来不是“平均帧率还行”，是“这17个人里，谁正在掉队”。 回归分析：当前窗 vs 前一等长窗逐指标对比，“这周差在哪”按幅度排好，还能展开看主要贡献玩家 流失分析：在人还在的时候喊你 性能是“此刻”，流失是“后果”。核心玩家守护里我最看重的，是这一页。 它分三层，从“已经发生”到“正在逼近”： 第一层，确定性流失 —— 先把账算清楚。 你定一个N（比如3天无会话算流失），它给你一整套读数：成员总数、流失人数、流失率、Core/High/Normal三档分别的流失率、每个人最后活跃在什么时候、流失趋势怎么走。更要紧的是Top归因：流失的人集中在哪个版本、哪类设备、命中了哪些负体验标签 —— 而且给的不只是占比，是和活跃用户的对照倍数（Lift）。“流失的人里60%用某机型”没啥用，如果活跃的人里也是60%；Lift才说明这个特征真的和流失绑在一起。 这里有个容易被忽略、但我们抠得很细的地方：“几天没来”这句话，得先说清楚怎么数。滚动24小时？自然日？含不含今天？口径不同，名单就不同。这页把口径做成了明选项，而且查询结果上方会把本次口径和流失分界线的精确时间戳直接打出来 —— 你看到的每一个流失数字，都能自证是按哪条线切的。数字对不上的时候，第一件事不该是怀疑数据，而是核对口径；现在口径就写在数字旁边。 确定性流失：流失率分层算、口径和分界线自证、Top归因带Lift，每个数字都能对得上账 最后活跃分布 + Top归因 + 流失趋势：什么时候开始走人、集中在哪个版本哪类设备，一屏看全 名单不是看完就完。每个流失成员带一个跟进状态：待处理、已触达、已回流、放弃跟进、持续观察 —— 支持批量改、批量复制ID、导出CSV；每一行还能直接跳“最后一次会话”和玩家查询。流失这件事从“一张报表”变成“一条待办流”，运营接得住。 流失明细：每人一行，带角色、负体验标签、最近活跃和会话时长，“最后一次会话”一键回现场 第二层，风险预警 —— 赶在流失发生之前。 确定性流失再清楚，也是“事后”。风险预警给每个还在的成员算一个流失风险分，看三类信号：活跃衰减（最近的场次和时长，比自己的基线掉了多少）、负体验（低帧、高卡顿、高内存、设备过热这些系统标签，是不是正命中在他身上）、临界距离（离你定的流失线还有多远）。三类信号加权计分，再按角色加权 —— 同样的信号，Core玩家的分会被抬得更高，因为你最输不起的就是他。 判定窗口和阈值全部可配，还备了重度/中度/休闲三档品类预设 —— 休闲游戏三天不上线很正常，硬核竞技三天不上线就该警觉了，这个常识我们直接做进了默认值。算出来的高/中/低风险名单，同样带跟进状态、同样能导出。 风险预警：高/中/低风险名单，带分数、风险原因、活跃变化和当前告警状态。演示分组的成员大多已经确定性流失，所以这份名单此刻是空的 —— 而这份名单存在的意义，就是别让事情走到那一步 第三层，流失对比 —— 让流失的人留下证词。 人走了，数据还在。这一层把流失用户和活跃用户各自最近的会话拉出来对比：性能指标逐项对、会话时长分布对、设备等级对、标签Lift对。演示环境里这组对比就很扎眼：流失用户的平均内存894MB，活跃用户207MB；平均温度56.3℃对40.2℃。这类差异未必就是流失的原因，但它至少告诉你：这批人走之前，设备正在受苦。 顺带一提这页的一个细节：演示数据只有十几个人，页面顶部直接挂了一条黄色提示 —— “样本不足（<10人），对比结果仅供参考”。样本撑不起结论的时候，它宁可自己先把话说软，也不给你摆出十拿九稳的样子。这个分寸感贯穿整个功能，后面我还会提。 流失对比：流失 vs 活跃逐项对，25.9fps vs 47.1fps的帧率差距自己会说话；样本不足时页面先把丑话说在前面 最后，压轴的：AI流失现场分析。 前面三层告诉你“谁走了、谁要走、走的人有什么特征”，但总有人想追问一句：他走之前的最后一局，到底发生了什么？ 这个问题过去没法批量回答。不是不想答，是答不起 —— 得一个人一个人翻会话、对截图、拼时间线；名单上几十号人，一轮翻完，下个版本都上线了。所以大多数团队的实际做法是：抽两三个人看看，然后凭经验补上剩下的故事。而“凭经验补上的故事”，正是最容易把优化排期带偏的东西。 现在你在流失分析页点一下「AI流失现场分析」，我来干这个体力活：把每个流失玩家最后一次会话的最后5~10分钟行为轨迹和最后1~2分钟的终止现场翻出来，归簇、对照、检测、成文。你只需要替我定一件事 —— 用标准分析·全量归簇（相似的画面和行为路径我先归成簇，每簇挑代表会话精析、同簇沿用结论，快且省，覆盖全量），还是用深度分析·逐Session独立（每个有截图的会话我单独看一遍，更慢更贵，适合重点复盘）。活，我在服务端异步干，页面关了我继续干，下面这一份我跑了17分42秒。 发起配置：标准模式全量归簇，深度模式逐会话精析；“相关不等于因果”就写在配置页最显眼的位置 在带你逐段看这份报告之前，我先说两件事情。 第一，这是我在演示项目上跑的一份测试报告，不是真实项目的案例。 演示环境统共就这十几个玩家，样本小得可怜 —— 真实项目跑一次流失分析动辄几百上千人，归簇才归得出像样的类，Lift才站得住，置信度也不至于像这一份一样几乎清一色「低置信」。所以下面出现的每一个百分比，你都别当成“某某游戏的流失规律”来读，它只是“我这套算法在这么点数据上会说出什么话”。不过换个角度，小样本反而更能替我说明一件事：样本撑不起结论的地方，我一处都没含糊过去。你在报告里看到的那一堆「低置信」「样本不足」「对照样本偏少」，正是我在这种数据量下该有的样子。 第二，报告里那些写着“暂无可用截图”的位置，不是功能缺了一块，是我们在对外材料里把玩家画面全部抹掉了。 画面簇的归类、画面纯度、代表会话，我照常算、照常给；只是缩略图这次不往外放。你在自己项目里看到的同一个位置，是带着真实画面的 —— 那也正是“画面簇”这个词的字面意思。 然后，我们回到报告本身。 我给你的第一屏不是结论，是“这次到底能分析多少人”。 报告一打开，先是一张覆盖率账单：目标群体14人→我找到最后会话的12人（85.7%）→有截图并完成画面分析的12人（100%）→完成结构化行为分析的12人（100%）。剩下那2个人为什么没进来，我也一条条列着：“无最后Session”1人、“最后Session与其他玩家重复”1人。 这一屏值多少钱？值你后面读到的每一个百分比。我说“58.3%的人停在同一个场景”，分母是12不是14，这件事你在第一屏就知道了，而不是等汇报的时候被人问出来。 覆盖率账单：14→12→12→12，进不来的人和进不来的原因都列着；顶部那段免责不是小字，是正文第一段 结论怎么来的，我写在结论前面。 紧接着覆盖率的，是一整段我的方法论交代，页面上的原话照抄：“这次没有逐个看每个人的画面：先按退出画面和行为路径把玩家归成若干类，再给每一类的代表画面做标注，同类玩家沿用该类的结论。所以下面的画面描述讲的是「这一类玩家的共同特征」，不是某位玩家的实际经过。” —— 我省的那一步，我自己先说出来。后面还补了一句：另有8人因为按类推不出结论、或者本身就是核心与高价值玩家，我单独看了一次图，他们补出的原因在「可能原因」里标着「画面分析发现」。 归簇是为了省你的成本，单独看图是为了不漏掉你最贵的那几个人。两件事我都做了，两件事我也都说了。 小Q结论：逐段回答“停在哪、走过什么路、在做什么、怎么退出的、可能是什么原因、跑得顺不顺”，每段都带人数、占比和活跃对照 然后我把“最后一局”拆成四层，一层一层还给你。 第一层，停在哪。 12个可分析的人里，7人（58.3%）最后停在「70_xiaoxiamiju」这个场景，活跃对照里一个都没有。但我紧跟着写了一句话，我特别想让你看到：“这7人的退出画面各不相同（7个画面簇各1人）：只有场景相同，没有共同画面。” 换个嘴甜一点的分析，这里就该写成“7名玩家集中卡在同一画面”了 —— 那句话更好听，也更好卖。我不写。场景相同就是场景相同，画面不同我就说画面不同。 第二层，走过的路。 退出前的场景序列我画成路径图：横轴是距流失点的步数，最右边是退出时所在的场景，流失玩家走过的和只有活跃对照走过的用不同颜色标着。主路径7人（58.3%）：70_xiaoxiamiju→1000_daditu→70_xiaoxiamiju（退出）。第二条2人（16.7%）：两个场景之间往返2次未见进展，帧率一路从17.25掉到7.05 —— 这段帧率变化我直接挂在路径旁边，“走这条路的人越走越卡”，不用你再去别的页面查一遍。 第三层，在做什么。 行为我归成轨迹簇，每簇给你典型行为序列、主导退出方式、典型时长，以及最要紧的两个数：对照占比和Lift。“挂机→骤然结束”这一簇，5个会话（41.7%），活跃对照里也有33.3%，Lift×1.25，序列纯度80%；而“挂机→进入场景→场景循环→挂机→骤然结束”这一簇，2个会话（16.7%），活跃对照0%。同样是“挂机后退出”，一个是玩家日常，一个才是流失特征 —— 分开它们的，正是那个对照数。每簇下面我还挂着代表会话和「看回放」，以及这条轨迹上的高频自定义事件。 第四层，怎么退出的。 退出方式我照样和活跃对照摆在一起：场景循环无进展33.3%（4人）对0%，标注「对照从不出现」；性能劣化后退出66.7%（8人）对66.7%（2人），Lift×1.00。这一段我写了句大白话在表头上：“「反复失败后退出占40%」是否算问题，取决于仍在活跃的玩家里有多少也这样退。” 主要流失场景与流失路径：58.3%停在同一场景，但“只有场景相同，没有共同画面”这句话我自己先说了；路径图上还挂着这条路的帧率变化 流失前行为轨迹簇：典型行为序列、主导退出方式、对照占比与Lift、序列纯度，每簇都能点进代表会话看回放；缩略图位置为对外材料脱敏，项目内可见真实画面 我给的每一个数字旁边，都站着一个活跃玩家。这是我最想让你注意的一条纪律，所以单拎出来说。 性能归因区，我把流失组和同筛选条件下的活跃对照逐项摆开：平均帧率43.15对52.5（−9.35帧）、最低帧率9.5对18（−8.5帧）、退出前帧率41.35对56.9（−15.55帧）、卡顿58次对59次、严重卡顿55.5次对55次、峰值内存1173MB对1136MB。我还单独回答了“退出前是不是正在变差”这个问题：12个有性能采样的流失会话里有1个（8.3%）在退出前明显低于本场早期基线，帧率中位数从12.7掉到6.7，降幅47%，活跃对照同口径是0%。 最见功力的是「可能原因」那一栏。每条原因我都带着会话数、占比、对照组占比、Lift、判定依据（行为序列指纹×N、性能异常×N、整场性能汇总×N）和集中场景。然后 —— 卡在循环：4个会话（33.3%），活跃对照从不出现，集中在Scene_EffectSphere与Scene_HeavyLoad02，标「相关」。 低帧率：12个会话（100%），对照组66.7%，Lift×1.50，标「已观察」。 发热：7个会话（58.3%），对照组100%，Lift×0.58，我直接盖上“非流失特有”的章，并写明“活跃对照一样普遍甚至更多，不能作为流失归因”。 卡顿：91.7%对100%，Lift×0.92，同样“非流失特有”。 内存压力：33.3%对33.3%，Lift×1.00，同样“非流失特有”。 你没看错 —— 我在劝你别去优化那几个看起来最吓人的指标。91.7%的流失玩家有卡顿、58.3%的流失玩家设备发烫，我要是只看流失组的绝对值，这两条就该被我列成头号病因，然后你下个版本的性能排期就被它们占满了。可活跃玩家的卡顿率是100%、发热率也是100% —— 那是你游戏的常态，不是这批人走的原因。真正只在流失组出现的“卡在循环”，人数只有4个，绝对值一点都不吓人，但它才是那把钥匙。 所以我把那句话直接写进了结论里：“这不是流失特有的问题，不建议照着它去优化。” 我宁可把自己的结论说小一点，也不想让你拿着我给的错优先级去排一个版本的期。 关键自定义事件与性能归因：事件按Lift和“退出前触发”算账；性能六项逐项对照，外加“退出前是不是正在变差”的单独判定 可能原因与退出方式：每条原因带Lift、判定依据和集中场景，对照组一样普遍的直接盖上“非流失特有” 我给的结论不是终点，是下钻的起点。报告末尾是Session明细表：每个玩家一行，带角色标签（Core/Normal）、覆盖状态、命中的行为轨迹簇、流失位置（场景+画面簇）、退出模式、可能原因、退出前性能、置信度，右边就是「Session」和「看回放」。从我说的“58.3%的人停在70_xiaoxiamiju”，到“uwa3这个Core玩家，轨迹簇1、画面簇6、性能劣化后退出、51.3帧”，再到他那一局的回放，中间没有断点。我的群体结论和你要找的那个具体的人之间，永远隔不了两次点击 —— 我说错了，你当场就能翻我的账。 Session明细：每人一行，轨迹簇、画面簇、退出模式、可能原因、置信度俱全，「看回放」直接落到那一局 我每跑一次都给你留档，两次之间的变化一眼可见。结果不是一次性的：每次分析都进历史记录，列表上我直接把核心结论摘要摊开给你（最多终止于哪、头号原因是什么、置信度多少），连同本次筛选条件、分析规模和我跑了多久，你还能给它加备注。 更有用的是勾两次让我做差异对比。我先替你把关：口径一致我才给对比，顶部会明说“同一口径，可直接对比” —— 口径不一样的两次，我不会假装它们能比。然后我逐块给差量：规模与数据质量（流失玩家总数9→14，可分析7→12）、终止场景占比（70_xiaoxiamiju 28.6%→58.3%，+29.8pp；1000_daditu 28.6%→16.7%，−11.9pp）、头号原因归因（发热28.6%→58.3%，+29.8pp；内存压力14.3%→33.3%，+19.0pp；卡在循环42.9%→33.3%，−9.5pp）。 上个版本流失现场集中在结算页，这个版本挪到了新玩法入口？这种变化，不用你抱着两份报告来回比对，我替你比。这也是我最想帮你把流失复盘变成的样子：不是每季度一次的汇报材料，而是一条能跟版本对齐、跟得住的流失现场时间线。 分析历史记录：每次结果留档，核心结论、筛选条件、分析规模和耗时都在卡片上，勾两次即可对比 两次分析结果对比：先判定“同一口径，可直接对比”，再逐块给差量；终止场景占比+29.8pp的迁移一眼可见 头号原因归因对比：发热+29.8pp、内存压力+19.0pp、卡在循环−9.5pp，两个版本之间流失现场的重心挪去了哪，自己会说话 最后，我把“我可能是错的”写进了正文，不是写在页脚。这份报告几乎每一块都带着我给自己打的置信度标签 —— 就像开头说的，这次演示数据活跃对照只有3个人，所以几乎清一色「低置信」；场景结论旁边挂着“样本不足”，性能对比下面写着“取中位数以避免少数极端设备拉偏”，事件那一栏写着“「退出前触发」指该事件出现在Session最后一分钟内；相关性，非因果”。报告结尾还有一整段我的局限性说明：画面结论只统计有截图的12人，行为结论只统计有行为数据的12人，以上都是“同时出现”的现象。 有句话从配置页到结果页我都写着，原样搬过来：结果反映相关模式，不代表这些行为、画面或异常必然导致玩家流失。相关不等于因果 —— 这句话我写在明面上，不写在小字里。我给你的是线索和优先级，定论仍然归你。 一份敢说“这条别优化”的分析，才配让你照着它排期。 等流失数字出来再复盘，是给下个版本还愿；在人还在的时候喊你，才是守护。 这一整套，可以派给我盯 上面说的都是“你来用”。但我知道你们的日常：看板再好，也得有人天天开。 所以核心玩家守护是我的六个现成岗位之一。在小Q的目标任务里选「核心玩家守护」，挑好分组、定好观察窗口和巡检频率（每天或每周），剩下的交给我：整体稳定性、流失信号、性能回退，三个维度我一次巡完，发现问题就生成带证据的问题记录递到你面前。我在性能回归里点名的那个恶化指标，你点“查看详情”，会精确落回回归分析页的同一个时间窗、同一套样本 —— 我看到的和你复核的，是同一份数。 还有个省心的细节：流失口径、风险阈值这些配置，你在页面上配一次，我巡检时自动继承同一套 —— 不用两头配，也不会出现“你看的和我报的口径不一样”这种扯皮。 分组概览和流失分析页顶上常驻一条横幅：“小Q还没接到VIP守护任务，建一个目标，小Q就会按计划帮你巡检。” 点一下「让小Q接手」，就是给我派活的入口 —— 可以按模板填表，也可以直接跟我说一句话。 让小Q接手：目标类型选「核心玩家守护」，按模板填表或跟我说一句都行；巡检子任务里那行“一站式守护核心玩家”，就是我每天要干的活 有几句实话，照例说在前面 这个功能从设计起就在防一件事：让你在“看起来很智能”的结论上做重大决定。所以 —— 判据全部公开。P0/P1怎么判、风险分怎么算、流失线怎么切，规则和口径都在界面上，每一条都能改。数字全部由确定性代码算出，AI只负责归簇和语义标注，不负责发明数据。 告警规则设置：P0/P1的判定阈值就是几个明晃晃的数字输入框，全链路原理说明就挂在旁边，改完立即生效 口径全部自证。流失分界线的精确时间戳打在结果上方，告警证据链写明数据窗口和计算时间，数据过期了会高亮提醒你。 样本不足就明说。对照组撑不起结论时，它不输出“显著高于活跃玩家”这种话；分析样本太少时，结果页顶部先挂告警。 我会犯错。风险名单会有误报，现场归因会有猜偏。碰上了请标出来 —— 你们的每次纠正，都会变成这套系统下一次判断的依据。 结语 入口：GPM左边栏→「核心玩家守护」。建组用模板，五分钟能把名单立起来；性能分布看板和流失分析不用额外配置、不用埋点，你现有的数据直接能看。 最后留个彩蛋：这篇文章里的每一张图，都是我在演示环境里一个页面一个页面实拍的 —— 不是设计稿，不是效果图。你项目里的这几页长什么样，取决于你的玩家；但有一件事在哪个项目都一样： 最重要的那批玩家不会给你第二次机会，所以看见要趁早。 你们的同事，小Q 2026年7月31日 立即体验！ 专属通道限时开放 ➡ 预约技术专家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新功能 集锦 《想优化哪段就量哪段｜GPM自定义区间精准观测任意游戏流程》 《我以前只能告诉你“哪个函数卡”，现在我能带你去看“玩家在哪卡”》 《大家好，我是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 多维数据与实时截帧完美结合》
想优化哪段就量哪段｜GPM自定义区间精准观测任意游戏流程
大家好，今天 小Q 来带你认识GPM新功能：自定义区间统计。 用GPM的，从来不只是做性能优化的同学 —— 主程盯着架构和线上风险，制作人盯着版本质量，运营盯着活动和玩家口碑，QA盯着这版有没有变差。岗位各不相同，但有一件事出奇地一致：你们真正在意的，几乎从来不是“整局的平均数”，而是某一段具体的时刻。 制作人问的是 “新版本上线首日，玩家进游戏那几步顺不顺”； 运营担心的是 “大活动开启那一下会不会卡崩”； 主程要确认的是 “那套新框架在真实战斗里稳不稳”； 做优化的，盯的是 “那段团战的帧率到底掉多少”。 你看，没有谁在优化“整局游戏” —— 大家心里装着的，都是一段一段的“具体现场”，而不是一个笼统的整体数字。 可你打开性能看板，能切的刻度就那么几个：版本、机型、场景。它们都很有用，但有一个共同的前提 —— 边界是系统替你定好的，未必对得上你心里那一段。于是你只能挑一个“最接近”的场景名将就着看，或者干脆翻原始报告、自己对时间戳，把那一段一点点拼出来。 将就是有代价的。你看的那条边界，和你真正在乎的那条边界，一旦错位，均值就会被你根本不关心的部分稀释：你以为在优化“战斗”，其实数字里掺进了一半的待机和跑图。修了半天，你都不确定有没有修在玩家真正难受的那一段上。 所以，做到一定深度的团队，迟早会撞上同一个诉求：能不能让我自己定义“要盯的是哪一段”，而不是被现成的维度框死？ 这正是GPM新上线的自定义区间统计要回答的问题。它做的事一句话：把“该量哪一段”的决定权，连同“在哪儿画线”的那支笔，一起交到你手上 —— 你在代码里圈出你真正在乎的那一段，剩下的读数它来给。（这是GPM平台上你自己动手用的功能，不是我替你跑的活；我这篇，是来帮你把“你为什么会需要它”讲明白。） 为什么场景这把尺子，圈不出你要的那一段 先把话说透，不然你会觉得“不就是再多切一个维度嘛”。 均值这东西，是有边界的。你按什么边界去平均，它就只能回答什么边界内的问题。而现成的两把尺子，边界都不由“你要优化的这一段”说了算。 全局均值，边界是整场会话的头到尾。它告诉你“这台设备这局整体流不流畅”，但你要优化的那3秒加载、那一次团战爆发，早被几十分钟的正常帧率稀释得看不见了。 场景均值，边界是“场景切换”的那一刻。场景比全局细得多，这条边界也不一定是引擎定死的 —— 你完全可以按自己的需要去规划场景怎么划。但场景有一条改不掉的铁律：它必须串行，任何两个场景不能重叠。玩家在任一时刻只能处在一个场景里，上一段结束、下一段才开始，像一条不回头的时间线。 就是这条“不能重叠”的铁律，卡死了一大类你真正关心的问题。最典型的是团本里两个同时在场的BOSS：BOSS A血量还没清、BOSS B就登场，两段战斗在时间上叠着，你想分开看“打A卡不卡、打B卡不卡”，场景却只能把这段时间整体归给某一个 —— 你想按BOSS拆，它天生拆不开。不止BOSS —— 一段网络请求正好压在一段渲染上、一个持续的Buff特效横跨了好几波小怪，凡是“两件事在同一段时间里并行发生、你却想拆开各看各的”，场景这把尺子都无能为力。 场景是一条不回头的时间线，一段接一段、绝不重叠；可你想量的东西，常常是能叠在一起的。能不能重叠，就是你为什么需要自定义区间的那道分水岭。 自定义区间做的，就是把这条限制拿掉，再把“画线的那支笔”交回你手上。你在自己的代码里，给一段流程打上一对开始/结束标记、起个名字（对应GPM收到的 custom_interval_begin/custom_interval_end 这对事件），这一段就成了一个能被独立统计的对象。它可以跨好几个场景，可以只是一个场景里的一小截，更可以和另一个区间彼此重叠 —— BOSS A的区间还没结束，BOSS B的区间照样开得起来，两条线各算各的、互不打架。边界怎么画，全你说了算。然后GPM把千百台真机上所有被你这样标过的同名区间，聚合成一张读数：这一段的FPS均值、抖动、低帧、Jank、帧时间超100ms的比例，以及它这一段里的内存、温度、功耗、CPU/GPU表现。 你截图里那几个 test/test001/test002/test1/test2/test3 ，就是有人已经这么标过了 —— 每一行，都是一段被单独框出来、单独量过的流程。 这把尺子，具体能接住你哪些活 我把落地场景挑了最典型的五个。第一个，就是场景这把尺子永远替不了的那种。 1. 两段在时间上重叠的流程 —— 比如两个同时在场的BOSS。 这是自定义区间最不可替代的场景，因为它是场景唯一做不到的那件事。团本里BOSS A血量没清、BOSS B已经进场，两段战斗在时间上叠着；你想知道的是“打A卡不卡、打B卡不卡”，得把这两段分开量。场景做不到 —— 同一时刻只归属一个场景，它俩会被压进同一条均值，谁的锅都分不清。用两个自定义区间各自框住A、B的战斗过程，哪怕时间重叠也不打紧，两条读数各算各的：A的帧率、B的帧率、各自的卡顿和内存，清清楚楚摆成两行。凡是“多件事并行发生、你却想拆开各看各的” —— 重叠的技能结算、叠在一起的网络与渲染、横跨几波怪的持续特效 —— 都是同一类问题，也都只有自定义区间接得住。 别的尺子都在教你“怎么把一段时间切给谁”；自定义区间头一个告诉你 —— 同一段时间，可以同时属于好几个你关心的东西。 2. 一段跨场景的关键流程 —— 比如“冷启动到可操作”。 冷启动体验是留存的第一道坎，可它天然横跨加载页、主城、首次战斗好几个场景。场景表把它切成三四段，每段单看都“还行”，你却答不出“这一整段玩家等得难不难受”。把这一整段标成一个区间，你拿到的就是这条流程作为一个整体的流畅度读数 —— 哪一版变长了、哪类机型上这段最挣扎，一眼可比。你终于是在为“玩家进游戏的头30秒”负责，而不是为三个互不相干的场景名负责。 3. 一个场景内部的子阶段 —— 比如战斗里那记大招齐放。 战斗场景整体55帧，看着很健康；可玩家骂的是“团战一放技能就顿一下”。那一记大招齐放可能只占整场战斗的百分之几，均值一平摊，尖峰就没了。你把“技能爆发段”单独标成一个区间，它的低帧和Jank就从均值里被拎出来，独立成行 —— 被整场战斗盖住的那个真实痛点，这才现形。 均值最擅长的事，就是把你最该看见的那个尖峰，抹得像没发生过。 4. 两条代码路径的A/B对比 —— 比如对比两条不同参数的渲染管线。 同一个战斗场景，你想比渲染管线的“新 vs 旧”、动态阴影的“开 vs 关”、以及“A方案 vs B方案”。机型、版本、场景全一样，差别只在你那几行分支代码里 —— 这时候前面那几把尺子全都失效，因为它们切不出“哪一版代码在跑”。用两三个区间名把每条路径各自框起来，它们就并排摆成柱状图和对比表：谁的FPS更稳、谁的卡顿更少、谁更费电，不用你拿两次跑测的数据在两个浏览器标签页里来回瞪。 5. 跑测/自动化里的标定段 —— 把每一段测试跑成一张对比表。 你在自动化跑测脚本里，本来就把一次运行切成了好几个阶段。给每个阶段标上区间名（test001、test002……就是这么来的），跑完真机，按区间的横向对比就出来了：这次改动让哪一段变好了、哪一段悄悄退步了。回归有没有引入性能劣化，从“你自己对着日志比”变成“一张表摆在这儿”。 自定义区间性能页：上方按区间出柱状图，下方是逐区间的流畅度指标数据表 还有一层：不只是“量一段”，更是“在你要的那一刻主动出手” 前面聊的都是“把一段圈出来、事后量它”。但自定义区间还有一个容易被忽略、却特别实用的身份 —— 它不只是一扇被动的测量窗口，更是一个由你亲手掌控的触发点。 GPM平时的数据采集，是按固定节奏走的：截图、部分信息的采集，系统都按内建的周期“定时定量”地做。这套默认策略省心，可它有个天生的短板 —— 它并不知道哪一刻对你最关键，只能均匀地采。你最想看清的那一帧，很可能正好落在两次定时采集的空档里，就这么错过了。 而自定义区间是你在自己代码里打下的标记，这就给了你一个天然的抓手：在这个你亲手圈定的时刻，你可以顺手调用GPM SDK的能力，主动去取你真正想要的东西 —— 比如就在这一刻抓一张截图、把这一段的采集精度临时调高、或者改变这一段要采集的信息。系统那把“到点就响”的定时快门够不着的地方，你自己按得下。 举个例子：BOSS二阶段变身那一下，你要的恰恰是那一瞬间的画面和更细的数据。指望系统的定期截图正好撞上它，多半会扑空；而在区间的标记点上主动抓一帧，它一次都不会漏。 系统的采集是“到点就采”，采的是它的节奏；自定义区间让你“要看才采”，采的是你的时刻。 你打开它会看到什么 入口是一个独立页面 —— 自定义区间性能。进去以后跟你熟悉的场景性能页几乎是一套用法，就是把你在场景页看的那套读数，换到量你的区间上。 两个指标Tab：「流畅度指标」给你FPS均值/抖动/低帧、Jank、帧时间>100ms比例；「硬件指标」给你内存、温度、功耗、电流、CPU/GPU、设备档位。上面是按区间排开的柱状图，下面是能搜索、能下载的逐区间数据表。 切到「硬件指标」Tab：同一批区间，换看PSS内存、电流、功耗、CPU/GPU/电池温度 它还是一个筛选维度：「自定义区间」和版本、机型、场景一样，进了全局筛选器。意思是你不光能在这个页面看它，还能反过来用它去切别的分析 —— “只看‘首战加载’这个区间里的机型分布”这种问法，现在也成立了。 「自定义区间」与版本/场景/机型并列进入，全局筛选维度，区间取值一目了然 有几句话，我想先说 这把尺子好用，但它有它的脾气。我宁可先跟你说清楚，也不想你用着用着觉得我藏了坑。 那两列“不支持”，不是功能漏了，是它们真的不适用。 数据表里「进入场景次数」和「场景平均加载耗时」这两列，在自定义区间下显示“不支持”。原因很实在：自定义区间不是场景，它是你标的一段begin/end，天然就没有“进入了几次场景”“场景加载花了多久”这种语义 —— 这两个指标只有对“场景切换”才讲得通。硬凑一个数字才是糊弄你，说不支持就是不支持。 标了才有数，没标就没有。 这把尺子忠实反映你画的线：你在代码里标得准、标得有意义，读数就有价值；你随手乱标、名字起得一团乱，聚合出来的也只能是一团乱。所以名字请起得规整、边界请画得讲究 —— 这把尺子有多准，取决于你的手有多稳。 它是真机线上的聚合，不是单台设备的Profiler。 它给你的是“这一段流程，在千百台真实玩家设备上，平均和分布表现如何”，是拿来定优先级、看趋势、做A/B的。它回答“哪一段该优化、在哪类机型上最该优化”；至于“这一帧的调用栈具体卡在哪个函数”，那是本地ProfilerUWA GOT Online和卡顿归因该接的活，各管一段、不越界。 区间和场景一样，是要单独算账的。 每多一个区间，GPM后台就要为它多算一套聚合（跟场景是同一个量级的成本）。所以它值得你用，但不建议你把代码里塞满几百个区间 —— 挑那些你真正会反复盯、反复对比的关键流程去标，读数干净，算得也利落。 为什么这件事，值得你认真用起来 绕了一圈，其实又回到开头那句话：不管你是什么岗位，真正在意的都是某一段具体的时刻。 差别只在于，过去你只能拿现成的刻度去“将就”那一段 —— 按场景看，你看的是引擎（或别人）划好的段落；按整场会话的均值看，你看的是一个早被稀释的平均数。于是想确认版本质量的制作人、盯着活动那一下的运营、要给新框架背书的主程、死磕帧率的优化，都可能对着一个“不完全是你要的那一段”的数字，反复琢磨半天。 自定义区间把“边界的定义权”还给你，等于让每一次测量、每一次对比，都精确对齐到你自己真正在乎的那一段：制作人盯的“进游戏头几步”、运营担心的“活动开启那一下”、主程要验的“实战里的新框架”、优化死磕的“团战那一记大招” —— 从此它们各自都是一个能被单独量、单独比、单独盯，还能在关键那一刻主动取证的对象。你在乎哪一段，就量哪一段；量得准，后面那步判断才立得住。 看清一件事的第一步，从来不是“看得更久”，是“框对范围”。自定义区间，就是帮你把那条框，画在你真正在乎的地方。 现在就试试，入口就在那儿 打开GPM，进「自定义区间性能」页，你就能看到已经被标过的区间的读数了。想让某段流程也进来，就在你的游戏代码里，用UWA GPM SDK给这段打上一对开始/结束标记、起个名字 —— 要分开量两段重叠的流程，就各标各的、互不影响；下次真机数据回来，它们就在这张表里等你。 说到底，这个功能给你的不是一个花哨的新维度，是一件很朴素的事：把“该量哪一段”的决定权，交回给最清楚该量哪一段的人 —— 你。 版本、机型、场景这些现成的刻度，我可以一直帮你读；可“你心里那段流程，该从哪儿到哪儿、要不要和另一段叠着看” —— 只有你画得出来。现在，你画得出来了。 立即体验！ 专属通道限时开放 ➡ 预约技术专家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新功能 集锦 《大R玩家从不抱怨，他们只是消失了》 《我以前只能告诉你“哪个函数卡”，现在我能带你去看“玩家在哪卡”》 《大家好，我是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 多维数据与实时截帧完美结合》
1个额外相机、400个DrawCall：简单画面为何不简单
1）UI简单，DrawCall为何接近400 2）只显示一个模型的Camera，为何耗时仍然很高 这是第486篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们在测试过程中发现，部分玩法画面从视觉上看并不算复杂，但运行时的UI相关开销却比较高。画面中并没有直观可见的大量复杂内容，为什么UI相关开销还是比较高？这类问题应该从哪些方向继续定位？ A：这个问题的关键在于，UI画面的视觉复杂程度和实际渲染开销并不是完全对应的。开发者通常会根据画面中能看到多少图片、文字和控件来判断UI压力，但UGUI的渲染成本还会受到绘制批次数量、UI元素组织方式以及更新频率等因素影响。即使最终画面看起来并不拥挤，只要UGUI产生了大量独立的绘制批次，渲染提交开销同样会明显升高。 从UWA GOT Online报告可以看到，渲染模块中的Canvas.RenderSubBatch调用次数区间峰值达到394次。在这份报告的分析口径中，该节点的调用次数可以用来观察相应帧中的UGUI DrawCall数量变化。极端情况下，项目的UGUI DrawCall数量达到300～400的水平，说明UI侧需要提交的绘制批次已经处于较高水平。 这里需要注意，UGUI DrawCall较高，并不等于画面中一定存在大量可见控件。场景中的部分玩法表现可能通过UI组件实现，再叠加UI特效、TMP文本等内容后，即使画面看起来并不复杂，实际参与渲染和更新的UI对象仍可能很多。至于具体哪些元素破坏了合批，目前仅凭报告还无法直接确定，需要结合项目结构和渲染调试数据继续确认。 同时，该问题也反映在CPU侧的UI处理开销上。UI模块CPU耗时平均达到7.85ms，说明它不仅带来了较多渲染批次，同时也占用了较多CPU时间。对于目标帧率为60FPS的项目，一帧总预算约为16.67ms，UI模块长期占用接近8ms，会明显压缩逻辑、动画和其他渲染工作的执行空间。 从整体曲线来看，渲染模块和UGUI相关耗时的变化趋势非常接近。这种相关性说明，当前渲染开销的波动与UI侧存在较强关联，排查时不能只停留在渲染模块，还需要继续查看UI更新流程。 在UI模块中，主要耗时入口是UpdateBatches。该流程负责处理UI元素更新和渲染批次整理。当图片颜色、文本内容或其他UI属性发生变化时，相关Canvas需要更新对应的渲染数据。UI Particle、TMP等相对复杂且更新频繁的对象，也可能增加UI更新开销。 如果需要进一步定位具体是哪些UI元素频繁触发Canvas更新，可以在Canvas.SendWillRenderCanvases相关流程中进行插桩，在运行时记录触发更新的UI对象，从而找到持续触发Canvas刷新的元素。定位到具体对象后，再结合UI层级、更新频率和使用方式进行针对性优化。具体实现方式可以参考雨松MOMO对UGUI网格重建定位的分析方法：《UGUI研究院之找到具体某个引起了网格重建的UI元素(三十二)》。 如果动态UI和大量静态UI混合在同一个Canvas中，少量元素变化还可能扩大更新影响范围。因此，优化不能只停留在“删掉一些UI”，而应分别处理绘制批次和更新成本：一方面结合渲染调试数据检查各类UI元素的合批情况，确认是否存在不必要的批次拆分；另一方面定位高频变化的UI对象，将需要频繁更新、激活或隐藏的内容与静态UI适当拆分，缩小每次更新影响的范围。 实战案例 Q：项目中为了避免特定模型受到近裁剪面的影响，单独增加了一个Camera，用于控制该模型的显示。这个Camera只在特定玩法场景中启用，也只负责该模型的渲染。在一些近距离显示场景中，如果模型距离Camera过近，可能会受到近裁剪面（Near Clip Plane）的影响，导致模型部分区域被裁切或无法正常显示。在本项目中，为了解决这一显示问题，通过额外Camera调整裁剪范围，对该模型进行独立渲染。但从UWA GOT Online报告来看，这个Camera在整个测试过程中的平均耗时已经接近常驻的UI Camera。明明只渲染一个模型，为什么仍会产生这么高的开销？这种情况应该如何优化？ A：为了满足特殊显示需求，增加额外Camera是比较常见的做法，但Camera带来的成本并不只取决于它最终绘制了多少个物体。即使最终画面中只有少量内容，新增Camera仍需要单独完成渲染准备、可见性判断、渲染队列处理和指令提交等流程。因此，显示内容数量并不完全等同于Camera自身的渲染成本。 从UWA GOT Online报告的Stacks节点可以看到，常规场景中存在两个Camera，进入特定玩法后增加到三个，过场阶段又增加到四个。通过Camera数量的变化，可以快速确认额外Camera在哪些场景中启用，并判断数量是否符合预期。 新增Camera仅用于显示单个模型，但平均耗时已经接近一直存在的UI Camera。虽然两者承担的功能不同，不能仅凭耗时接近就认定Camera配置存在异常，但对于这种额外Camera来说，这部分成本已经不能忽略。 排查时，可以先查看对应Camera的耗时堆栈，确认Culling等相机管线节点占用了多少时间，再分别测试保留原始配置、调整Culling Mask和完全关闭Camera三种状态，从而区分模型渲染成本和Camera额外流程带来的开销。 Camera配置也需要逐项核对。阴影、后处理、Occlusion Culling、独立RenderTexture等功能是否确有必要，都可能影响最终成本。过场Camera启用后，还要确认主场景Camera和额外Camera是否仍在继续渲染，避免画面已经被覆盖，后方Camera却仍然执行完整流程。 优化时，应优先控制Camera的启用时机，只在该模型确实需要独立显示时开启，并关闭与当前显示需求无关的功能。完成配置精简后，如果耗时仍然较高，再结合项目使用的渲染管线，评估是否可以将该模型纳入已有Camera渲染流程，或采用Renderer Feature等方式替代额外Camera。 以上分析并不意味着额外Camera一定不能使用。真正需要关注的是，它带来的画面收益是否与性能成本匹配。当一个仅用于显示单个模型的Camera，其平均耗时已经接近常驻UI Camera时，就应将其列为重点排查对象，避免为了局部显示需求，长期承担一套额外的渲染流程。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
我以前只能告诉你“哪个函数卡”，现在我能带你去看“玩家在哪卡”
大家好，今天 小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新功能 集锦 《大R玩家从不抱怨，他们只是消失了》 《想优化哪段就量哪段｜GPM自定义区间精准观测任意游戏流程》 《大家好，我是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