UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - 厚积薄发
UWA旨在诊断与优化游戏和VR项目开发过程中潜在的运行性能问题，帮助项研发团队快速检测项目运行时的性能瓶颈，高效定位问题根源，及时反馈针对瓶颈问题的深度分析和解决方法。
1）小游戏RT内存为何偏高 2）Physics耗时为何居高不下 这是第490篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：同一套项目资源，APP侧RenderTexture占用只有30MB左右，但小游戏侧却达到130MB左右，为什么会出现这么大的差异？ A：排查后定位到两个明显的配置问题：小游戏侧3D渲染分辨率没有按预期缩小，同时Depth Buffer仍然使用32位格式。这两项都会增加RenderTexture相关的内存压力。 小游戏和APP端虽然运行的是同一套项目，但不同平台实际创建出来的RenderTexture配置可能并不一致。尤其是渲染分辨率、Depth格式以及MSAA配置，一旦平台侧实际生效情况不同，最终内存占用就会产生明显差异。排查时可以重点关注： 3D渲染分辨率没有正确缩小。 小游戏侧实际渲染分辨率较大，说明原有的降分辨率策略没有按预期生效。RenderTexture内存和宽高直接相关，如果3D场景仍然按照设备较高的原始分辨率渲染，Color和Depth相关RT都会随之增大。 APP端原本可以通过分辨率设置控制渲染尺寸，但到了小游戏环境，需要重新检查对应接口是否生效。对于URP项目，可以通过renderScale控制3D场景实际渲染分辨率，最终再放大到屏幕，而UI仍保持全分辨率显示。 Depth Buffer仍然使用32位格式。 小游戏侧当前使用的是32位Depth，而移动端原有配置使用24位。需要检查平台相关设置，将小游戏/WebGL纳入对应的Depth格式配置，减少这部分额外的内存占用。 确认MSAA配置。 当前RenderTexture配置中可以看到MSAA为2。MSAA会增加Color Buffer和Depth Buffer的存储开销，尤其是在RT分辨率较高时，需要保存更多采样数据，会进一步放大内存占用。排查RenderTexture内存差异时，需要确认小游戏侧最终生效的MSAA等级，避免平台配置差异导致额外的RT开销。 实战案例 Q：项目已经针对物理碰撞制作了简化模型，但低端机上Physics耗时仍然达到二三十毫秒。碰撞模型已经简化，为什么物理压力还是很高？ A：碰撞模型复杂度只是影响Physics耗时的一部分。如果Collider已经进行了简化，但Physics耗时仍然较高，还需要继续关注场景中同时参与模拟的动态对象数量，以及这些对象之间产生的碰撞关系。 从GOT Online报告中数据变化来看，随着场景中的物件减少，Physics耗时也同步下降，同时Overlaps数量明显降低。当前场景中的Overlaps已经达到几千级，说明物理系统需要维护大量潜在碰撞关系。排查时可以重点关注以下几个方向： 确认Physics耗时是否和碰撞关系规模相关。 可以减少部分动态物体参与碰撞，再观察Overlaps数量和Physics耗时是否同步下降。如果两者变化趋势一致，说明主要压力可能来自大量碰撞关系，而不是单个Collider复杂度。 检查动态Rigidbody和Mesh Collider组合。 场景中存在大量带Rigidbody的动态对象，其中部分使用Mesh Collider。对于动态非Kinematic Rigidbody，Mesh Collider需要使用Convex。形状允许的情况下，可以继续对比Box、Sphere、Capsule或者Compound Collider的实际效果。不过碰撞体替换最终仍需要结合项目实际测试结果，不能简单认为基础Collider一定更优。 减少无效碰撞检测。 如果部分对象之间没有实际碰撞需求，可以通过Layer Collision Matrix关闭对应Layer之间的Collision，减少不必要的碰撞组合。调整后可以继续观察Overlaps数量和Physics耗时变化。 检查物理模拟频率。 Unity默认按照Fixed Timestep执行物理模拟。当低端设备出现掉帧时，一个渲染帧内可能需要补做多次物理模拟，从而进一步增加Physics耗时。可以尝试调整Fixed Timestep，例如从0.02调整到0.03，对比Physics耗时以及实际物理表现。 Unity 2022以后，也可以测试不同Physics Simulation Mode带来的影响，例如切换到Update驱动，观察减少单帧多次物理模拟后能带来多少收益。不过这种方式会受到实际帧率波动影响，需要同时确认碰撞稳定性和运动表现是否满足需求。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
对象销毁了，为什么材质还留在内存里
1）Material Instance为什么持续增长 2）动态生成Mesh后，为什么Mono内存变高了 这是第489篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：项目运行一段时间后，Material Instance数量持续增长，从开始的100多个增加到2000多个，这是什么原因？应该如何排查？ A：这种情况可以先排查运行过程中是否不断实例化了新的Material。报告中可以看到，某类技能材质（Instance）从最开始的100多个逐渐增加到2000多个，而且持续出现新的实例。这类现象通常需要结合技能、角色、特效对象的生命周期一起看，确认运行过程中是否不断产生新的Material Instance。 Unity中访问Renderer.material时，如果当前Renderer使用的是共享材质，Unity会为它实例化一份独立的Material。这里不只是调用SetColor、SetFloat等修改操作需要注意，仅仅访问Renderer.material本身，就可能触发实例化。如果只是读取或继续使用共享材质，可以检查是否应该使用Renderer.sharedMaterial。 真正容易被忽略的是材质和GameObject的生命周期并不是完全绑定的。通过Renderer.material等方式在运行时实例化出来的Material，不会因为对应GameObject被Destroy就自动完成资源释放，需要对这类动态材质单独管理。 比如技能释放时创建了临时特效对象，同时实例化了一份材质。技能结束后GameObject已经销毁，但这份Material没有被显式销毁，下一次释放技能又生成新的实例，长期运行后就可能看到Material Instance数量从几百一路涨到几千。未被使用的材质也可能在Resources.UnloadUnusedAssets()等资源清理过程中被回收，但不应该把它当成高频动态材质的主要生命周期管理方式。 排查时可以从持续增长的材质名称入手，再对应技能释放、角色生成、特效播放等操作做复现。如果每执行一次操作，某类Material Instance数量都会增加，而对应对象销毁后实例仍然保留，就需要继续检查这条材质创建和销毁链路。 优化时可以重点关注两个方向： 减少不必要的Material Instance创建。 检查是否存在只为了读取材质却访问Renderer.material的情况；如果只是修改颜色、Vector等每个Renderer不同的属性，也可以评估MaterialPropertyBlock是否适用，减少独立Material实例。需要切换Shader或Shader Keyword时，则不能简单用MPB替代。 显式管理动态材质生命周期。 如果确实需要实例化Material，需要保留对应引用，并在对象生命周期结束时显式Destroy。对于高频创建的技能、角色和特效，也可以进一步检查是否能够复用材质，而不是不断创建新的实例。 实战案例 Q：项目地图区域原本直接使用MeshRenderer，但动态加载、卸载时会有卡顿，后来改为在脚本中保存顶点等数据，运行时再动态生成Mesh。UWA GOT Online报告显示Mono内存占用较高，如何判断是否和这部分数据有关？ A：可以先通过Memory Profiler确认Mono堆中的主要占用。如果其中存在大量顶点、矩阵等Mesh相关数据，再结合引用关系查看这些数据是否由地图区域相关脚本对象持有，就能进一步判断Mono占用是否和这套动态Mesh实现有关。 这里需要关注的并不是“动态生成Mesh”本身，而是为了生成Mesh，脚本侧长期保存了多少网格数据。例如C#侧保存了大量Vector3[]、索引数组或矩阵数据，这些内容会占用Managed Heap，也就是这里看到的Mono内存。随后再把这些顶点、索引数据提交给Mesh，Mesh自身还需要在Native侧维护对应的网格数据。 因此，如果Mesh已经生成，而C#侧的原始顶点、索引等数据仍然长期保留，就可能出现同一批网格信息同时占用Managed和Native两侧内存的情况。这里不能简单理解为固定的“双倍内存”，但确实需要同时关注两边的数据占用，而不能只看Mono。 排查时可以重点看： 确认Mono中的Mesh相关数据占了多少。 查看顶点数组、索引、矩阵等数据的数量和实际占用，再通过引用关系确认它们是否由地图区域相关脚本长期持有。 检查Mesh生成后，托管侧数据是否仍然需要保留。 如果网格生成完成后不再需要频繁修改，原始Vector3[]、List等数据就要确认是否还有长期保存的必要。如果没有其他引用，可以解除引用，让GC后续回收，而不是一直和Mesh同时保留。 检查是否存在多份数据。 如果原始地图区域数据、转换后的顶点数据、运行时Mesh和其他缓存同时存在，同一份区域信息就可能以不同形式占用多份内存。 此外还要关注动态Mesh生成过程中的临时分配。如果频繁创建新的Vector3[]、List等托管对象，会持续产生GC Alloc，Managed Heap扩容后也不一定会马上回落，因此会出现Mono内存长期维持在较高水位的情况。 Mono堆规模变大、需要管理的托管对象增多后，GC扫描和标记的成本也可能随之增加，因此这部分问题不仅体现在内存占用上，也可能进一步带来GC耗时。 如果项目使用的Unity版本和现有架构允许，还可以继续评估NativeArray、Mesh.SetVertices(NativeArray)，以及MeshDataArray、Mesh.ApplyAndDisposeWritableMeshData等方式，减少动态网格构建过程中对Managed Heap的依赖。不过这属于进一步的实现优化，排查时仍然应该先确认：现在到底是哪一批数据占用了Mono，它们为什么还被脚本长期持有。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
流式加载后，为什么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
【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
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
【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
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）