UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技
UWA博客，UWA旗下官方博客。针对游戏及VR应用性能优化等内容记录、交流，以及案例分享。博客主要分为：厚积薄发、万象更新、博观约取、充电一刻、博物纳新等几个模块。
系统层显存 vs 引擎层显存的差值从哪来
1）系统层显存 vs 引擎层显存的差值从哪来 2）如何定位String的异常增长 这是第470篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们项目中通过系统层统计到的显存有1.5GB，但是通过引擎层采集到的纹理、网格、RT这些加起来也只有400+MB，为什么会这样，其它的部分是什么呢？ A：这个其实是一种比较少见的情况，根据以往的经验来说可能有以下几种可能性： Shader占用本身是属于引擎层Native部分的，在运行中会执行Shader.CreateGPUProgram把它编译成GPU可以理解使用的一个Program，生成的这个GPU Program在一些项目中可能造成相当可观的高占用，我们在过往部分极端测试中也发现，它会同时导致系统层Native内存与GFX显存出现显著上升。 测试发现一些RT除了在Memory Profiler中统计到的部分外，也会对显存造成一些额外的开销。 在其它项目中还见过使用Compute Buffer，或者在引擎设置里面开辟一些额外的Buffer，通过C++进行加载的，也会对显存造成额外开销。 这些内存的具体来源是什么无法直接通过Unity引擎的分析工具进行分类和定位，建议可以做一些对比测试，比如去拆分一些渲染策略或场景，如果观察到开启某个渲染策略、进入某个渲染场景时显存出现明显的飙升，那么基本就能做更精准的定位了。 参考文章：《Unity移动游戏工业级性能优化指南 3.0》 实战案例 Q：我用Memory Profiler看到托管堆内存部分中String驻留了50+MB、45万多条，这个现象是正常的吗？ A：这一部分占用还是比较高的，从下图右边的Details信息看很多来自TMP图集用的文本，这种大量字符串理论上不应该驻留在运行时了，我们生成了TMP的图集纹理后像这个文本应该就不需要了，可以看看是不是可以及时卸载。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 物理系统与物理世界 物理系统是每个游戏引擎必备的功能，可以让游戏模拟真实世界的物理规则。而现代游戏引擎中的“物理”，应该称为刚体动力学。刚体（Rigidbody）是理想化、无限坚硬、不变形的固体物理。动力学（Dynamics）是一个过程，计算刚体怎样在力（Force）的影响下随时间移动及相互作用。 在游戏世界背后，有一个镜像的物理世界，其中不关心Actor的视觉表现，只关注Actor的形状、物理材质等信息。简单使用场景下，只要把所有Actor当成刚体处理即可。 以常见的StaticMeshActor为例。下图左侧是Editor窗口，右侧是Chaos Visual Debugger看到的物理世界。 UE5的物理引擎已经从PhysX改成了Chaos，可能物理查询速度会慢些，但更适合做物理破坏效果，而且数据结构和游戏引擎统一，不需要再做转换。 物理世界类 UWorld是代表由Actor构成的整个游戏世界，它持有一个FPhysScene来代表物理世界，可以类比为渲染中的FScene，物理世界的步进（Advance）初始也由UWorld::Tick()发起。UWorld与物理相关的功能一般在PhysLevel.cpp中实现。FPhysScene等价于FPhysScene_Chaos，继承自FChaosScene，是Chaos的首要入口。碰撞事件的注册分发，物理网络同步相关的内容也由其处理，物理模拟的步进也在该类开始（StartFrame）。 示例：创建一个StaticMeshComponent StaticMesh的物理配置 编辑器中，可以给StaticMesh设置碰撞，这里直接使用和模型大小一样的Box碰撞即可。还有其他很多选项，如Sphere、Capsule、凸包等，面对复杂模型时会用多种基础形状拼出一个碰撞。 UBodySetup 这些碰撞配置，最终会存储到UBodySetup类里，作为资源的一部分，它会是StaticMesh的一个配置项。对于例子中添加的简单几何体碰撞，存储在其AggGeom变量中。 class UBodySetup : public UBodySetupCore { //... /** Simplified collision representation of this */ UPROPERTY(EditAnywhere, Category = BodySetup, meta=(DisplayName = "Primitives", NoResetToDefault)) struct FKAggregateGeom AggGeom; //... }; AggGeom里是一大串几何体的数据，Box就位于BoxElems数组里。 struct FKAggregateGeom { GENERATED_USTRUCT_BODY() UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Spheres", TitleProperty = "Name")) TArray<FKSphereElem> SphereElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Boxes", TitleProperty = "Name")) TArray<FKBoxElem> BoxElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Capsules", TitleProperty = "Name")) TArray<FKSphylElem> SphylElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Convex Elements", TitleProperty = "Name")) TArray<FKConvexElem> ConvexElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Tapered Capsules", TitleProperty = "Name")) TArray<FKTaperedCapsuleElem> TaperedCapsuleElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Level Sets", TitleProperty = "Name")) TArray<FKLevelSetElem> LevelSetElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "(Experimental) Skinned Level Sets", TitleProperty = "Name"), Experimental) TArray<FKSkinnedLevelSetElem> SkinnedLevelSetElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "(Experimental) ML Level Sets", TitleProperty = "Name"), Experimental) TArray<FKMLLevelSetElem> MLLevelSetElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "(Experimental) Skinned Triangle Meshes", TitleProperty = "Name"), Experimental) TArray<FKSkinnedTriangleMeshElem> SkinnedTriangleMeshElems; }; UPrimitiveComponent UPrimitiveComponent为所有持有几何物体的基类，StaticMeshComponent也是它的子类。会创建一个FBodyInstance作为其在物理世界中的代表。无论是UStaticMeshComponent还是USkeletalMeshComponent又或者UCapsuleComponent，实际最终都会通过FBodyInstance与物理世界进行交互。此外还有UGeometryCollectionComponent等用于特定物理功能相关的Component，它们会持有额外的物理代理。 FBodyInstance 一个物理对象的实例，在Gameplay部分的表示，用于设置物理相关的各项属性。引擎代码注释中对FBodyInstance的定义如下： Container for a physics representation of an object 一些属性举例： TWeakObjectPtr BodySetup：关联的BodySetup，BodyInstance本身并不包含具体的几何形状。 FBodyInstance* WeldParent：一个BodyInstance所连接的父Body。 FPhysicsActorHandle ActorHandle：实际是FSingleParticlePhysicsProxy*，为物理引擎内部BodyInstance的呈现与代理。 FName CollisionProfileName：碰撞的ProfileName。 至此，StaticMeshComponent创建完毕，其包含了BodyInstance，并关联了BodySetup。 简单理解的话，StaticMeshComponent包含了Render和物理信息，而BodySetup+BodyInstance就表示物理信息。 物理对象与Shape 直观理解，物理世界就是大量的形状构成的。 物理世界如何表示"对象" Game世界使用Actor、Component等类型来表示一个对象，但物理世界只关注一个物体的几何形状、位置、以及质量等属性，它们构成了另一套PhysScene里的数据结构，以及PhysScene里的“对象”。这个对象称为GeometryParticle，Game世界通过FPhysicsActorHandle类型来索引它。 FPhysicsActorHandle using FPhysicsActorHandle = Chaos::FSingleParticlePhysicsProxy*; 看声明代码，等价于FSingleParticlePhysicsProxy*，既然叫XXProxy，那就是一个代理的作用。一个需要进行物理解算的对象在物理引擎内被称为一个Particle，是一个约定俗成的名字，该类作为其代理充当与物理交互的接口。物理引擎只关注一个物体的几何形状、位置、以及质量等属性，它们由TGeometryParticle持有。PhysicsActorHandle的TGeometryParticleHandle属性指向了GeometryParticle，实现关联。不仅如此，TGeometryParticle内部还有一个指向更底层物理SOA存储的FGeometryParticleHandle，暂时用不到，先不管。概念比较多，之后会做总结。 注意，目前都只涉及物理世界，但不涉及物理线程。游戏线程和物理线程都可以读写物理世界，但物理线程主要负责物理模拟。 除此之外还有描述Chaos破坏集合的FGeometryCollectionPhysicsProxy、描述关节约束的FJointConstraintPhysicsProxy等。 FSingleParticlePhysicsProxy的属性： TUniquePtr<PARTICLE_TYPE> Particle; FParticleHandle* Handle; FPhysicsObjectUniquePtr Reference; int32 GravityGroupIndex; TUniquePtr<FProxyInterpolationBase> InterpolationData; GetGameThreadAPI()是GameThread获取操作类的方法，直接cast即可： FORCEINLINE FRigidBodyHandle_External& GetGameThreadAPI() { return (FRigidBodyHandle_External&)*this; } UPrimitiveComponent::OnCreatePhysicsState 用于创建物理数据。Component在创建时会执行RegisterComponent函数，其中执行到OnCreatePhysicsState创建物理数据，OnCreatePhysicsState本身是虚函数，可以被PrimitiveComponent的不同子类重载。 重载了OnCreatePhysicsState的Component： 而其中主要逻辑都在FBodyInstance::InitBody函数里，最终执行到FInitBodiesHelperBase::InitBodies。 UPrimitiveComponent和FPhysicsActorHandle的映射 给UPrimitiveComponent创建完物理对象后，和PhysicsActorHandle的映射关系会存储在PhysScene里，用于后续查询。 值得注意的是，Component和PhysicsActorHandle是个一对多的关系，比如一个Component里面可以创建多个BodyInstance，自然就有了多个PhysicsActorHandle。 一个例子是SkeletalMeshComponent，会有多个刚体组成。 /** Array of FBodyInstance objects, storing per-instance state about about each body. */ TArray<struct FBodyInstance*> Bodies; 创建Actor（Particle） 这里Actor是PhysX遗留的术语，可以视为物理场景中的一个对象，基本等价于Particle，也即前文的TGeometryParticle。这里的Particle和下文的Shapes都在CreateShapesAndActors函数中创建。 最终的创建代码如下，StaticMesh情况比较简单，直接创建TGeometryParticle即可： void FChaosEngineInterface::CreateActor(const FActorCreationParams& InParams,FPhysicsActorHandle& Handle, UObject* InOwner) { TUniquePtr<FGeometryParticle> Particle; // Set object state based on the requested particle type if(InParams.bStatic) { Particle = FGeometryParticle::CreateParticle(); Particle->SetResimType(EResimType::ResimAsFollower); } //... } TGeometryParticle的属性如下： TChaosProperty<FParticlePositionRotation, EChaosProperty::XR> MXR; TChaosProperty<FParticleNonFrequentData,EChaosProperty::NonFrequentData> MNonFrequentData; void* MUserData; FShapeInstanceProxyArray MShapesArray; EParticleType Type; FDirtyChaosPropertyFlags MDirtyFlags; MXR：表示位置和旋转；FVec3 MX；FRotation3f MR；MShapesArray是Particle所包含的Shape，一个GeometryParticle完全可以包含多个形状，如球、长方体等，只是用的不多。 MNonFrequentData指向了低频访问的数据。 有了Particle，随之就能创建PhysicsActorHandle了，把刚创建的FGeometryParticle指针存入即可。 创建不同形状的Shape Shape为一个几何体，它包含碰撞、几何与物理材质等数据，一个Particle可以有多个Shape。 Shape的形状描述都在AggGeom里，总共有以下几种基础形状： Sphere：对应几何类为TSPhere，记录了圆心坐标和半径。 Box：对应几何类为TBox，记录了Box的HalfExtent和中心Transform。 Capsule：对应类型为FCapsule，照理说应该记录HalfHeight、Radius和中心Transform，但小小优化了一下，改为记录圆柱体上下两个点+Radius。 Convex：除了这些基础形状，还有能处理任意形状的凸包，对应类型为FConvex，里面存储了所有顶点数据，是三角形的集合。下图是为一个锥体生成的凸包。 这里例子的StaticMesh只配置了Box类型的碰撞，会创建TBox类型的Geometry，包含了Min和Max两个坐标，其实就是HalfExtend的作用。 template<class T, int d> class TBox final : public FImplicitObject { TAABB<T, d> AABB; }; class TAABB { TVector<T, d> MMin, MMax; }; 然后对TBox再包一下，加上Box的Transform数据，生成ImplicitObjectTransformed对象。 ChaosInterface::CreateGeometry函数负责根据配置的AggGeom数据，创建Geometry实例，然后存入Particle中。 至此，示例中StaticMesh对应的物理Geometry已创建完毕。 Simple Collision & Complex Collision 通常一个对象会有简单&复杂两套碰撞，就需要分别创建两个Shape和Geometry。勾选UseSimpleAsComplex则只用一套，但不建议这么做。 把上面几个类都联系起来，总结关系如下。 写入物理世界 目前为止，只是创建了Particle、Shape等数据，还需要把它们注册到物理世界中，就像把Actor注册到UWorld。 FChaosScene::AddActorsToScene_AssumesLocked 物理世界写锁 首先物理世界会被多线程同时读取与写入，因此是个读写锁的场景，写入时需要加写锁，通过FPhysicsCommand::ExecuteWrite函数实现，执行到这时可能会阻塞在等待锁上。 写入Solver Solver即为FPBDRigidsSolver，管理了物理世界所有Particle，如此理解即可，写入代码如下： FPBDRigidsSolver::RegisterObject RigidBody_External.SetUniqueIdx(GetEvolution()->GenerateUniqueIdx()); TrackGTParticle_External(*Proxy->GetParticle_LowLevel()); //todo: remove this Proxy->SetSolver(this); Proxy->GetParticle_LowLevel()->SetProxy(Proxy); AddDirtyProxy(Proxy); UpdateParticleInAccelerationStructure_External(Proxy->GetParticle_LowLevel(), EPendingSpatialDataOperation::Add); 然后Particle会被赋予一个UniqueIdx作为标识。 RigidBody_External.SetUniqueIdx(GetEvolution()->GenerateUniqueIdx()); 写入空间加速结构 这是一个重点。物理世界中有大量的几何体，为了支持射线检测、碰撞查询、物理模拟等功能，必然要用到空间加速结构。这就是FChaosScene类中的SolverAccelerationStructure属性。 Chaos::ISpatialAccelerationCollection<Chaos::FAccelerationStructureHandle, Chaos::FReal, 3>* SolverAccelerationStructure; 虽然是一个Interface，但实际都用的是： 它只是一个Collection，里面划分了多个底层的加速结构。Chaos的默认底层加速结构为AABBTree，它是一个类似BVH的数据结构，但实现更简单。 为什么要划分多个AABBTree？ 整个物理场景使用一个超大的AABBTree来管理并不合适，元素多了效率会很低。所以分多个AABBTree子树是合理的，划分依据并不是空间远近，而是Particle的Static/Movable属性、QueryOnly/QueryAndPhysics属性等，大方向是划分了Static Tree和Dynamic Tree。因为Static Tree是很少做更新的，Particle移动时，更新单独的Dynamic Tree效率更高。Particle的SpatialAccelerationIdx属性就表示它属于哪一个AABBTree，有16位，能表示8个Bucket，每个Bucket又有最多8192个AABBTree，但目前远没有用这么多，实际默认用了四个，最多能支持到8个。 struct FSpatialAccelerationIdx { uint16 Bucket : 3; uint16 InnerIdx : 13; } Bucket的划分 对于Bucket的划分，只有两种，一个是0，即默认的，另一个是1，当AABB的包围盒过大时会放到Bucket 1里。这个包围盒阈值默认为100米。 在FPBDRigidsSolver::RegisterObject函数中，会根据这个条件设置Bucket。 为什么超大AABB要单独划分？如果超大Particle和小Particle存储在一棵树中，会导致查询效率变低。想象一个叶节点包含了一个超大Particle和若干小Particle，那么做射线检测时，很容易与这个叶节点相交，但做逐Particle判断时，大概率只和这个超大Particle相交，小Particle的射线检测就都浪费了。 InnerIndex的设置 查看FChaosEngineInterface::CreateActor代码，会发现根据Particle的Static/Dynamic，以及是否QueryOnly，分成了四类： Defautl是Static Tree，Dynamic是Dynamic Tree。 Static Tree会存储静态对象，如场景中的树木、石头、房屋等。 例子中，Box的属性是Static+QueryOnly，因此InnerIndex被设置成了ESpatialAccelerationCollectionBucketInnerIdx::DefaultQueryOnly。 Dynamic Tree存储会移动的对象，如角色、载具、电梯。 DefaultQueryOnly是对Static Tree再细分出Query Only的Particle，DynamicQueryOnly类似，但目前没启用，通过p.Chaos.AccelerationStructureIsolateQueryOnlyObjects参数开启。 通过Bucket和InnerIndex划分AABBTree的示意图如下： 例子中，Box的属性是Static+QueryOnly，因此InnerIndex被设置成了DefaultQueryOnly。 具体写入了什么数据 这里立即更新物理世界的加速结构，UpdateElement就是把Geometry插入到AABB中。 写入数据有Payload和PayloadInfo两部分。 Payload更像Key，类型是FAccelerationStructureHandle，包含Particle指针、UniqueIdx、FilterData等数据。 PayloadInfo是AABBTree叶节点真正存储的数据，能反向关联到Payload和Particle，类型为FAABBTreePayloadInfo。属性如下： struct FAABBTreePayloadInfo { int32 GlobalPayloadIdx; // GlobalPayloads里单独存的NodeIndex，没有BoundingBox int32 DirtyPayloadIdx; // 单独的DirtyElementTree里的NodeIdx int32 LeafIdx; // 常见情况，Leaf Node的Index int32 DirtyGridOverflowIdx; // 用Dirty Grid而不是DirtyElementTree时，overflow的Index，少见情况 int32 NodeIdx; // 常见情况，Tree Node的Index } 最终Payload和PayloadInfo会存在PayloadToInfo这个Map里，其实是个数组，通过UniqueIndex索引，模拟成了Map。 typename StorageTraits::PayloadToInfoType PayloadToInfo; AABBTree 既然AABBTree加速结构很重要，不妨展开分析下。 核心思想 其实和BVH类似，都是把一群AABB包围盒，通过启发式的规则，不断划分成左右两个AABB子树，直到每个叶节点包含的AABB包围盒少于一个阈值，本质是一棵二叉树。 以2D情况为例，AABB树的非叶节点，会把叶节点的AABB给取并集，作为自己的AABB，叶子节点则包含了一定数量的AABB对象。下面Root的Child1节点就是叶子节点。 其对应的二叉树结构如下： 至于AABBTree的具体代码实现，为了效率，没有使用动态new节点的方法，而是用数组来表示了二叉树的拓扑。 TArray<FNode> Nodes; TLeafContainer<TLeafType> Leaves; Nodes存储了所有节点，每个节点如下： struct TAABBTreeNode { TAABB<T, 3> ChildrenBounds[2]; int32 ChildrenNodes[2]; int32 ParentNode; bool bLeaf : 1; bool bDirtyNode : 1; }; 当Node是叶节点时，ChildrenNodes[ ChildrenBounds是左右子节点的Bounds。 TAABBTreeLeafArray TArray<TPayloadBoundsElement<TPayloadType, T>> Elems; 叶子节点数组只存储AABB包围盒，会略微扩大一点，DynamicTreeLeafEnlargePercent=0.1，为了给Update做一点开销缓冲，避免Update太频繁。 叶节点包含最多8个AABB包围盒，叶节点的Bounds是它们的并集。 8是默认值，也可以通过CVar参数改变： int32 FAABBTreeCVars::DynamicTreeLeafCapacity = 8; FAutoConsoleVariableRef FAABBTreeCVars::CVarDynamicTreeLeafCapacity(TEXT("p.aabbtree.DynamicTreeLeafCapacity"), FAABBTreeCVars::DynamicTreeLeafCapacity, TEXT("Dynamic Tree Leaf Capacity")); 直接向叶节点插入元素 InsertLeaf 把Payload和Bounds插入到AABBTree中。简单起见，假设已经找到了最适配的那个叶节点，直接插入其中。 叶节点有个Elements数组，打包存储了Payload和Bounds，元素类型如下： struct TPayloadBoundsElement { TPayloadType Payload; TAABB<T, 3> Bounds; }; 具体代码层面的实现上，一个Leaf节点还需要一个额外的Node节点来辅助。 下图为AABBTree拓扑到实际存储结构的示例，可以看到树的叶节点，需要一个Node节点与一个Leaf节点来表示。 插入完成后，会返回Particle在AABBTree中的索引，索引由Node数组下标和Leaf数组下标两部分组成，类型是NodeAndLeafIndices。 struct NodeAndLeafIndices { int32 NodeIdx; int32 LeafIdx; }; 然后这个数据就存储在之前的FAABBTreePayloadInfo中。 如何寻找最合适的叶节点 叶节点满了，新建一个Node，作为Parent。 FindBestSibling 如果一颗AABBTree层数较多，那么插入一个AABB包围盒时，需要寻找“最合适”的叶节点插入。 何谓“最合适”，加入新AABB，通常会使一些ChildNode的BoundingBox变大，需要使BoundingBox增加的体积尽量小，以此来判定“最合适”。这是一种启发式的方法，认为BoundingBox大小直接影响到AABBTree查询的效率，类似构建BVH使用的SAH表面积启发算法。这个寻找过程称为“FindBestSibling”。 下面看几个例子。 例子1，新加入AABB在原先AABBTree的BoundingBox之内。此时经过对比BoundingBox增加的体积，应该加入ChildTree1。 例子2，新加入AABB在原先AABBTree的BoundingBox之外。此时不仅要考虑分别加入ChildTree[ 这里Root节点带来的增量相同，显然加入ChildTree1更合适。 例子3，多层子节点情况。看个更复杂的多层子节点例子，ChildTree[ 对于ChildTree[ 对于ChildTree1的两个子树，先要计算与ChildTree1产生的额外空间，再计算与两个子树的额外空间，最后都加起来。 因此这里显然应该把新节点加入ChildTree[ Leaf满了怎么办？ 当BestSibling找到的叶节点已经满了，就无法直接添加，需要再新增两个Node，一个作为中间节点，另一个作为新的Leaf。之后同样要更新Leaf到根节点路径上所有Parent的BoundingBox。 例子2的变体： 删除元素 与插入元素对应的，就是删除元素了，删除逻辑会简单很多。 virtual bool RemoveElement(const TPayloadType& Payload) 首先从PayloadToInfo中找到Payload所属的节点下标，然后从节点的Element数组里把Payload移除即可，并且把到根节点链路上的所有Bounds再更新一遍。 但是特别的，当叶节点删除Payload后整个都空了，就要精简一下AABB树了，把Parent节点和自己都删掉，然后Parent节点的位置放Sibling节点即可。 Static AABBTree AABBTree大体上分了Dynamic Tree和Static Tree两类，其中Dynamic Tree最贴近原生的AABBTree实现，增、删、改都直接操作AABBTree即可，而Static Tree则做了不少优化，来提升效率。因为通常Static Tree里的元素远超Dynamic Tree。 “Static”并不意味着不更新，比如可以把石头先改成Movable移动，再改回Static等等。Static Tree的构建总是先提供所有AABB对象，一次性建好，之后也能继续添加/删除/修改Payload，但树节点结构不实时改变，只会重建。 Static AABBTree的构建 构造函数中提供一个能表示AABB的数组即可，像这里的数组元素有3202个。 接着执行GenerateTree函数来构造AABBTree。 GenerateTree(Particles); 一次性构造一整棵树有个好处，就是能让左右子树尽量空间上均衡，提升之后的查找效率。如果是Dynamic那样一个一个节点的插入，树的质量和节点插入顺序是有关系的。那么重点就是对于一群AABB节点，如何合理地划分左右子树。回想在构造BVH时，有两种常用划分方法，一种是在XYZ三个方向里选取Max-Min最大的一个作为划分轴，然后取中点划分；另一种是SAH表面积启发算法，同样是考虑XYZ三个轴，但在每个轴上要计算多种表面积划分组合，再选取某个能使Cost最小的位置做划分，计算量更大，但效果也更好。UE的AABBTree使用了更接近前者的简单做法，但不会取Max-Min最大的作为划分轴，而是计算每个轴上AABB 中心点形成的方差，取方差最大的轴作为划分轴。计算中心点和方差的代码如下，使用了流式数据常用的Welford算法。 举个二维平面的例子。 按照Max-Min的方式，应该用X轴划分，按照方差方式，应该按照Y轴划分，结果上看方差方式更优。 然后GenerateTree的具体实现上，也有两个特点，一是支持分帧构建，避免突然卡一下，另一个就是全程避免递归和动态内存分配了，是比较值得学习的工程实践。 Static AABBTree删除Payload 如果Static AABBTree要删除Payload，过程反而更简单，直接从Leaves数组中移除Payload即可，不用考虑树的坍缩，也不用更新整个树节点链路上的AABB包围盒。 然后标记ShouldRebuild为true，等待后面一起更新树，见PBDRigidsEvolution.cpp文件。 Static AABBTree插入Payload 插入Payload比较有意思，既要不改变AABBTree的结构，又要插入元素。UE做法是另外创建了一个容器，来存储插入的元素，而且这个容器同样支持空间加速查询。一种实现是2D Grid，另一种实现是再加一个Dynamic AABBTree。还是比较复杂的。 Grid实现 默认用Grid实现，首先Payload加入DirtyElements数组，然后把整个场景划分为2D正方形Grid，再把Payload加入到Grid数据结构中。 Grid的每个Cell大小为CVarDirtyElementGridCellSize，默认1000，如果一个Payload和某个Cell重叠，该Cell就会把Payload的DirtyPayloadIndx记下来。 但Grid也有大小限制和容量限制。首先，Payload所覆盖的Cell不能过多，即Payload的AABB不能过大，然后每个Cell重叠的Payload数量不能过多，不然都会影响效率。目前前者配置是16，后者配置是32。如果超过了限制，Payload就要被加入到单独的DirtyElementsGridOverflow数组，并把下标记录在PayloadInfo中，后续也会单独查询。 Grid示意图如下： DirtyElementTree实现 新加的AABBTree称为DirtyElementTree，Payload插入完成后，PayloadInfo会存储其在DirtyTree中的Node下标。 如此一来，Grid/DirtyElementTree就是Static AABBTree的一部分了，往后的增/删/改Payload，以及AABBTree做碰撞查询，都要额外考虑它们。后面AABBTree重建了，再清空Grid/DirtyElementTree。 AABBTree可视化 最后，可以读取AABBTree的所有元素，并画出来，看下AABBTree都是什么样。 遍历PhysicsScene的SolverAccelerationStructure属性即可，可以看到整个AABBTree还是比较复杂的。 尤其是中间角色，有多个BodyInstance。 碰撞通道设置 一个PrimitiveComponent，不仅包含几何信息，还可以配置它的碰撞通道、ObjectType、CollisionType等信息，这些设置都会影响到PrimitiveComponent的物理表现。比如例子中的StaticMeshComponent，默认的ObjectType为WorldStatic，然后对所有CollisionResponses的响应都是Block。 首先需要根据ColllisionProfileName加载对应的各个碰撞响应，每个BodyInstance都自己存了一份CollisionProfileName和CollisionResponses，加载函数为UCollisionProfile::ReadConfig。至于为什么每个BodyInstance都要存一份，而不是用BodySetup里的，是因为运行时可以动态改变单个BodyInstance的碰撞设置。 此时这些碰撞设置还是一堆Bool和Enum，对于底层物理引擎存储不太方便，因此要把它们进行编码，最终会存入四个int32，用FCollisionFilterData表示。 struct FCollisionFilterData { uint32 Word0; uint32 Word1; uint32 Word2; uint32 Word3; }; 在CreateShapes函数中，会对碰撞数据进行编码，并且生成三个FCollisionFilterData实例，存储不同用途的物理碰撞信息。 /** Helper struct holding physics body filter data during initialisation */ struct FBodyCollisionFilterData { FCollisionFilterData SimFilter; FCollisionFilterData QuerySimpleFilter; FCollisionFilterData QueryComplexFilter; }; 编码过程主要由CreateShapeFilterData函数实现。 SourceObjectID：UPrimitiveComponent的UniqueID。 InstanceBodyIndex：这是UPrimitiveComponent里的第几个BodyInstance，比如常见的SkeletalMeshComponent有多个BodyInstance。 SimFilter：物理模拟相关信息。 inline void GetSimData(uint32 BodyIndex, uint32 ComponentID, uint32& OutWord0, uint32& OutWord1, uint32& OutWord2, uint32& OutWord3) const { OutWord0 = BodyIndex; OutWord1 = BlockingBits; OutWord2 = ComponentID; OutWord3 = Word3; } QuerySimpleFilter：SimpleCollision的碰撞响应。 inline void GetQueryData(uint32 SourceObjectID, uint32& OutWord0, uint32& OutWord1, uint32& OutWord2, uint32& OutWord3) const { OutWord0 = SourceObjectID; OutWord1 = BlockingBits; OutWord2 = TouchingBits; OutWord3 = Word3; } QueryComplexFilter：ComplexCollision的碰撞响应，响应部分和Simple是一样的。 // Build filterdata variations for complex and simple SimpleQueryData.Word3 |= EPDF_SimpleCollision; if (bUseSimpleAsComplex) { SimpleQueryData.Word3 |= EPDF_ComplexCollision; } ComplexQueryData.Word3 |= EPDF_ComplexCollision; if (bUseComplexAsSimple) { ComplexQueryData.Word3 |= EPDF_SimpleCollision; } OutFilterData.QuerySimpleFilter = SimpleQueryData; OutFilterData.QueryComplexFilter = ComplexQueryData; Word3的设置： inline uint32 CreateChannelAndFilter(ECollisionChannel CollisionChannel, FMaskFilter MaskFilter) { uint32 ResultMask = (uint32(MaskFilter) << NumCollisionChannelBits) | (uint32)CollisionChannel; return ResultMask << NumFilterDataFlagBits; } PrimitiveComponent的销毁 Component销毁时，需要同步的清除掉物理世界数据，主要通过FBodyInstance::TermBody函数实现。 1.从PhysScene的几个容器里移除 PhysicsProxyToComponentMap和ComponentToPhysicsProxyMap。 2.从PhysScene的加速结构中移除 FChaosScene::RemoveActorFromAccelerationStructure 就是从AABBTree移除，先从Buckets找到对应的AABBTree，然后调用RemoveElement移除。 最终进入AABBTree的RemoveElement函数，根据DynamicTree属性、使用Grid还是DirtyElementTree等情况，做移除。 3.从Solver中移除 FPBDRigidsSolver::UnregisterObject，物理模拟信息的删除。 这是侑虎科技第1966篇文章，感谢作者南京周润发供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/xu-chen-71-65 再次感谢南京周润发的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
做UE项目的你，一定经历过这些场景：QA测出卡顿 → 录屏反馈 → 程序复现半天，对不上。打开 Unreal Insights，却发现真机数据和Editor分析结果不一致： 跨线程（GameThread / RenderThread / RHIThread）互相影响，瓶颈像“黑盒”一样难以拆解，长周期卡顿、内存泄露问题，测得到却抓不住。最后问题不是解决不了，而是——定位成本太高，效率太低。 UE性能分析长期存在一个核心断层：开发侧分析工具（Insights）与日常QA测试数据之间，无法稳定对齐，这意味着：QA的数据，程序不能直接拿来主义、分析必须反复复现、性能优化高度依赖经验与人力，仍然是一件“专家工具 + 人肉分析”的事情。 随着 Unreal Engine 5.7的发布，UWA GOT Online UE版迎来全量升级。但这次，我们做的不是简单适配，我们想做的是：把UE性能优化，从“少数人能做”，变成“团队都能做”的标准能力。 原生级引擎适配 让数据第一次真正“对齐” 从“看数据”到“直接定位问题” 我们完成了UE 5.x全系列（含5.7）的原生适配，并打通了引擎底层数据链路。具体包括： 1. 函数级Timeline与Insights的100%对齐 函数名、调用层级、时间统计完全一致，这意味着：真机数据，可以直接用于定位问题，而不是“参考数据”，程序无需再在多个工具之间反复核对。 2. 引擎原生指标全量接入 新增5大类、数十条UE原生STAT指标，譬如覆盖了渲染流程中，从FDrawSceneCommand（RT）到RHIThreadExecute（RHI），每一层都有指标 + 函数堆栈，可以精确定位：到底卡在“哪一段渲染流程”。 另有，加载/GC/动画/物理/AI/UI，所有数据均来自UE原生采集通道，开发者看到的，就是引擎真实发生的。 物理模块 UI 模块 多线程核心指标（Game/Render/RHI）全面覆盖：支持多视角切换（总览/单帧/场景） 且支持独立线程分析、冰柱图 + 堆栈联动、万级节点自动过滤。可以直接定位到某一帧、某一线程、某一个函数。 性能优化第一次变成“团队能力” 这个版本中我们也支持了一系列对于日常调试需求来说非常友好的功能，譬如： 新增Direct Mode模式，支持游戏启动后，立即开始抓取性能数据。能够针对游戏启动后加载慢、内存增长快等特殊场景进行针对排查。 编译级开关可控，支持宏定义一键屏蔽 SDK，可在 Debug、Release 等不同编译环境中灵活控制 SDK 的启用与关闭，彻底避免了 SDK 对项目正常开发、编译与发布的影响。 集成流程简化，移除 Binaries 目录，降低了项目版本管理与团队协作的成本。 这次升级里，最具行业意义的一点，QA可以参与性能定位了： 过去：QA → 发现问题 → 程序分析 → 来回拉扯 现在：QA → 直接提供可定位数据 → 程序直接修 带来的本质变化是：性能优化，从“程序能力”，变成“团队协作能力”，并且覆盖研发全周期的性能管控，从SDK接入、功能测试、性能回归、长周期监控、一套工具全部覆盖，支持 Android / iOS；不只是“跑得快”，也关注“玩家体验”。 从2015年至今，UWA始终专注于性能优化领域。但这一次，我们更在意的是另一件事：能不能把“复杂能力”，变成“标准流程”，如果你正在做UE项目，并且遇到这些问题： 性能问题定位效率低 QA与程序反复沟通 长周期问题难复现 那这次Unreal版的GOT Online，就是为你准备的。我们做的，不只是工具升级，而是让性能优化这件事，第一次可以被“团队稳定复用”。 关于UWA UWA是一家创业十年的高新技术企业，作为游戏行业的深耕者，UWA始终专注于为使用Unity、Unreal引擎的开发者提供丰富的优化产品，帮助开发者高效解决开发问题、定位性能瓶颈、提供解决方案，已支持超过一万款游戏项目。
3 月刊｜GPM 2.0 性能数据探索 & 单玩家追踪升级，精细化性能分析再突破
GPM 2.0持续深化全链路性能监测能力，2026年3月围绕性能对比、个体排查、分布统计、案例沉淀四大核心场景完成功能升级，推出性能汇总、数据探索、单用户数据、性能分布大盘及收藏库五大实用能力，实现版本迭代效果量化、单玩家问题快速复现、多维数据自由检索与团队经验长效沉淀，从宏观趋势研判、微观问题定位、高效协同排查三个维度，全面提升性能分析效率与决策精准度。 性能汇总 + 数据探索 多维度横向对比，量化版本迭代差异 「性能详情」模块全新上线性能汇总功能，支持多版本、多场景、多设备关键性能指标一键横向对比，有效解决版本迭代后性能波动难以量化、优化效果无法直观评估的行业痛点。用户可自定义指标展示、排序规则与统计粒度（日级/小时级），清晰呈现各维度性能表现并支持数据导出，帮助研发团队快速识别性能退化点与优化收益。 配套推出的数据探索功能，提供高度灵活的自定义分析面板，支持自由配置表格行、列、数值与筛选条件，满足多维度交叉比对需求，同时开放更自由的数据查询与导出能力，适配多样化深度分析场景。 单用户数据 精准定位个体体验问题，高效响应玩家反馈 新增单用户数据模块，包含玩家查询与条件查询两大核心能力： 玩家查询：支持通过玩家 ID、GPM ID 精准检索目标用户的全部历史会话，联动性能曲线与异常报告关键指标，快速定位该用户体验最差的会话，高效处理玩家反馈与客诉问题。 条件查询：支持按用户维度与设备、版本、环境等条件组合筛选，批量查看会话性能数据，一屏锁定存在性能短板的异常报告，大幅提升个体问题排查效率。 性能分布大盘 自定义指标与分档，全局掌握性能结构 全新性能分布大盘支持自选核心指标、自定义档位区间、配置统计周期（动态/静态），并可保存多套分析方案。单指标支持跨时段数据对比，点击分布柱状图可直接下钻至指标分析页面并自动锁定对应数据区间，帮助团队快速掌握性能分布特征，为适配优化与版本决策提供依据。 收藏库 典型案例一键沉淀，构建团队共享知识库 本次更新新增收藏库功能，针对典型卡顿、崩溃、性能拐点等高价值会话，支持一键收藏、自定义备注、问题类型标注与排查结论记录。实现典型案例长效留存与团队共享复用，避免同类问题重复排查，降低沟通与试错成本，构建可持续迭代的性能优化知识库。 立即体验！ 专属通道限时开放 ➡ 预约技术专家1V1部署指导！我们将全程支持服务搭建、数据分析与反馈，确保您能够充分体验GPM 2.0带来的价值。 邮件：sales@uwa4d.com 微信号：17502188376 了解GPM 2.0全功能，可以点击《GPM 2.0 核心功能全景指南｜重塑游戏性能主动管控能力》查看详情。 关于GPM 2.0 UWA GPM 2.0是专为上线及测试阶段游戏项目打造的高性能监测平台。它不仅能深度捕捉宏观性能数据，更创新采用性能无损截帧技术，在不影响玩家体验的前提下，助力开发者全面掌握玩家端运行关键细节，从多维度优化游戏性能，实现从“玩家投诉后救火” 到 “问题发生前预警” 的核心转变。 GPM 2.0全景指南 《UWA GPM 2.0 核心功能全景指南｜重塑游戏性能主动管控能力》 《GPM 2.0 功能更新月报：新增导出表格、崩溃反混淆等实用功能，体验再升级！》 《2 月刊｜GPM 2.0错误日志分析上线，PC端监测能力全维度突破》 《1 月刊｜日报周报一键获取+AI问答，玩家行为精准分析》 《12月刊｜上新！全局筛选+设备标记+序列帧回溯，GPM 2.0性能分析效率拉满》 GPM 2.0新功能 集锦 《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 重新定义性能监测与优化｜从未知到已知，从被动到主动》 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）小游戏的Spine数量过多开销大，怎么办 2）关于在AssetBundle中同时看到SpriteAtlas图集纹理和小图是否合理 这是第469篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From 问答社区 Q1：我们小游戏上Spine耗时有点高，平均每帧耗时8.04ms，该怎么优化？ A：从函数调用次数来看，每帧有200多次调用，这个数量级下单个Spine的开销其实已经相对较低了。其中还看到SkeletonAnimation.FixedUpdate会有3~4ms的开销，理论上进入FixedUpdate后会直接Return，但由于调用数量太高（物理调用次数最大17，导致SkeletonAnimation.FixedUpdate的调用次数达到3000+），还是把这里的开销堆上去了。可以考虑禁用Skeleton Animation组件，专门写一个Manager主动触发Spine的Update和LateUpdate，应该能减少大量Spine对象导致跨语言调用开销。 Q2：是不是要重写一个Skeleton Animation组件呢？ A：在Manager里面注册Skeleton Animation到List里面，统一把SkeletonAnimation.enabled=false就好，然后用Manager统一调用Update和LateUpdate，不用重写Skeleton Animation。 Q3：是在Manager里面的Update直接getComponent到Skeleton Animation点出Update执行吗？ A：最好一开始就注册到某个List里面，动态维护这个List，在Update里面遍历这个数组，每个成员都执行Update。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/69c9ec21abed2e338a7dac6d From 问答社区 Q：有个打AssetBundle的情况想咨询下， 我们把一个Unity SpriteAtlas图集打成一个包，这个包里理论上应该只有合并后的图集一个资源， 现在用AssetStudio解包看除了图集还有单个散图，是正常的吗，会不会导致运行时同时加载出来图集和小图而冗余？ A：下图是只打了一个SpriteAtlas图集的AssetBundle在AssetStudio中看到的情况。AssetBundle打包了sactx的Texture2D大图的同时，也打包了很多Sprite；只要看到小图的Type不是Texture2D，就是正常的。 Assets: - Assets/CommonAssets/AssetBundle Test/Texture_common/SpriteAtlas/TestAtlas.spriteatlasv2 Dependencies: [] 运行时还是仅加载一张大图，然后找到其中小图所在的UV位置和长宽，就渲染出来了。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/69c9e6feabed2e338a7dac6c 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
Unity移动端游戏性能优化简谱之 功耗、发热和降频在软件层面的优化
《Unity移动端游戏性能优化简谱》共分为五个部分，今天向大家介绍文章的最后一部分：功耗、发热和降频在软件层面的优化，包含了移动端高功耗与发热产生机制、功耗与发热的定量数据分析、使用Perfetto的功耗调优实践等多项常见的游戏画面表现讲解。（全文长约3200字，预计阅读时间约15分钟） 前四部分可点击以下对应文章查看： 一、《Unity移动端游戏性能优化简谱之 前言》 二、《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（上）》 《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（下）》 三、《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（上）》 《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（下）》 四、《Unity移动端游戏性能优化简谱之 达到GPU压力与画面表现间的平衡》 1. 功耗、发热与降频基本原理 1.1 移动端高功耗与发热产生机制 手游运行时功耗由核心计算功耗（CPU/GPU/NPU）、访存功耗、外设功耗（屏幕/射频/传感器/存储）、系统调度开销四部分构成，其中对于手游来说尤其常见和需要关注的情况有： 高负载场景下（战斗、大世界渲染、物理模拟、网络同步），CPU/GPU持续高频运行，功耗可能持续过高； 手机机身散热面积有限，热量无法快速传导至外部，形成热堆积，且往往出现部分硬件模块压力尤其大，发热严重并传导到附近其他模块，使得这些模块也出现性能问题； 功耗与性能并非线性关系，超过能效拐点后，性能小幅提升会带来功耗大幅上涨，发热急剧恶化。 当SoC温度达到温控阈值，Android内核与固件会启动多级热防护机制，强制降低性能以控制发热，多数情况下体现为CPU/GPU频率降低、限制大核调度、减少并发线程数。 降频同时导致了CPU/GPU的算力下降，常直接导致游戏运行过程中帧率暴跌。尤其相对于其他前文讨论的性能问题，该问题反而在一些玩家的高端设备上更加显著，对于一些算力强劲的CPU/GPU，可能计算压力并不足以直接导致掉帧，但发热降频却导致了这些玩家有手机发烫、低帧卡顿等负面体验。 1.2 功耗与发热的定量数据分析 作为开发者和性能优化执行者，观察问题的视角显然还是要与玩家区分。因此，要精准定位功耗与发热的核心诱因，应避免依赖 “手感发烫”、“续航缩短”等主观感受，必须通过定量数据分析建立客观判断标准。正如上文讨论的，移动端游戏的功耗发热本质是硬件模块（CPU/GPU/存储/射频等）持续高负载运行的结果。所以定量分析的核心目标是：明确各硬件模块的功耗占比、定位高功耗场景的时间分布。 不过定量分析功耗发热问题的核心困难是，该问题其实已经脱离了游戏引擎可掌控的范畴，属于非常硬件层的问题。这就导致了不同芯片厂商、不同SoC厂商、不同手机厂商，其散热设计、保护机制等因素的不同都会导致发热和降频的情况互不相同；且他们提供的数据采集接口即便看起来单位一致，其实表示的实际开销也互不相同；在测试过程中，也会受到环境的干扰，比如冬夏气温、插电测试都会导致测试结果受到影响。 所以，应当尽量在固定设备使用可靠的工具进行测试，将数据进行按时间的、优化调试前后的纵向测试，而不是不同硬件、不同工具之间的横向测试。 1.3 使用Perfetto的功耗调优实践 1.3.1 市面上功耗测试工具评测 功耗优化的第一步是精准采集数据，市面上各类功耗测试工具各有优劣，需结合实际测试需求、设备适配性择优选用，以下是主流工具的实操测评与选型建议： （1）GOT Online作为常用性能监测工具，可实时采集帧率、帧耗时、CPU核心频率、内存占用、功耗峰值等核心数据，支持设备截屏、数据导出与对比分析，能直观呈现游戏运行时的功耗波动，基础监测能力在线，但针对细分硬件功耗的拆解能力有限。 （2）Trepn Profiler实测实用性较低，数据采集精度与稳定性难以满足专业调优需求；adb shell dumpsys batterystats指令虽能获取电池消耗数据，但数据维度单一、参考价值不大，仅能作为辅助参考，无法支撑深度功耗分析。 （3）Android Studio自带的Profiler工具，搭载On Device Power Rails Monitor（ODPM）模块，可监测CPU大小核、GPU、内存、屏幕、WiFi等硬件组件的功耗，但缺陷十分突出：不仅需要Debuggable包体，还仅适配Pixel系列机型，且测试时长不宜过长，通用性极差，难以适配主流安卓机型的规模化测试。 （4）Perfetto是移动端功耗测试的最优选择之一，无需Debuggable包体，支持USB/WebSocket连接实时录制。只不过在采集各硬件功耗轨道数据时，谷歌官方只在自己旗下的Pixel品牌手机中进行了专门的插桩，从而能够清晰呈现power.rails.cpu.big、power.rails.gpu、power.rails.memory.interface等细分模块的能耗值，还能通过ui.perfetto.dev在线分析Trace文件，精准定位功耗峰值与异常节点，是下文介绍的调优实践的核心工具。 移动端Unity游戏的能耗分布并非无迹可寻，核心能耗集中在CPU、GPU、内存、屏幕、网络和SoC等硬件模块，各模块的运行状态直接决定整机功耗。开展功耗测试前，需统一测试标准：固定30FPS帧率、固定屏幕亮度与刷新率，排除外界因素干扰；采用AB Test对比优化前后数据，依托Perfetto采集硬件细分功耗，搭配GOT Online排查CPU耗时、Gears监测GPU Counters，必要时用Renderdoc、Xcode截帧分析渲染细节，全方位定位问题。 结合实测数据逐一拆解如下。 1.3.2 GPU质与带宽对功耗的影响 测试数据如上图。GPU是游戏功耗的核心来源之一，其能耗与画质等级、数据带宽强相关，画质档位越高，GPU时钟频率、带宽占用越高，整机功耗随之陡增。实测数据显示，从省电、流畅、标准到精致画质，GPU带宽从1195MB/s升至1483MB/s，ddr.c、memory.interface、gpu.l2都有不同程度的上升；GPU Clocks从264M/s升至393M/s，GPU功耗从335mW攀升至543mW；整机功耗涨幅超270mW，GPU压力与能耗呈正相关。 带宽是GPU能耗的关键影响因子，半透明叠加层数、纹理格式与Mipmap开启状态，对带宽和功耗的影响尤为显著。对比测试发现，同等层数下，开启ASTC 4x4纹理压缩可降低带宽与功耗，而开启Mipmap的优化效果远优于纹理压缩：30层纹理开启Mipmap后，带宽从3.38GB降至0.9GB，GPU功耗直降49mW；50层纹理开启Mipmap，功耗降幅超160mW。据此可得出核心结论：3D渲染纹理务必开启Mipmap。 且带宽与能耗存在固定估算关系——1GB/s≈67mW，带宽越高，内存交互、GPU缓存的功耗损耗越大。 综上，建议所有3D场景纹理开启Mipmap，优先选用ASTC 4x4、ETC2等移动端压缩纹理，摒弃无压缩格式；根据机型性能划分画质档位，低端机锁定省电/流畅档位，降低GPU时钟频率与带宽占用；减少半透明叠加层数，规避无效渲染拉高带宽。从而最终降低GPU带宽。 1.3.3 屏幕和网络对功耗的影响 亮度： 刷新率： 画面内容（色彩鲜艳程度）： 测试数据如上图。屏幕能耗受三大因素主导：刷新率、亮度和画面色彩复杂度。实测数据表明，亮度与画面内容相同时，90帧刷新率的屏幕功耗高于60帧；刷新率与画面内容固定时，屏幕亮度越高，功耗越大；而刷新率和亮度不变，则彩色画面的屏幕功耗远高于纯黑画面——100%亮度下，彩色壁纸屏幕功耗达575mW，纯黑壁纸仅140mW，差距超4倍。开发者可通过动态调节亮度、适配机型刷新率和简化冗余画面色彩，实现屏幕功耗压降。 测试数据如上图。网络传输是隐性功耗增长点，10MB/s下载速率下，WiFi与移动数据的功耗差异显著，且均会大幅拉高整机功耗。无下载状态下，WiFi模组功耗仅62mW，开启下载后飙升至359mW；移动数据模组则体现在modem模块从280mW飙升至583mW功耗，加上CPU线程的开销整机总功耗涨幅超500mW。 综上，根据场景动态调节屏幕亮度，非核心场景降低屏幕刷新率，简化复杂彩色画面；网络端优化资源下载逻辑，避免大体积资源后台高频下载，线上游戏精简网络传输数据量、尽量减少持续性传输的做法，降低模组功耗。 1.3.4 CPU对功耗的影响 CPU功耗遵循动态功耗公式：P_dynamic ∝ C * V² * f，功耗与工作频率并非线性关系，核心频率越高，功耗呈指数级增长。主线程密集运算的功耗远高于多线程分摊，实测主线程运算功耗达3.67W，大核频率2.58GHz；改用JobSystem多线程后，总功耗降至2.55W，大核频率压至1.4GHz，优化效果显著。 此外，子线程运算对CPU功耗也是不可忽视的因素，但在引擎层缺乏可靠的手段予以分析。这里介绍一个使用SimplePerf工具采集并分析子线程的流程。只要用Release模式打包，搭配Create Symbols生成符号文件，便于SimplePerf工具解析函数调用栈，即可精准定位子线程高耗函数；获得的测试结果如上图。观察到该项目运行过程中存在一个占比极高的逻辑线程，超过了渲染线程甚至直逼主线程，这是非常异常的现象，点开观察细节后从该线程的函数名分析得到该线程为专门处理网络的线程。 如图测试，优化前CPU功耗高达4820mW，定位并清理冗余运算后，功耗降至3838mW，压降近千毫瓦。 综上，建议拆解主线程密集运算，将物理计算、动画更新等任务迁移至JobSystem，利用多线程分摊负载，避免CPU大核高频运行；通过SimplePerf定位子线程高耗函数，清理空转循环、冗余遍历等无效逻辑；严格采用Release模式打包，关闭调试相关冗余配置，减少CPU额外开销。 2. 结束语 这篇文章之所以称为简谱，实在是因为这些笔墨远不能达到面面俱到，很多内容还未涉及到，或者限于篇幅和重点不能深入讨论。它更多的是立足于如何以用好一套完善完整的性能工具为基础，构建发现问题-解决问题-监控问题的优化思维和优化体系，使得性能优化的工作事半而功倍。更多的优秀内容，欢迎在UWA社区中进行搜索。 至此《Unity移动端游戏性能优化简谱》已经全部更新完毕，文章从Unity移动端游戏优化的一些基础讨论出发，例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题，并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题，希望能提供给Unity开发者更多高效的研发方法和实战经验。
Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（下）
《Unity移动端游戏性能优化简谱》从Unity移动端游戏优化的一些基础讨论出发，例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题，并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题，希望能提供给Unity开发者更多高效的研发方法和实战经验。 今天向大家介绍文章第三部分：以引擎模块为划分的CPU耗时调优，共10小节，包含了渲染模块、UI模块、物理模块、动画模块、粒子系统、加载模块、逻辑代码、Lua等多个模块等常见的游戏CPU耗时调优讲解。 本章包含6-10小节，1-5小节请点击《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优（上）》。 （全文长约14115字，预计阅读时间约30分钟） 文章第一部分《Unity移动端游戏性能优化简谱之 前言》、第二部分《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（上）》、《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（下）》可戳此回顾，完整内容可前往UWA学堂查看。 6. 粒子系统 围绕粒子系统相关优化更全面的内容可以参考《粒子系统优化——如何优化你的技能特效》。 6.1 Playing粒子系统数量 UWA统计了粒子系统数量和Playing状态的粒子系统数量。前者是指内存中所有的ParticleSystem的总数量，包含正在播放的和处于缓存池中的；后者指的是正在播放的ParticleSystem组件的数量，这个包含了屏幕内和屏幕外的，我们建议在一帧中出现的数量峰值不超过50（1GB机型）。 针对这两个数值，我们一方面关注粒子系统数量峰值是否偏高，可选中某一峰值帧查看到底是哪些粒子系统缓存着、是否都合理、是否有过度缓存的现象；另一方面关注Playing数量峰值是否偏高，可选中某一峰值帧查看到底是哪些粒子系统在播放、是否都合理。 前文提到的控制同种粒子数量上限也是常见方案。 分级策略也是粒子系统优化过程中必不可少的手段。比如对于一个有12345，五种子粒子的火焰特效Prefab，其中345可能是美术同学用来做火焰燃烧时飞溅的火星、飘散的烟尘、空气的波动的，只有12是火焰本体。则低端机上应该考虑只保留12。 虽然我们在讨论粒子系统对耗时的影响时，多数时候还是在讨论粒子系统的渲染开销，无论是CPU端还是GPU端的。但对于粒子系统使用非常重度、数量非常多的项目来说，Playing的粒子过多也会对其本身在CPU端进行更新计算的ParticleSystem.Update()耗时有所影响。 6.2 Culling Mode 为粒子系统设置合理的Culling Mode也是一种有效降低粒子系统CPU端更新耗时的方式。 相关的关键设置为如上图的Simulation Space和Culling Mode。 其中，Simulation Space可选项有Local，World，Custom；CullingMode可选项为Automatic，Pause And Catch-up，Pause，Always Simulate。通常创建的粒子系统的默认选项是Local和Automatic。 我们显然希望当粒子在屏幕外看不到时停止相关运算、减少其耗时开销。因此我们测试在不同设置组合下粒子系统在屏幕外时的ParticleSystem.Update()函数耗时，结果如下： 在Local模式下，只有Culling Mode为Always Simulate有例子更新耗时（没有开启额外的Module）。 在Local模式下，如果粒子系统开启了Collision等Module，那么Culling Mode为Automatic时也会产生耗时。 在Local模式下，如果是Culling Mode为Pause或者Pause and Catch-up，就算有Collision Module也没有耗时。 在World模式下，Culling Mode为Automatic也会有耗时（没有开启额外的Module）。 在World模式下，Culling Mode为Pause，就算有Collision，也没有耗时。 综上，我们可以看到Culling Mode的设置对不同设置的粒子系统开销的限制能力是合理递进的，这说明我们在设置粒子系统时应尽量至少设为Automatic；应慎用Simulation Space为World的粒子；应慎用Collision、Trigger、Noise、Trails这些颇为常见的Module；对于确实要使用Module的粒子，也应考虑进一步调整设置为Pause等以节省耗时。 6.3 Prewarm ParticleSystem.Prewarm作为粒子系统的核心初始化选项，其设计初衷是为了让粒子系统在实例化或从Deactive状态激活时，立即完成一次完整的生命周期模拟，使粒子效果从激活瞬间就呈现出“已运行一段时间”的成熟状态（例如持续燃烧的火焰、循环播放的光晕等），避免出现“从无到有”的突兀感。但该机制在带来表现优势的同时，也隐藏着显著的性能风险——尤其在粒子系统数量较多、效果复杂的场景中，Prewarm触发的瞬时计算开销可能成为CPU耗时峰值的主要诱因，甚至引发卡顿。 主要优化手段如下： 优化粒子系统本身的复杂度，通过降低Prewarm触发时的计算量，从源头减少耗时： 规避集中触发Prewarm的场景，当多个带Prewarm的粒子系统需要激活时，通过分帧激活、延迟激活、或缓存复用的方式分散计算压力。 7. 加载模块 围绕加载模块相关优化更全面的内容可以参考《Unity性能优化系列—加载与资源管理》。 7.1 Shader加载 （1）Shader.Parse Shader.Parse是指Shader加载进行解析的操作，如果此操作较为频繁，通常是由于Shader的重复加载导致的，这里的重复可以理解为2层意思。 第一层是由于Shader的冗余导致的，通常是因为打包AssetBundle的时候，Shader被被动打进了多个不同的AssetBundle中而没有进行依赖打包，这样当这些AssetBundle中的资源进行加载的时候，会被动加载这些Shader，就进行了多次“重复的”Shader.Parse，所以同一种Shader就在内存中有多份了，这就是冗余了。 要去除这种冗余的方法也很简单，就是把这些会冗余的Shader依赖打包进一个公共的AssetBundle包。这样就会主动打包了，而不是被动进入某些使用了这个Shader的包体中。如果对这个Shader进行了主动打包，那么其它使用了这个Shader的AssetBundle中就只会对这个Shader打出来的公共AssetBundle进行引用，这样在内存中就只有一份Shader，其它用到这个Shader的时候就直接引用它，而不需要多次进行Shader.Parse了。 第二层意思是同一个Shader多次地加载卸载，没有缓存住导致的。假设AssetBundle进行了主动打包，生成了公共的AssetBundle，这样在内存中只有这一份Shader，但是因为这个Shader加载完后（也就是Shader.Parse）没有进行缓存，用完马上被卸载了。下次再用到这个Shader的时候，内存里没有这个Shader了，那就必须再重新加载进来，这样同样的一个Shader加载解析了多次，就造成了多次的Shader.Parse。一般而言，经过变体优化以后的开发者自己写的Shader内存占用都不高，可以统一在游戏开始时加载并缓存。 特别地，对于Unity内置的Shader，只要是变体数量不多的，可以放进Project Settings中的Always Included中去，从而避免这一类Shader的冗余和重复解析。 （2）Shader.CreateGPUProgram 该API也会在加载模块主函数甚至UI模块、逻辑代码的堆栈中出现。相关的讨论上文已经涉及，优化方法相同，不再赘述。 7.2 Resources.UnloadUnusedAssets 该API会在场景切换时被Unity自动调用，一般单次调用耗时较高，通常情况下不建议手动调用。 但在部分不进行场景切换或用Additive加载场景的项目中，不会调用该API，从而使得项目整体资源数量和内存有上升趋势。对于这种情况则可以考虑每5-10min手动调用一次。 Resources.UnloadUnusedAssets的底层运作机理是，对于每个资源，遍历所有Hierarchy Tree中的GameObject结点，以及堆内存中的对象，检测该资源是否被某个GameObject或对象（组件）所使用，如果全部都没有使用，则引擎才会认定其为Unused资源，进而进行卸载操作。简单来讲，Resources.UnloadUnusedAssets的单次耗时大致随着（（GameObject数量+Mono对象数量）*Asset数量）的乘积变大而变大。 因此，该过程极为耗时，并且场景中GameObject/Asset数量越高，堆内存中的对象数越高，其开销也就越大。对此，我们的建议如下： （1）Resources.UnloadAsset/AssetBundle.Unload(True) 研发团队可尝试在游戏运行时，通过Resources.UnloadAsset/AssetBundle.Unload(True)来去除已经确定不再使用的某一资源，这两个API的效率很高，同时也可以降低Resources.UnloadUnusedAssets统一处理时的压力，进而减少切换场景时该API的耗时。 （2）严格控制场景中材质资源和粒子系统的使用数量 专门提到这两种资源，因为在大多数项目中，虽然它们的内存占用一般不是大头，但往往资源数量远高于其他类型的资源，很容易达到数千的数量级，从而对单次Resources.UnloadUnusedAssets耗时有较大贡献。 （3）降低驻留的堆内存 堆内存中的对象数量同样会显著影响Resources.UnloadUnusedAssets的耗时，这在上文也已经讨论过。 7.3 加载AssetBundle 使用AssetBundle加载资源是目前移动端项目中比较普遍的做法。 而其中，应尽量用LZ4压缩格式打包AssetBundle，并用LoadFromFile的方式加载。经测试，这种组合下即便是较大的AssetBundle包（包含10张1024*1024的纹理），其加载耗时也仅零点几毫秒。而使用其他加载方式，如LoadFromMemory，加载耗时则上升到了数十毫秒；而使用WebRequest加载则会造成AssetBundle包的驻留内存显著上升。 这是因为，LoadFromFile是一种高效的API，用于从本地存储（如硬盘或SD卡）加载未压缩或LZ4压缩格式的AssetBundle。 在桌面独立平台、控制台和移动平台上，API将只加载AssetBundle的头部，并将剩余的数据留在磁盘上。AssetBundle的Objects会按需加载，比如：加载方法（例如：AssetBundle.Load）被调用或其InstanceID被间接引用的时候。在这种情况下，不会消耗过多的内存。 但在Editor环境下，API还是会把整个AssetBundle加载到内存中，就像读取磁盘上的字节和使用AssetBundle.LoadFromMemoryAsync一样。如果在Editor中对项目进行了分析，此API可能会导致在AssetBundle加载期间出现内存尖峰。但这不应影响设备上的性能，在做优化之前，这些尖峰应该在设备上重新再测试一遍。 要注意，这个API只针对未压缩或LZ4压缩格式，因为如果使用LZMA压缩，它是针对整个生成后的数据包进行压缩的，所以在未解压之前是无法拿到AssetBundle的头信息的。 由于LoadFromMemory的加载效率相较其他的接口而言，耗时明显增大，因此我们不建议大规模使用，而且堆内存会变大。如果确实有对AssetBundle文件加密的需求，可以考虑仅对重要的配置文件、代码等进行加密，对纹理、网格等资源文件则无需进行加密。因为目前市面上已经存在一些工具可以从更底层的方式来获取和导出渲染相关的资源，如纹理、网格等，因此，对于这部分的资源加密并不是十分的必要性。 在UWA GOT Online Resource模式下的资源管理页面中可以排查加载耗时较高的AssetBundle，从而排查和优化加载方式、压缩格式、包体过大等问题，或者对反复加载的AssetBundle考虑予以缓存。 7.4 加载资源 有关加载资源所造成的耗时，若加载策略比较合理，则一般发生在游戏一开始和场景切换时，往往不会造成严重的性能瓶颈。但不排除一些情况需要予以关注，那么可以把资源加载耗时的排序作为依据进行排查。 对于单次加载耗时过高的资源，比如达到数百毫秒甚至几秒时，就应考察这类资源是否过于复杂，从制作上考虑予以精简。 对于反复频繁加载且耗时不低的资源，则应该在第一次加载后予以缓存，避免重复加载造成的开销。 值得一提的是，在Unity的异步加载中有时会出现每帧进行加载所能占用的最高耗时被限制，但主线程中却在空转的现象。尤其是在切场景的时候集中进行异步加载，有时会耗费几十甚至数十秒的时间，但其中大部分时间是被空转浪费的。这是因为控制异步加载每帧最高耗时的API Application.backgroundLoadingPriority默认值为BelowNormal，每帧最多只加载4ms。此时一般建议把该值调为High，即最多50ms每帧。 在UWA GOT Online Resource模式下的资源管理页面中可以排查加载耗时较高的资源，从而排查和优化加载方式、资源过于复杂等问题，或者对反复加载的资源考虑予以缓存。 7.5 实例化和销毁 实例化同样主要存在单个资源实例化耗时过高或某个资源反复频繁实例化的现象。根据耗时多少排列后，针对疑似有问题的资源，前者考虑简化，或者可以考虑分帧操作，比如对于一个较为复杂的UI Prefab，可以考虑改为先实例化显眼的、重要的界面和按钮，而翻页后的内容、装饰图标等再进行实例化；后者则建立缓存池，使用显隐操作来代替频繁的实例化。 在UWA GOT Online Resource模式下的资源管理页面中可以排查实例化耗时较高的资源，从而排查和优化资源过于复杂的问题，或者对反复实例化的资源考虑予以缓存。 7.6 激活和隐藏 激活和隐藏的耗时本身不高，但如果单帧的操作次数过多就需要予以关注。可能出于游戏逻辑中的一些判断和条件不够合理，很多项目中往往会出现某一种资源的显隐操作次数过多，且其中SetActive(True)远比SetActive(False)次数多得多、或者反之的现象，亦即存在大量不必要的SetActive调用。由于SetActive API会产生C#和Native的跨层调用，所以一旦数量一多，其耗时仍然是很可观的。针对这种情况，除了应该检查逻辑上是否可以优化外，还可以考虑在逻辑中建立状态缓存，在调用该API之前先判断资源当前的激活状态。相当于使用逻辑的开销代替该API的开销，相对耗时更低一些。 在UWA GOT Online Resource模式下的资源管理页面中可以排查激活隐藏操作较频繁的资源，从而排查和优化相关逻辑和调用。 8. 逻辑代码 逻辑代码的CPU耗时优化更多是结合项目实际需求、考验程序员本人的过程，很难定量定性进行讨论。不过UWA SDK中提供了方便开发者在逻辑代码中进行打点的API&UWA GOT Online，从而将复杂的函数拆解开，在报告中排查堆栈耗时、更快速地验证优化效果。 我们发现有越来越的团队在使用JobSystem将主线程中的部分逻辑代码放入子线程中来进行处理，对于可以并行运算的逻辑，非常推荐将其放入到子线程中来处理，这样可以有效降低主线程CPU处理逻辑运算的压力。 9. Lua GOT Online Lua模式提供的分析Lua造成的CPU耗时工具可视化程度高，堆栈清晰明了，还提供了实用且特色的倒序调用分析功能。以下结合一个Lua报告Demo简单介绍使用该工具分析Lua耗时的方法。 重申：Lua报告中出现的函数名称格式为：函数名称@文件名：行号。 可以通过报告提供的Lua文件名/行号/函数名来定位CPU耗时的瓶颈函数和CPU耗时峰值的具体原因。Lua函数的命名格式为X@Y:Z，其中X是其函数名，在无法获取时，X会变为默认的unknown；Y是该函数定义的文件位置；Z则是该函数被定义的行号。需要注意的是，当Lua脚本以字节码运行时，该值将始终为0，因此建议在测试时尽可能使用Lua源码来运行。 （1）正序调用分析——总表（曲线图+列表） 曲线图： 曲线选取了选取总体Lua代码耗时和按照耗时均值正向排序的前五个函数耗时组成耗时曲线图，每一个数据点代表了该函数在当前帧（横坐标）的耗时（纵坐标），有助于定位耗时瓶颈函数。 列表： 列表默认按照耗时均值从高到低对Lua函数进行了排序，粗略展示了函数名、总CPU耗时、场景CPU耗时、耗时均值等数据。通过点击函数，可以进入对应的单个函数分析页面。 （2）正序调用分析——单个函数页（截图+曲线图+堆栈信息） 截图： 项目运行时截图与使用者选中的帧大致对应，有助于定位问题。 曲线图： 曲线图包括了CPU耗时曲线图和调用次数曲线图；也可以使用下方条缩放曲线观察局部耗时情况。 从曲线图中可以观察到：函数是否存在持续性高耗时；函数是否存在短暂的大量耗时，导致卡顿；某些函数单次耗时并不高，但因为被大量的调用，导致函数总耗时较高。 函数XXXX堆栈信息 （列表）： 其中，可以在右上角选定列表数据的时间范围：总体堆栈信息时，时间范围为全部测试时间；指定场景堆栈信息时，时间范围为指定场景的开启时间；指定帧堆栈信息时，时间范围为当前在曲线图中选中的指定帧。 列表中各项指标含义是：总体占比，以根节点函数的总耗时为100%，当前节点函数总耗时相对根节点函数的总耗时占比；自身占比，以根节点函数的总耗时为100%，当前节点函数自身耗时相对根节点函数的总耗时占比；总耗时，时间范围内执行该函数的耗时；自身耗时，时间范围内去除子节点函数（该函数调用的函数）耗时剩余的耗时；调用次数，时间范围内该函数被调用的次数；单次耗时，总耗时/调用次数，表示每次执行该函数的平均耗时；显著调用帧数，该函数自身耗时大于3ms的帧数。 （3）倒序调用分析——总表（曲线图+列表） 曲线图：与正序调用分析不同的是，选取了自身耗时正向排序的前五个函数，每一个数据点代表了该函数在当前帧（横坐标）的自身耗时（纵坐标）。 列表：与上同理。 （4）倒序调用分析——单个函数页（截图+曲线图+堆栈信息） 函数XXXX堆栈信息 （列表）： 各项指标含义（与正序相比有所不同）变为了：自身占比，以选定函数的自身耗时总和为100%，这条调用路径下选定函数的自身耗时相对选定节点函数总自身耗时的占比；自身耗时，时间范围内，这条调用路径下，选定函数自身耗时的总和；调用次数，这条调用路径的调用次数；单次耗时，代表这条路调用路径下，选定函数的平均耗时。 在通过以上界面定位到自身耗时较高的函数后，常见的优化手段有：优化该函数的函数体，减少该函数自身的耗时；定位调用次数较多的调用路径，减少调用次数。 （5）优化建议 优化单次调用耗时过高的函数的函数体，减少该函数自身的耗时； 定位调用次数较多的调用路径，减少调用次数； 在高调用次数的函数中，尤其关注其中是否同时存在大量Lua和C#之间的穿梭调用，效率较低的同时可能对CPU端的能耗和发热产生大幅度贡献； UWA报告中的Lua函数耗时相当于在进出函数时打点，统计耗时。所以如果Lua脚本运行时调用了C#函数，这部分C#函数是会被统计进去的。 因此，需要关注和C#穿插调用的情况。对于一些反复调用同一个C#函数，或者调用多个C#函数但流程比较固定的情况，应考虑将相关逻辑移入C#层，把需要改动的参数开放。即可能需要多传几个参数，但跨语言穿梭调用的次数能够大幅降低，从而节省开销。 10. ILRuntime ILRuntime的使用率相对低一些，但有些团队延续着之前项目的开发习惯，仍在移动端项目中大规模使用ILRuntime。但根据UWA经验，使用ILRuntime的项目无论是逻辑耗时还是堆内存相关的性能表现都较为糟糕，因此建议尽量不用或少用。在热更方案的选择上，更多项目团队都在使用的Lua，或最近使用率飙升的HybridCLR（huatuo），仅从性能层面来看也都是更好的替代方案。 以下针对已经遇到ILRuntime导致的性能问题，但短时间内无法调换方案的情况，提出一些相对有效的优化手段。 CPU层面： 尤其是战斗部分的逻辑尽量少用，考虑写成固定的C#代码。在ILRuntime代码中如果有大量的密集型的计算或者循环，会导致性能急速的下降。所以在ILRuntime的使用上，可以用来做一些UI的更新，在核心战斗逻辑中避免使用，需要控制力度； 打DLL的时候选择Release的方式； 对于Android平台，可以选择使用Mono的模式打包，然后使用反射的方式进行加载，这样可以做到和原生C#效率相当。 堆内存层面： 尽量少用，尤其是核心战斗模块，因为其对于数值类型的变量也存在装箱的问题； 关闭Debug的宏，可以降低堆内存的分配，在PlayerSettings中定义DISABLE_ILRUNTIME_DEBUG宏。 官方文档提供的一些优化建议可以作为进一步节省ILRuntime开销的思路：https://ourpalm.github.io/ILRuntime/public/v1/guide/FastQA.html 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。
如何兼顾大量小怪渲染时的合批性能和层次感
1）如何兼顾大量小怪渲染时的合批性能和层次感 2）Lua堆内存是怎么触发GC的 这是第468篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 From 问答社区 Q：我们有个类似吸血鬼幸存者的小游戏，画面中有很多怪物，大概有4~5种不同的类型，目前是已经转成了GPU Skinning的方式做渲染，但如果不对怪物做排序，会因为穿插导致无法合批，渲染耗时就很高；而如果做了排序，是能够进行合批，渲染耗时也降下去了，怪物之间却没有了层次感，表现效果也不好，请问该怎么处理？ A：建议做成Texture2DArray版本的材质球，应该纹理顺序就不影响合批了。 欢迎大家转至社区交流： https://answer.uwa4d.com/question/69c0f55a92894f1c4f0c2356 From UWA AI 问答 Q：Lua堆内存是怎么触发GC的？ A：Lua垃圾回收（GC）采用增量式策略，触发机制不依赖真实时间，而是由虚拟机内存分配行为驱动。只有当虚拟机分配新内存时，GC才会推进。 一、触发阈值控制 GC启动由内存使用量倍数决定，通过参数LUA_GCSETPAUSE调节。 默认配置：C API传入值为200。 对应倍率：200%即2倍。 计算逻辑：触发阈值 = 上次GC后内存量*(参数值/100)。 单位校验：若上次GC后内存为100MB，参数值200。 计算过程：100MB*(200/100) = 200MB。 结果：当内存增长至200MB时触发GC。 优化建议：调低该值（如150）可降低内存峰值，但会增加GC频率与CPU开销。 二、回收速度控制 GC推进速度由参数LUA_GCSETSTEPMUL控制。 默认配置：C API传入值为200。 对应倍率：2倍。 作用：表示GC清理速度目标是新增内存速度的两倍，防止内存无限膨胀。 优化建议：数值过低可能导致回收赶不上分配；过高则导致单帧耗时波动增大。 三、监控与优化建议 建议通过性能分析工具观察堆内存曲线。堆内存显著下降意味着发生GC。高频GC降低峰值但增加耗时。若内存持续上涨无下降，需排查泄漏。 优化策略： 减少运行时动态创建Table和闭包，尽量复用对象。 合理预分配内存，避免频繁分配驱动GC。 在加载场景间隙手动调用收集接口，避开帧渲染高峰。 定位高分配热点函数，从代码源头减少内存分配。 调节参数需结合项目内存预算与CPU性能，平衡触发频率与回收速度，实现最佳折衷。 分享链接： https://www.uwa4d.com/main/uwa-shared.html?shareId=dc49a77a-7262-4ca1-98b8-6063b9a13d70 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（下）
《Unity移动端游戏性能优化简谱》从Unity移动端游戏优化的一些基础讨论出发，例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题，并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题，希望能提供给Unity开发者更多高效的研发方法和实战经验。 今天向大家介绍文章第二部分：资源内存、Mono堆内存等常见游戏内存控制，共13小节，包含了纹理资源、网格资源、动画资源、音频资源、材质资源等多个资源内存以及Mono堆内存等常见的游戏内存控制讲解。 本章包含8-13小节，1-7小节请点击《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（上）》。 （全文长约11400字，预计阅读时间约20分钟） 文章第一部分《Unity移动端游戏性能优化简谱之 前言》可戳此回顾，完整内容可前往UWA学堂查看。 8. Render Texture 8.1 渲染分辨率 资源列表中的一些RT资源能反映项目当前的渲染分辨率。对于GPU和渲染模块压力较大的项目，在中低端机型上降低其渲染分辨率是非常直观有效的分级策略。但相关调整比起说影响内存，还是更多地影响GPU片元计算压力和带宽，后文GPU章节会进行更加详细的讨论。 如果一些其他的RT资源分辨率过高也应引起注意，尤其是2048*2048以上的资源。应当排查是否有必要用到如此精细的RT，在低端机上考虑采用更低分辨率的效果。 8.2 抗锯齿 资源列表中展示了RT资源的AA倍数。开启多倍AA会使RT占用内存成倍上升，并对GPU造成压力。建议排查是否有必要开启AA，尤其在中低端机上，可以考虑关闭此效果。 特别的是，在华为部分机型上2倍的AA会失效。即已经造成了性能消耗但没有实际起到抗锯齿效果。 8.3 后处理 一些常见的后处理相关的RT（如Bloom、Blur）是从1/2渲染分辨率开始采样，可以考虑改从1/4开始采样、并减少下采样次数，从而节省内存并降低后处理对渲染的压力。 站在性能优化的角度，在中低端机型上甚至最好分级地关闭各类后处理。围绕一些常见后处理效果的讨论会在下文GPU部分中进一步展开。 8.4 URP下的RT 使用URP时，内存中会多出_CameraColorTexture和_CameraDepthAttachment两份RT资源作为渲染目标，而开启URP相机的CopyDepth和CopyColor设置时会额外产生_CameraDepthTexture和_CameraOpaqueTexture作为中间RT。当资源列表中出现这两种RT时，需要排查确实是否用到CopyDepth和CopyColor，否则应予以关闭以避免不必要的浪费。 9. Shader资源 9.1 ShaderLab Unity 2019.4.20是Shader内存统计方法的一个转折点。在此之前，Shader的内存主要统计在ShaderLab中，而之后则主要统计在Shader资源自身身上。 对于Unity 2019.4.20之前的版本的项目，查看ShaderLab的内存使用Memory Profiler截帧查看。而无论是Shader资源本体还是ShaderLab内存占用过高，都要着手于控制Shader的数量、变体数量和Shader复杂度。 9.2 变体数和复杂度 对于单个Shader资源而言，其内存占用高本质在于其编译后的行数高。因此也可以视为，怎么控制变体的数量和每个变体的平均行数（也就是Shader的复杂度），这两个乘数的问题。 其中，变体数过多是造成一个Shader资源内存占用过大、占用包体过大的主要原因，也是相对比较好和排查和修改的部分。在项目迭代中可能会出现已经被弃用或者没有被实际使用到的关键字，导致变体成倍上升；又或者Shader写的比较复杂，其中一些关键字组合永远不会被用到，从而导致很多变体是多余的。UWA的本地资源检测中提供了Shader检测功能，可以看到变体数量，定位变体数过多的Shader资源。 针对上述情况，Unity提供了回调函数，在项目打AssetBundle包或者Build时线剔除用不到的关键字或关键字组合相关的变体。剔除Shader变体的方法可以参考《Stripping scriptable shader variants》。 而Shader的复杂度同时还涉及到GPU性能相关的优化。具体在后文GPU部分有更加详细的讨论。 9.3 冗余 Shader冗余尤其需要予以关注，Shader的冗余不光导致内存上升，还可能造成重复解析，即运行时不必要的Shader.Parse和Shader.CreateGPUProgram API调用耗时。 一般的做法是，首先使用ShaderVariantCollection这一Unity自带的资源类型收集项目渲染过程中会使用到的变体。将其和Shader依赖打成一个包。在游戏运行过程中统一加载、缓存和Warmup，将Shader.CreateGPUProgram API调用耗时集中在游戏初期或场景切换时完成，避免战斗过程中出现首次加载和参与渲染的Shader变体因而产生耗时卡顿的问题。 收集变体还有一个好处，即可以反推哪些关键字组合或变体是渲染中用不到的，再进行上文的通过回调剔除变体的做法，可以使得Shader整体的内存得到良好的控制。 一般而言，在复杂度和变体数量都控制得比较好、排除了冗余并且剔除了多数用不到的变体的前提下，根据经验，一些对画质需求非常高、PBR Shader也使用得非常重度的移动端项目也完全可以把Shader内存控制在50-60MB，而这种值进行缓存是完全可以接受的。 特别地，以上描述主要对开发者自己写的Shader适用。而内置Shader一般建议放进Project Settings-Graphics中的Always Include Shader列表中以防止冗余；相反地，自己写的Shader则不建议放入，以防止冗余。 9.4 Standard Shader 在资源列表中发现Standard、ParticleSystem/Standard Unlit。这两种Shader变体数量多，其加载耗时会非常高，内存占用也偏大，不建议直接在项目中使用。出现的原因一般是导入的FBX模型中或者Unity自身生成的一些3D对象使用了自带的Default Material，从而依赖了Standard Shader，建议予以排查精简。也可以结合UWA在线AssetBundle检测工具排查是哪个AssetBundle包中哪些资源引用了Standard Shader和ParticleSystem/Standard Unlit。如果确实要使用Standard Shader或ParticleSystem/Standard Unlit，应考虑自己重写一个Shader并只包含自己需要用到的变体。 9.5 使用本地资源检测排查Shader问题 在本地资源检测中包含了“项目中：全局关键字过多的Shader”、“项目中：可能生成变体数过多的Shader”、“Build后：生成变体数过多的Shader”、“使用了Standard Shader的材质”等上文已经提及的检测规则，方便开发者精确定位存在潜在性能问题的Shader资源。 10. 字体资源 若单个字体资源内存占用超过10MB，可以认为该字体资源内存偏大。可以考虑使用FontPruner 字体精简工具或其他字体精简工具，对字体进行瘦身，减小内存占用。 我们也需要关注项目中字体数量过多的情况，因为每个Font会对应一个Font Texture字体纹理，所以字体资源数量多了，Font Texture的数量也多了，从而占用较多内存。 11. 粒子系统 将UWA的粒子系统曲线结合粒子系统资源列表来看。会发现很多项目的内存中粒子的数量会远远高于实际Playing的粒子数量，在曲线中表现为蓝色曲线要比紫色曲线高出很多；在资源列表中表现出有大量资源在内存中的数量峰值远高于Playing数量峰值。这说明粒子系统内存很可能存在大量的缓存浪费。 此时一方面需要检查是否存在迭代过程中有被弃用但未删除的粒子系统对象、或制作过程中测试过的组件但未解除依赖的粒子系统组件。尽管是Deactive状态，但仍会随着Prefab进入包体、加载进场景，从而进入内存；另一方面则可以考虑优化对粒子的缓存策略，减少不必要的粒子缓存，尽量做到播多少粒子就缓存多少粒子。 12. Mono堆内存 UWA GOT Online Mono模式报告提供了堆内存具体分配和堆内存泄露分析两个主要功能，供开发者分析项目中堆内存存在的问题。 12.1 原理简述 对于游戏项目来说，除了上述常见的资源导致的内存占用外，还有一部分大头来自于代码相关的占用。 Mono堆内存是Unity C#脚本运行时的核心内存区域，主要用于托管对象（如string、List、自定义类实例、委托等）的动态分配与管理。除了驻留量高同样会和其它内存问题一样影响闪退风险外，堆内存的分配情况不合理还会直接影响GC（垃圾回收）耗时、帧稳定性，也需予以关注。 机制上，+Unity采用分代式垃圾回收机制管理Mono堆，核心逻辑可分为三步： 对象分配：C#中new创建的引用类型对象、值类型装箱、字符串拼接等操作，均会从Mono堆中申请内存空间。堆内存以连续内存块形式管理，优先使用空闲区域分配新对象。 堆扩容：当堆内空闲空间不足时，Unity不会立即触发GC，而是向系统申请扩容堆内存（默认扩容幅度为当前堆大小的50%~100%），直至达到设备内存上限。 垃圾回收：当堆内存占用达到阈值、或手动调用GC.Collect()时，GC会遍历所有托管对象，标记不再被引用的对象，在连续约6次GC都不被引用时，释放其内存，回收的空间会重新加入堆的空闲列表，供后续分配使用。 这就涉及到堆内存分析中三个相当常见的问题： 堆内存的构成。我们不妨延续2.1章节的讨论。如图，结合Memory Profiler截图来看，Mono堆内存占用（即Reserved Mono）实际上主要分为3个部分，即依次为预留值（210.9-201.5的部分）、实际对象占用（Objects）、碎片（Empty Fragmented Heap Space）。开发者可控的部分基本只有对象实际占用部分，从上文的扩容机制来看、这部分基数越高则预留的部分也会越高。因此优化对象实际占用对内存的降低效果往往大于表面数值。 堆内存碎片。结合以上堆的扩容机制，游戏过程中常存在高频的堆内存分配，则在大块对象被释放后立刻分配小对象，即很容易出现碎片。在堆内存分配释放较为频繁的项目中，UWA常观察到对象实际占用的部分和碎片会逐渐趋于相等，因此只能通过尽量避免大小对象频繁交错分配释放的方式，减缓碎片逐渐上升的趋势；且与预留值同样地、降低对象实际占用仍是主要的优化对象 “Mono 堆内存只增不降”的错误说法。时不时开发者向UWA如此提问：测试报告中Reserved Mono曲线为什么是有增有降的？按网上普遍的说法，Mono堆内存不该是只增不降的吗？事实上，GC回收的空闲内存会保留在堆内供复用，若堆内空闲空间长期过剩，Unity会在特定时机（如低内存告警、场景切换）主动将多余空闲内存归还系统，实现堆内存收缩。也即，造成堆内存主要占用的内存页是会返还给系统的，仅有地址空间是只增不降的。 事实胜于雄辩。如图为2022.3.60版本下使用IL2CPP打包的测试，分配了1GB大小的String类型堆内存对象并在一段时间后释放，并适当调用GC。可以观察到无论是Reserved Mono还是整个进程的PSS Total都得到了正常释放。 对于堆内存分配和驻留不合理导致的性能问题，UWA GOT Online Mono模式报告都有相应的子功能定位和分析具体问题。 12.2 持续/峰值分配堆栈 在堆内存具体分配页面中，可以排查高堆内存分配函数的具体堆栈。我们主要关注两种形式的堆内存分配。 一种是单次过高的堆内存分配。这种峰值一般出现在游戏初期的读表操作导致的大量分配，需要开发者结合具体堆栈信息排查是否合理。而游戏运行过程如果还出现堆内存分配峰值则需要着重关注。 另一种则是持续偏高的堆内存分配。如果项目中存在每帧或者每隔几帧就分配较多堆内存的现象需要引起注意。持续的高堆内存分配会导致GC频率增高，从而在游戏中形成频繁的卡顿，可以结合堆栈排查是什么子节点在持续分配堆内存。 12.2 泄露分析 在泄露分析页面中排查项目中各个函数的堆内存驻留情况。选中图表中前后两处采样帧进行比较，就可以从堆栈中查看堆内存驻留情况的变化，查看驻留上升主要是什么堆栈分配造成的。 一方面可以避免堆内存持续上升造成泄露的风险，另一方面针对驻留高的函数进行优化，予以及时释放，可以降低单次GC的耗时。我们一般推荐测试GOT Online Mono模式的测试时长尽量长一些，比如1个小时，否则泄露问题往往难以被暴露。 13. 其他内存 我们在处理完相对可控的Unity分配的Reserved Total部分内存后，仍然不能掉以轻心。常见情况下，我们在内存部分一开始介绍的PSS Total值是要比Reserved Total值高出200-300MB，可以认为是在接受范围内。但是，也会有项目出现这两个数值差值高出500-600MB甚至更高的情况。 13.1 Lua 内存 UWA GOT Online Lua模式提供了针对Lua脚本语言的性能测试。 其中出现的函数名称格式为：函数名称@文件名：行号。 可以通过报告提供的Lua文件名/行号/函数名来定位CPU耗时的瓶颈函数和CPU耗时峰值的具体原因。Lua函数的命名格式为X@Y:Z，其中X是其函数名，在无法获取时，X会变为默认的unknown；Y是该函数定义的文件位置；Z则是该函数被定义的行号。需要注意的是，当Lua脚本以字节码运行时，该值将始终为0，因此建议在测试时尽可能使用Lua源码来运行。 针对Lua分配的内存，报告在总体堆内存、Lua内存峰值分页中的折线图每30帧采一次值。根据折线图走势，帮助开发者对项目运行过程中的堆内存分配情况有大致的了解。其中，堆内存的下降意味着发生了一次GC。相对高频的GC使得Lua内存峰值变低，但也会有相应的额外耗时开销。关于Lua GC机制和策略对内存的具体影响，下文还将具体讨论。从下图的示例曲线中就可以看出，该项目的Lua内存在整体测试流程中存在非常显著的上升趋势，不过所幸总体基数很小，如果这里是从70MB持续上升至120MB，就需要重点关注Lua模块中可能的泄露风险了。 还可以注意到在总体堆内存页面中还有对Table、Function、Userdata的统计，提供一些辅助信息。整体的内存变化趋势其实开发者已经可以从刚才的曲线中窥知一二了，那这些具体的数据结构的数量统计意义在于，一方面可以推测是那种数据结构的上升趋势相同，那么它可能就是罪魁祸首，为之后的分析提供参考和抓手；另一方面则是对数量尤其高的类型产生警惕，比如大部分项目Table数量主体一般在10w-20w，但有的项目则达到了80w-90w的驻留，这就存在比较大的排查和优化空间了。 其中，查看内存具体分配功能和Mono模式报告大同小异。 Lua模式报告中还有一个重要功能，即Mono对象引用统计。该功能的意义就在于辅助开发者排查Lua端GC机制导致的Mono堆内存泄露风险，具体的解释将在下文讨论。 在柱状体选择对应帧后，列表中会显示该帧的Mono对象类型列表。其中： 对象类型：表示Unity Object对象的具体类型； 对象个数：表示这种类型的对象个数； Destroyed对象个数：表示已经被Destroyed，但Lua层还有相关引用的这种类型的对象个数。需要关注Destroyed对象个数，如果数目较大，C#堆内存存在泄露风险。 13.2 Lua GC 对于现代编程语言的自动化内存管理机制而言，为了实现在内存不再使用的时候能够自动释放它们的目的，有两种主流做法：引用计数和扫描标记。Lua使用的是基于后者的GC机制，对于动态语言来说，引用计数带来的额外开销太大，尤其是即使一段程序完全不分配内存，也需要承担这额外开销。Lua的垃圾收集器本身并不负责内存分配释放，内存的底层管理是通过在创建Lua虚拟机时从外部注入的分配器完成的。虚拟机工作时所有产生的对象都被串在一个链表上组成一个集合，而被虚拟机根集间接引用的对象都会被保留，剩下的对象引用无法被根集引用，则会在恰当的时机回收。虚拟机的根集包括了注册表，以及原生类型的metatable。而全局表、主线程、标准库的代码等等，都被注册表所引用。简单来说，就是扫描并标记了无引用的对象，并在适当的时候回收。 在Lua 5.0以前，Lua使用的是一个非常简单的标记扫描算法。它从根集开始遍历对象，把能遍历到的对象标记为活对象；然后再遍历通过分配器分配出来的对象全集链表，把没有标记为活对象的其它对象都删除。 从Lua 5.1开始实现了一个步进式垃圾收集器。这个新的垃圾收集器会在虚拟机的正常指令逻辑间交错分布运行，尽量把每步的执行时间减到合理的范围。Lua 5.1采用了一种三色标记的算法。每个对象都有三个状态：无法被访问到的对象是白色；可访问到，但没有递归扫描完全的对象是灰色；完全扫描过的对象是黑色。基于此，我们可以假定在任何时间点，下列条件始终成立： 所有被根集引用的对象要么是黑色，要么是灰色的。 黑色的对象不可能指向白色的。 白色对象集就是会被回收的部分；黑色对象集就是需要保留的部分；灰色对象集还需要被进一步遍历。 那么，在垃圾收集器工作过程中，通过充分遍历灰色对象，就可以把它们转变为黑色对象，直至所有灰色对象消失，收集过程也就完成了。 接下来，Lua GC的时机究竟是什么时候呢？事实上，GC的步进并不和真实的时间相关，它只和虚拟机分配新的内存有关。也就是说，只要虚拟机不分配更多内存，GC是不会自动运行的。GC依靠新增内存量来决定该做多少工作，它也依靠标记或清理的内存量来决定工作做了多少。每次做多了，会导致程序更多额外的停顿，做少了，会导致内存回收速度赶不上新增速度。步进式GC比全量GC复杂之处在于，它不能再只用一个量来控制GC的工作时间。对于全量GC，我们能调节的是GC的发生时机，对于Lua 5.0，就是2倍上次GC后的内存使用量；在5.1以后的版本中，这倍数可以由参数LUA_GCSETPAUSE调节。另外增加了LUA_GCSETSTEPMUL来控制GC推进的速度，默认是2，也就是新增内存速度的两倍。Lua用扫描内存的字节数和清理内存的字节数作为衡量工作进度的标准，有在使用的内存需要标记，没在使用的内存需要清理，GC一个循环大约就需要处理所有已分配的内存数量的总量的工作。这里2是个经验数字，通常可以保证内存清理流程可以跑的比新增要快。 举例而言，默认不做任何修改的状况下，上次GC回收后是100MB，那么Lua就会等到内存一直达到200MB时才会进行下一次GC。这种默认的频率有其风险性，而实际上是可以通过LUA_GCSETPAUSE调节的——当开发者结合数据判断当前GC频率过低而一定程度上导致了Lua内存峰值偏高时，完全可以考虑调低这个参数来提高GC的频率。 13.3 Lua的Mono对象引用 在使用Lua进行游戏开发时，肯定会涉及到去访问一些Unity（或者说C#的）的对象。此时，我们在Lua中写了Object.Instantiate来实例化一个对象出来，实际上在Lua层会基于各个类自动生成Wrapper代码，把所有的接口都额外包一层，来把这些Unity的接口注册到Lua中。 在Lua中调这些函数时，会传入Lua的状态机到这些Wrapper代码中，就可以通过这个状态机取得Lua这头的数据，比如能根据栈中参数的数量和类型去判断实际上是要调函数的哪个重载。 继续以Object.Instantiate为例，调用完该函数进行实例化以后，返回的对象在Unity这一层实际上就已经进了Scene场景，产生了引用。但不难想象到一种情况是，当我们自己的Lua逻辑代码New了一个对象，在C#端却没有任何地方会引用到这个对象，那么当当前函数直接Return后，这个对象就会在下次GC被回收掉，但你实际上很有可能还是要在Lua端访问它的。即这个对象的生命周期应该是Lua端在管，而C#端得先建立引用，否则C#端的GC将予以回收。 所以，一定要通知C#端去把它引用起来，即会做一个Push操作来把这个对象放在一个容器中。但这样为了不GC掉可能要用到的对象而进行的操作，反过来又有了另一种风险：C#端可能不知道什么时候该回收这部分内存了，从而导致Mono堆内存越来越高的泄露问题。这时就要靠Lua的GC来回收存储的Userdata，从而也回收掉C#端对应的引用。 GOT Online的Lua模式中，就可以通过Mono引用对象统计页面中进行分析。由于采集时主动定时调用了GC，所以在图表中仍然统计到的对象，就是Lua端没有解引用、短期内回收不掉的。数量不宜过高。 重点在于同时统计的Destroy对象数量。它统计了上述对象中其实在场景里已经被销毁的对象。但这些对象还在容器中、还占着堆内存，尤其数量越来越高时就可能存在Mono堆内存的泄露了，需要结合GOT Online Mono报告的情况进行综合分析。解决的方法就需要在Lua中找到相关对象的引用链，在Lua中及时释放。 所以，UWA的Lua专项报告提供的Mono对象引用柱状图中，黑色部分表示未被Destroyed的对象数目，由于受到Lua端GC的影响，导致会有一些Destroyed对象。这时候就要注意它是否是趋于稳定的，如果持续上涨就需要引起重视。 13.4 Lua配置表优化 Lua除了用于业务逻辑外，还有一种常用的用法，就是数据存储，也即配置文件。一般来说配置文件的初始来源是策划同学以Excel表格形式维护的，经由程序员提供的导出工具，把Excel的表格数据导出成为游戏能直接读取使用的Lua源码。 这些源码文件以Lua table的形式存储与Excel等价的数据，通常可以简单把这个配置表看成是一组2维数组，转换成配置就是一个key（Excel第一列）对应一组子数据（Excel中一行），那么整个配置数据就是一个大表包含着若干小表。 如果配置文件中的数据过大，或着是有冗余的无用数据，那么势必会导致输出的Lua文件过大，这将严重影响加载速度和增大内存占用量。冗余的原因在于： 大量的数据是重复的，或着是代表没有意义的空值（比如0，[]等）； 大量的中文字符串是需要游戏后期做本地化处理的； 很多复合型数据（子表，数组）内容是一样的。 基于此的优化方案为： 对于Excel中的一列，出现次数最多的值认定为默认值，然后把它从Lua表中剔除掉，然后利用metatable机制实现全局默认值存储； 对于中文字符串，替换为一个唯一的ID标识，写回到Lua表中，读取的时候加上相应的查找替换； 对于一些复杂的子表或着数组，做唯一化替换处理，替换后写回到原始数据中。 上面的操作其实做的都是唯一化处理，所以有个要求就是整个Lua表的数据必须是只读的，如果不满足以上条件，一切优化都是错误的。这样做势必会降低一些可读性，一些被多处引用的子表已经被替换成了一个变量，这些变量是以Local的形式存储在作用域的。由于Lua本身的一些限制，一个作用域内能够存放的最大Local变量的个数是200个（lparser.c #define MAXVARS 200），所以超过个数限制的多余部分表会被放入一个临时数组中，初始化的时候需要额外的查表，稍微多一些开销，但可以接受。 13.5 Native Heap优化 如2.1章节讨论的，当通过adb dumpsys meminfo发现Native Heap持续走高、且已排除第三方插件与Lua模块的明显问题后，Perfetto是定位原生堆内存分配与泄漏的核心工具，尤其适用于IL2CPP打包、引擎底层或自定义C++插件导致的Native内存异常。该工具基于Android系统底层Heapprofd，可采样malloc/free调用栈，以火焰图直观呈现内存分配来源，弥补Unity Memory Profiler无法追踪Untracked部分中Native Heap内存的短板。 前置条件 设备系统：Android 10及以上；非UserDebug设备需将游戏APK设置为Debuggable或Profileable。 工具准备：下载Perfetto工具包，配置Python环境；确保adb可正常连接设备并识别游戏进程。 采集步骤 启动游戏并进入内存异常场景，通过adb shell ps获取游戏包名/进程 ID。 执行Perfetto堆采样命令：python perfetto/tools/heap_profile -n游戏包名，启动Native内存追踪；首次运行会自动下载依赖工具，需保证网络通畅。 复现内存增长场景（如反复切换场景、加载资源、运行核心玩法），过程中可通过adb shell killall -USR1 heapprofd手动触发多轮内存快照，便于后续对比。 追踪结束后停止命令，导出raw-trace文件，在Perfetto官方在线分析平台（ui.perfetto.dev）打开文件。 结果解读与问题定位 核心视图：优先查看Unreleased Malloc Size（未释放内存大小），按内存占用排序调用栈，定位持续持有内存的分配路径；Total Malloc Size可用于排查频繁分配释放导致的内存抖动与碎片。 火焰图分析：色块宽度代表内存占用量，自上而下为调用链路。重点关注IL2CPP虚拟机、Unity引擎底层（如Rendering、Audio模块）、自定义C++插件对应的色块，若某一路径未释放内存持续增长，基本可判定为泄漏或不合理驻留。 符号化解析：若调用栈显示为乱码地址，需注意是否以IL2CPP Debuggable模式打包；或配置llvm-symbolizer并指定符号文件，将地址映射为具体函数名，精准定位代码行。 常见问题和优化方向 引擎触发的泄漏：若定位到和Unity原生模块有关的占用（如有时会观察到AssetBundle序列化信息），需检查相关缓存机制。 外部C++代码占用：Lua、HybridCLR等方案的部分版本，或有些项目自己写了一些C++代码，都可能会导致一定的Native Heap占用，可在堆栈中观察到相应的分配函数、对象构造节点，从而予以定位优化。 渲染线程下图形API相关占用：最常见的有Shader运行时生成的GPUProgram信息；还有的项目因引擎设置或渲染功能导致的一些Buffer占用等。 13.6 .so mmap优化 .so mmap是Android系统对共享库文件的内存映射，属于“Untracked Memory”的重要组成部分，在游戏项目中，主要包含libunity、libil2cpp、插件、第三方SDK的.so文件。在中低端机型上，代码量过高或插件过多导致的.so mmap占用过高同样会挤压游戏运行内存，提升OOM闪退风险。 连接真机，执行adb shell dumpsys meminfo游戏包名，查看.so mmap对应的PSS占用，若超过设备总内存的10%或超过200MB，需予以关注。 只观察到.so文件的总占用显然不足以具体定位问题，还是需要进一步拆分具体是哪些库文件占用过高。 首先对硬盘中的文件进行静态分析，可解压APK安装包，提取lib目录下所有.so文件，统计文件数量与体积。 紧接着结合运行时的数据进行映射关系核对，通过adb shell cat/proc/游戏进程ID/maps，查看内存映射表，对比lib目录下的.so文件，确认运行时的实际占用，并检查是否存在未使用的.so被强制加载、重复映射或加载后未卸载等情况。 优化上，可优先减少浪费：按发行目标机型保留主流架构（如国内市场仅保留arm64-v8a）；删除已弃用SDK的.so文件；剔除调试、测试用的工具类.so。 若libil2cpp.so体积过大，需检查编译打包时是否进行了合理的代码裁剪设置，避免最终包含大量未使用的引擎模块、第三方库代码。如，可尝试在Player Settings中开启Strip Engine Code，选择High级别的代码剥离；移除项目中未使用的Unity模块（如AR、VR、物理模块）。 若项目用到Lua等类似插件，也会出现liblua这类库文件占用过高的现象，同样也应关注代码量、代码裁剪是否合理。 针对以上代码导致的内存占用，还可检查是否存在以下不合理的情况，也都会导致.so文件内存占用过高： 泛型Template用的是否较多； 自动化生产的代码是否较多； Lua生产的Wrapper代码是否较多。 13.7 Wwise音频内存优化 Wwise作为移动端主流音频插件之一，采用事件驱动+SoundBank模块化架构，其内存管理独立于Unity Native内存与Mono堆内存，往往观察到其内存分配时主要体现在系统层Native Heap和Unknown部分内存的上升，一般可使用Wwise自带的编辑器工具分析、设置和优化内存。Wwise音频内存由元数据内存、媒体内存、引擎运行时内存、I/O与流送内存四部分构成。 元数据内存。加载Bank时分配，包含Event、Switch/State、RTPC、混音图、触发规则等结构，占用小但决定音频逻辑链路，卸载Bank可完整释放。 媒体内存。音频波形数据（WEM）的驻留区，是音频内存大头。和引擎自带音频累死地，需使用合理的加载方式。 引擎运行时内存。播放实例、路径计算、3D空间化、混音器缓冲区等动态开销，随播放启停自动分配与回收，受初始化设置中最大播放数、最大路径数约束。 I/O与流送内存。流送音乐的预读缓冲区与Bank异步IO缓存，大小可按平台配置，不使用时可收缩归还系统。 在UWA观察到的使用Wwise的项目中，常见的性能问题和一般优化方案如下： Bank粒度失控。大Bank全量加载，整个Bank中可能只有个别音频同时被用到，而大多数音频对象用不到却随该大Bank长期驻留；过于碎片化的小Bank则可能导致元数据与IO开销叠加。需尽量按场景/关卡/功能拆分Bank，并配套加载前预载、切换后卸载的生命周期控制；高频通用音效（UI、点击、脚步）则打入SharedBank，全局一次加载。 加载/卸载不匹配：只加载不卸载、重复加载、跨场景残留引用，引发媒体内存泄漏；需结合Wwise Profiler监控相关资源缓存加载策略是否合理。 对象设置不合理：长音乐错误地用全额加载、高频短音效反而用流送，造成内存暴涨或解码卡顿。长音频应采用流式以降低内存；反复播放的短音频则采用全额加载以避免反复触发耗时。 初始化参数不合理。默认内存池、播放数、路径数、IO缓冲区过大，中低端机内存浪费。应同样地针对不同内存分档设计不同的策略和设置。 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 总览 UE5的GC，相对UE4做了一些改动，但本质没变，依然是基于UObject的标记清扫算法，许多改动都属于优化。 先看GC的各个阶段： 遍历所有Object，标记为不可达 改动较少，增加了RefCount引用方式；更改了Unreachable Flag；增加了单独的RootArray作为根集。 遍历引用关系做可达性分析 改动较多，Batch模式的引用收集；UObject对ClassPrivate和Outer的引用从Token中移除；增加了引用收集的Debug模式。 清理不可达Object 差别不大，依然是分帧Purge，对每个UObject依次执行BeginDestroy, Destroy, FinishDestroy。 除此之外，还有实验性的Increment可达性分析模式，想完全避免可达性分析造成的一帧卡顿，算是一个最大的不同。 对于GC Cluster，变化不大。 另外还有一些其他Tips，比如IsPendingKill接口改成了IsGarbage；ObjectFlags增加了Reachable0，Reachable1，Reachable2等等。 文章中所使用的为UE5.7。 前篇：《UE4垃圾回收》 一、加入引用计数机制 1. RefCounted Flag & RefCount 观察InternalObjectFlags，发现多了一个叫RefCounted的Flag，说明UE的GC在标记清扫的基础上，又加入了引用计数机制，算是一个不小的改动。 引用计数是另一种GC方式，Python中就使用了，简单概括为每个Object记录自己被多少个其他Object引用了，当发现引用计数为0时，就判定自己为垃圾。至于垃圾的清理，可以选择为0时立即清理，也可以选择之后一起清理，反正垃圾是不会再被重新引用到的。 enum class EInternalObjectFlags : int32 { None = 0, //... RefCounted UE_DEPRECATED(5.7, "Use GetRefCount() to determine if a refcount exists instead.") = 1 << 29, ///< Object currently has ref-counts associated with it. //... }; 但有了Flag还不够，得给每个Object再定义一个RefCount属性，记录被引用的次数。UE5.7之前，在FUObjectItem中有一个专门的int32 RefCount属性，但UE5.7开始把RefCount和Flags一起打包成了int64。Flag存在高32位里，RefCount存在低32位里。 这样有个好处，RefCount通常用不了int32这么大，因此可以把更多位数给EInternalObjectFlags用，比如添加一些我们自定义的Flag，或者UE引擎自己扩展。 2. 操作接口 添加RefCount： void UObjectBase::AddRef() const { FUObjectItem* ObjectItem = GUObjectArray.ObjectToObjectItem(this); ObjectItem->AddRef(); } 减去RefCount： void UObjectBase::ReleaseRef() const { FUObjectItem* ObjectItem = GUObjectArray.ObjectToObjectItem(this); ObjectItem->ReleaseRef(); } 获取RefCount： 在引擎内部代码，比如GC过程中，才有获取RefCount的需求，因此UObject上没有获取RefCount的接口，只有FUObjectItem上有。 FORCEINLINE int32 GetRefCount() const { return RefCount; } 哪些地方会给UObject加上引用计数？ 引用计数管理UObject显然更容易出错，万一AddRef/ReleaseRef不成对，就会造成UObject泄漏，引擎中目前也只有StrongObjectPtr使用了。而且是用了RAII模式，在构造和析构中操作RefCount，防止泄漏。StrongObjectPtr可以对一个UObject添加强引用，防止被GC，和AddToRoot类似。下面是StrongObjectPtr中操作的代码： FORCEINLINE_DEBUGGABLE void Reset(ObjectType* InNewObject) { if (InNewObject) { if (Object == InNewObject) { return; } if (Object) { // UObject type is forward declared, ReleaseRef() is not known. // So move the implementation to the cpp file instead. UEStrongObjectPtr_Private::ReleaseUObject(Object); } InNewObject->AddRef(); Object = InNewObject; } else { Reset(); } } 3. 为什么要使用引用计数管理UObject 一个原因可能是能减少可达性分析时间。 可达性分析阶段需要遍历Object间的所有引用关系，耗时正比于引用数量。 比如下图，4个UObject相互引用，总共要遍历10个引用关系。但假如都用引用计数管理，可以省略遍历开销，直接判定四个对象都可达。 但是理论上讲，引用计数方式只是把遍历开销分摊到了运行时维护引用计数的加减上。而且注意到上面例子其实是环形引用的情况，没有外部引用情况下，四个UObject都该被判定为垃圾才对。因此一个完善的垃圾回收实现，不会只用引用计数，比如Python的FullGC就用的标记-清扫算法，作为辅助。UE依然把标记-清扫作为主GC流程，引用计数只是小小的辅助，但使用是绝对要注意。 另一个原因是单独针对StrongObjectPtr的优化。 StrongObjectPtr之前的实现是把包起来的UObject单独放到一个大数组里，然后这个数组被一个FGCObject添加引用。这样有个问题，当数组元素很多时，添加需要遍历全数组，判断是否已添加过，而删除时同样需要遍历全数组，找到元素并删除，遍历的时间很可观。 4. RefCount如何影响可达性分析 其实RefCount等同于Root，观察AddRef调用链，最终会执行AddToRoot操作。 在第一步标记所有UObject不可达后，会遍历GRoots容器，把其中的UObject变成可达，那么RefCounted的Object也变成可达了。 二、可达性分析阶段的优化 1. FPrefetchingObjectIterator 首先介绍一个挺细节的ObjectIterator，基于经验做了Prefetch，尽量避免访问Object的Outer和ClassPrivate造成的CacheMiss。整个可达性分析阶段都充分应用了Prefetch技术。 首先观察下面代码，会遍历一组UObject，然后分别访问它们的Class和Outer： FORCEINLINE_DEBUGGABLE void ProcessObjects(DispatcherType& Dispatcher, TConstArrayView<UObject*> CurrentObjects) { for (FPrefetchingObjectIterator It(CurrentObjects); It.HasMore(); It.Advance()) { UObject* CurrentObject = It.GetCurrentObject(); UClass* Class = CurrentObject->GetClass(); UObject* Outer = CurrentObject->GetOuter(); //... } } 这样必然会导致读Object内存操作，相比读CPU Cache肯定是更慢的。粗略估计，访问内存的延迟是60ns，而访问CPU L1 Cache的延迟是1ns，当中相差不少。 既然已经知道要访问这么多Object的ClassPrivate和Outer属性，那么能不能提前把它们读取到CPU Cache中，加快访问速度？这就是FPrefetchingObjectIterator做的事情，使用了操作系统的Prefetch接口，异步的提出一个预读请求，不需要当场等待内容返回到CPU Cache，CPU可以继续执行后面指令。 FPrefetchingObjectIterator迭代器在++时，会Prefetch后面第六个的Outer和ClassPrivate的Schema，以及后面第16个的Class。这些数字都是经验值，既需要保证访问到这块内存时，其已经被加载到了CPU Cache，又不能Prefetch太多，从而挤占CPU Cache。 示意图如下： 2. 基于Batch的Object引用收集 Batch UE4怎么做的： 给定一个待分析可达性的Objects数组，会不断遍历其引用，做广度优先搜索，最终遍历完所有可达的UObject，完成任务。其中碰到StructArray结构时，还会形成类似递归的情况，处理代码更复杂些。整个过程由一个大的ProcessObjectArray函数实现，总共有600+行。 UE5的Batch： UE5使用了Batch的遍历方式，首先把ProcessObjectArray逻辑拆成多个函数，函数逻辑精简，最长不超过90行，对CPU指令Cache更友好，然后多个函数形成执行流；大概500个Object作为一个Batch，依次经过这些执行流，更少的数据量对应更少的内存使用量，让CPU Cache足够应对；另外每段处理函数逻辑变简单后，可以更容易地使用上面提到的Prefetch技术做Cache预取，进一步提升效率。 Schema 首先，UE5加入了Schema的概念，其实就是UE4里的TokenStream，记录Object带引用的各成员地址偏移，以及成员类型。 ProcessObjectArray 下面是ProcessObjectsArray的主要流程，首先执行ProcessObjects，收集引用到Dispatcher，Dispatcher的具体类型为BatchedDispatcher。CurrentObjects处理完后，再尝试从全部的ObjectsToSerialize数组中取出BlockSize数量的UObject，继续执行ProcessObjects做引用收集。如果ObjectsToSerialize也空了，就执行FlushWork，把引用到的UObject都添加到ObjectsToSerialize中继续。 这样就能把标记阶段分成多个Pass，每个Pass处理一点Object，流程示意图如下： ProcessObjects ProcessObjects用于收集CurrentObjects的所有引用，FPrefetchingObjectIterator已在上面提过。注意到这里把Class和Outer的处理单独提出来了，UE4还是在正常的Schema里处理它们。可能是考虑到Class和Outer是每个UObject必备的引用，提出来统一处理能减小Schema大小。 VisitMembers函数是真正的处理Schema里定义的各种引用，包括UPROPERTY引用单个Object，TArray数组引用等等，这里只给出单个Object引用和StructArray引用两个常见例子。 VisitStructArray只会把StructArray缓存到对应容器中，后面Flush时再处理： FORCEINLINE_DEBUGGABLE void QueueStructArray(FSchemaView Schema, uint8* Data, int32 Num) { StructBatcher.PushStructArray(Schema, Data, Num); } ImmutableReference & KillableReference 注意到对于单个UObject的引用，分了ImmutableReference和KillableReference(FMutableReference)，其实只有UObject对Class和Outer的引用属于Immutable，其他都属于Killable。这对UE4而言并没有提出新的内容，只是老内容包装成了新概念。 比如有如下代码，想强制删除B，但B又是A的属性，被引用了 A->Prop = B; B->MarkAsGarbage(); 对于Class和Outer引用，非常强力，强到能阻止对B的强删，让B继续以Garbage的形式存在。 对于普通的KillableReference，无法阻止，同时A->Prop属性会变成nullptr。 因此查看它们的初始化方式，FMutableReference是存储了指向属性的指针。 // Retains address of reference to allow killing it struct FMutableReference { UObject** Object; }; struct FImmutableReference { UObject* Object; }; FlushWork 依次处理StructArray和其他Object引用，主要工作就是遍历各种Array。 FORCEINLINE_DEBUGGABLE void FlushWork(DispatcherType& Dispatcher) { if constexpr (DispatcherType::bBatching) { if (Dispatcher.FlushToStructBlocks()) { ProcessStructs(Dispatcher); } Dispatcher.FlushQueuedReferences(); } } 最终通过HandleValidReference函数，把UObject标记为可达，并插入到ObjectsToSerialize数组中，以待后续遍历。 三、配套Debug功能的完善 1. gc.history协助找泄露 其中一个比较实用的功能是可以记录下上次GC过程中，每个对象被谁引用了，方便排查UObject泄露问题。 看个例子，假如有如下代码： FActorSpawnParameters SpawnParameters; SpawnParameters.Name = TEXT("TestObject123"); APawn* TestPawn = GetWorld()->SpawnActor<APawn>(SpawnParameters); USceneComponent* TempObject = NewObject<USceneComponent>(TestPawn, USceneComponent::StaticClass()); TempObject->RegisterComponent(); { TStrongObjectPtr<UObject> StrongPtr(TempObject); TestPawn->Destroy(); CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS, true); TempObject->MarkAsGarbage(); } GEngine->Exec(GetWorld(), TEXT("OBJ Refs Name=TestObject123")); 在有StrongObjectPtr的情况下，即使MarkAsGarbage，这次GC也是删不掉TempObject和TestPawn的，在严格的逻辑下，算是UObject泄露。Obj Refs的输出也印证了： LogReferenceChain: (Garbage) Pawn /Game/UEDPIE_0_Zoo.Zoo:PersistentLevel.TestObject123 is not currently reachable. Try using GC history to debug transient leaks with 'gc.historysize 1' 输出中只能看到UObject泄露了，但打印Obj Refs时，引用现场已经没了，不知道为什么泄露。过去，要调试这个泄露，得先记下Object地址，再去GC下数据断点，再对Flags下断点等等操作，非常麻烦。 现在UE5加了HistorySize的Debug功能，会对上次GC存储Snapshot，之后再输出。 首先通过如下控制台指令开启： gc.ForceEnableGCProcessor gc.Historysize 1 然后把Exec代码改成： FReferenceChainSearch::FindAndPrintStaleReferencesToObject(TestPawn, EPrintStaleReferencesOptions::Log); 接着就能从GC History中获取到历史GC引用了，快速定位到泄露原因，非常方便： LogLoad: Old Pawn /Game/UEDPIE_0_Zoo.Zoo:PersistentLevel.TestObject123 not cleaned up by GC! Garbage object SceneComponent /Game/UEDPIE_0_Zoo.Zoo:PersistentLevel.TestObject123.SceneComponent_0 was previously being referenced by NULL: (refcounted<1>) (Garbage) SceneComponent /Game/UEDPIE_0_Zoo.Zoo:PersistentLevel.TestObject123.SceneComponent_0 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ This reference is preventing the old Pawn from being GC'd ^ -> UObject* UObject::Outer = (Garbage) Pawn /Game/UEDPIE_0_Zoo.Zoo:PersistentLevel.TestObject123 2. 实现原理 UE5中，新增了用于Debug的TDebugReachabilityProcessor，在可达性分析阶段，当第一次遍历到一个Object时，把当前路径记录下来即可。 四、增量式可达性分析 1. 为什么要做增量式可达性分析 增量式的垃圾回收，目前还是Experimental阶段，个人感觉有点意义不明。 Documentation | Epic Developer Community 一个典型例子是Lua虚拟机实现，采用了黑白灰三色标记，UE的实现也比较类似。 开启方式： DefaultEngine.ini加入下面配置： [ConsoleVariables] gc.AllowIncrementalReachability=1 ; enables Incremental Reachability Analysis gc.AllowIncrementalGather=1 ; enables Incremental Gather Unreachable Objects gc.IncrementalReachabilityTimeLimit=0.002 ; sets the soft time limit to 2ms 为什么有必要把可达性分析阶段改成增量式的？ GC耗时可以分成三个部分：所有UObject先标记不可达，可达性分析，销毁垃圾。其中，销毁垃圾本身是分帧的，不会造成卡顿。前两个部分过去不能分帧，而可达性分析阶段是耗时主要部分，通常超过第一部分十倍，如果游戏希望维持60帧或更高帧率运行，这绝对会是一个阻碍。为了缓解这个卡顿，UE很早就提出了Cluster优化方案，现在又进一步提出了增量GC方案。 为什么之前可达性分析不能分帧执行？ 考虑这样的场景，假如可达性分析要多帧完成，在中间执行了A.XX = B语句，使A引用到B，但A已经完成了可达性分析，UE识别不到这个新加的引用。那么B就有可能被错误当成垃圾回收掉。 可是这是官方文档的解释，但个人感觉不太符合正常使用场景，还没想通。不妨把B分成下面两种情况： B在这次GC开始前已创建，如果此时B没有其他引用了，那么代码里又是如何获取到B的？存裸指针当然可以，但本身也是不规范的做法。 B在这次GC开始后才创建，但新创建UObject默认是Reachable的，不会被这次GC删除。这点和Lua是有区别的，因为Lua里Object创建后默认不可达。 这个也许和增量回收还处于Experimental有关，逻辑还不成熟，不妨顺着UE思路继续往下看实现。 2. Write Barrier 解决这种问题还是要靠Write Barrier。得益于UE5中把裸指针替换为TObjectPtr，使得A.XX = B这种语句能被捕获到了。 TObjectPtr会在Operator =函数里做检查，如果发现正在GC，就会立即把B作为可达对象，加到全局GReachableObjects或GReachableClusters链表中。 之后以GReachableObjects为例，在可达性分析的PerformReachabilityAnalysisPass函数开头，会首先把GReachableObjects容器中的Object作为InitialObjects，当成初始可达对象，然后继续做可达性分析遍历。 这只是TObjectPtr的处理，我们也会定义些自定义逻辑，然后使用AddReferenceObjects函数添加引用，这就不太好处理了。 3. 如何实现分帧 回顾之前的ProcessObjectsArray函数，已经实现了Batch形式的Object遍历，每趟处理500个Object，那么分帧也是比较方便实现的。这一段TimeLimit逻辑控制了每帧可以花费多少时间做可达性分析，超过了就给下一帧继续执行，会从CollectGarbage入口进来。 既然分帧，就要保留当前GC进度，要知道还有哪些Object需要遍历。这些信息就存储在Context.InitialObjects数组里。因为可达性分析阶段是多线程执行的，因此每个线程都有自己的Context。 其实不仅可达性分析阶段做了增量式处理，后面的收集不可达对象阶段也支持了增量执行。只是耗时本身不高，逻辑也更简单，进度保留在Context中即可。 开启增量GC后的Trace如下，可以看到实现了分帧，但还不能做到完美的Timelimit分帧： 五、其他Tips 1. ReachabilityFlag0, ReachabilityFlag1 UE5在标记所有Object不可达时，不再遍历所有Object并设置UnReachable标记，而是交替使用两种ReachabilityFlag，同时依然有UnReachableFlag，用来表示真正的不可达Object： ReachabilityFlag0 = 1 << 14, ///< One of the flags used by Garbage Collector to determine UObject's reachability state ReachabilityFlag1 = 1 << 15, ///< One of the flags used by Garbage Collector to determine UObject's reachability state ReachabilityFlag2 = 1 << 16, ///< One of the flags used by Garbage Collector to determine UObject's reachability state Unreachable = 1 << 28, ///< Object is not reachable on the object graph. 交替两个ReachabilityFlag代码如下： FORCEINLINE static void SwapReachableAndMaybeUnreachable() { // It's important to lock the global UObjectArray so that the flag swap doesn't occur while a new object is being created // as we set the GReachableObjectFlag on all newly created objects GUObjectArray.LockInternalArray(); Swap(ReachableObjectFlag, MaybeUnreachableObjectFlag); // Maintain the old flag variables for backwards compatibility PRAGMA_DISABLE_DEPRECATION_WARNINGS UE::GC::GReachableObjectFlag = ReachableObjectFlag; UE::GC::GMaybeUnreachableObjectFlag = MaybeUnreachableObjectFlag; PRAGMA_ENABLE_DEPRECATION_WARNINGS GUObjectArray.UnlockInternalArray(); } 如此能实现这一帧Flag0表示可达，Flag1表示可能不可达，下一帧反过来，下下一帧再反过来。 然后一次GC结束后，PostCollectGarbageImpl，GatherUnreachableObjects会遍历所有UObject，把依然有MaybeUnreachableObjectFlag的Object视为垃圾，标记上Unreachable Flag。 遍历完成后，插入到专门储存待清理对象的GUnreachableObjects数组中。 2. MarkAsGarbage取代MarkAsPendingKill UE4对于强删的Object，比如Actor，会MarkAsPendingKill，UE5把接口改成了MarkAsGarbage： inline void MarkAsGarbage() { check(!IsRooted()); AtomicallySetFlags(RF_MirroredGarbage); GUObjectArray.IndexToObject(InternalIndex)->SetGarbage(); // If we explicitly marked the object as garbage, remove the async flag so it's visible to the GC AtomicallyClearInternalFlags(EInternalObjectFlags::Async); } 对应的IsPendingKill判断也改成了IsValid： inline bool UKismetSystemLibrary::IsValid(const UObject* Object) { return ::IsValid(Object); } 这是侑虎科技第1958篇文章，感谢作者南京周润发供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/xu-chen-71-65 再次感谢南京周润发的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）