探索Unreal的物理架构

Unreal在哪里计算x=x+v*dt,真的吗
探索Unreal的物理架构_第1张图片

前言

Unreal 的物理框架及其新的(或者说比较新的)物理引擎 Chaos 是一些非常复杂的软件。当然有一些值得抱怨和改进的地方,但是很容易认为,拥有如此强大且高性能的 3D 物理/碰撞系统是理所当然的,通过在下拉列表中选择一个组件即可工作,而无需阅读和复制计算机科学博士论文。

当然,在处理具有物理引擎的重要用例的项目时,有时它不能正常工作,然后事情就变成了无助地搜索神奇的复选框或想出一个令人讨厌的变通方法,如果出现最坏的情况,则完全有可能需要对引擎进行修改。在这些情况下,有必要打开引擎盖并至少阅读一些引擎代码——在大多数情况下这是一项艰巨的任务,对于游戏引擎的这种专业的和普遍的方面来说尤其如此。

这篇博文旨在提供一个与Unreal物理框架相关的内部 C++ 引擎类的基本概述,并概述 API 和这些类之间的主要代码路径。它不探索高级物理特性,也不涵盖物理模拟实现本身。相反,它将熟悉的Unreal API 类和更新场景中actor位置的“黑匣子”之间的点连接起来。这不是一个教程或绝对可靠的文档:我没有物理模拟方面的专业知识,也没有任何关于引擎设计的内部信息。它仅仅是阅读和单步执行引擎代码的结果。

这篇文章是参考 Unreal 源代码的 5.1.0 版本编写的。它假定熟悉 C++ 和 Unreal 的 C++ API,并可以访问Unreal Engine GitHub 存储库 (否则所有 GitHub 链接都将是 404!)。

介绍

游戏开发者期望从任何物理引擎中获得两个基本概念:

1、Simulation(模拟)—— 跟踪和更新场景中actor的位置,无论是孤立的还是与其他actor相关的(即碰撞)
2、Interaction(交互)——通过配置初始参数和在运行时对输入做出反应来查询和修改场景中角色状态的能力

没有交互的模拟是动画;没有模拟的交互是数据输入。从引擎架构的角度来看,这些概念相互关联很多。我会将框架的探索组织到以下广泛的 API 交互中:

1、Actor Registration —— 初始化场景的物理引擎模型,以及向模型添加/从模型中删除新的actor
2、Ticking/Input —— 提示物理引擎推进模拟,考虑来自用户输入的任何变化,并处理结果
3、Querying —— 直接检查场景的状态,例如线追踪,以及被通知场景中的物理事件,例如碰撞/重叠

我将在第二部分依次介绍这些内容,但在此之前,有必要列举一下让整个作品走到一起的角色阵容。

主要的类

————————————————————————————————————————————
探索Unreal的物理架构_第2张图片
在本节中,我将简要介绍上文介绍中提到的,探索API 交互时会看到的主要类。这是我们在开始实际浏览之前对物理框架结构关系的高级概述。

世界

UWorld是"代表地图的顶级对象,Actors 和 Components 将存在于其中并被渲染"。如果你曾在 Unreal 中使用 C++ 工作过,那么这应该是此处提到的最熟悉的类;GetWorld()可能是引擎中最常用的 API 调用。正如文档中提到的,通常一次只有一个世界,但由于各种原因可能会有更多世界,尤其是在编辑器中。蓝图节点将其作为参数省略,因为它们的范围永远不会使它们所关注的“世界”模棱两可。出于这篇博文的目的,我们并不特别关心多元宇宙这个分支,但在我们深入研究引擎并了解单一UWorld所包含的内容时,最好记住这一点。

文档对一个UWorld的描述在技术上是正确的,但掩盖了很多UWorld做和不做的东西。例如,UWorld不维护AActors 的数组:ULevel维护。那实际上是ULevel的主要目的。但是ULevels没有一个UWorld 就不存在(ULevel属于UWorld),并且UWorld提供修改AActor列表的主要接口 (例如UWorld::SpawnActor())。所以说,从技术上讲是正确的。但我们关心的是UWorld如何与物理引擎相关。自然地,最重要的方面是那个AActor列表,或者更具体地说是它们相关的FBodyInstance们,它们是AActors 的物理表示。UWorld 拥有FPhysScene1,我们众所周知的物理表演的舞台。

除了位于对象层次结构顶部之外,UWorld的接口还为我们提供了所有三个 API 交互的入口点:
1、Actor Registration:SpawnActor()和DestroyActor()。
2、Scene Ticking:UWorld::Tick()负责启动每个物理模拟步骤。
3、Querying:UWorld包含一套用于执行物理场景的跟踪、扫描和重叠查询的方法。

其中每一个都将在各自的部分进行更详细的探讨。作为旁注,在代码中进行探索时,记住UWorld的源代码被拆分为多个 .cpp 文件是很有用的:World.cpp、LevelActor.cpp、WorldCollisionAsync.cpp、PhysLevel.cpp和LevelTick.cpp。

大舞台

本节中介绍的类形成一对一、从上到下的对象层次结构,是我们可识别的 Unreal API 和物理模拟的数学之间的主要控制器。当然,每个级别之间和内部都有抽象层,但这些类将是我们在下一节中探索主要代码路径时的关键里程碑。

FPhysScene_Chaos

FPhysScene_Chaos(继承自FChaosScene),也就是FPhysScene,是物理模拟的主要接口。它拥有网络物理复制处理程序(另一个主题),处理碰撞事件的注册和调度(查询),并提供用于实际向物理粒子施加力的 API。

然而,比这些职责更重要的是,它提供了用于实际推进模拟(Ticking)的 API ,是由UWorld用StartFrame()和EndFrame()调用的。它还创建并拥有FPBDRigidsSolver。

FPhysInterface_Chaos

FPhysInterface_Chaos应该被提到,也就是FPhysicsInterface。如果你跟着代码走,你会看到它在不同的地方被提及,但为便于理解,我积极地试图掩饰它,因为它正如自己名字描述的:一个通向更有趣的物理类的接口,特别是FPhysScene_Chaos和Querying helpers。

FPBDRigidsSolver

FPBDRigidsSolver(继承自FPhysicsSolverBase),也就是FPhysicsSolver,是站在“游戏引擎”和“物理引擎”之间最后的堡垒。它拥有FPBDRigidsEvolutionGBF(物理模拟的核心);FPBDRigidsSOAs(粒子的规范集合);以及FChaosMarshallingManager(在各种游戏线程之间进行通信,以使的整个产品走到一起的忙碌的中间人)。

“PBD”代表“Position Based Dynamics”,这是真正的计算机科学博士论文领地。请放心,探索 PBD 的工作原理以及 Unreal 如何实现它远远超出了本博文的范围。可以说这是物理模拟的概念基础。

FPBDRigidsSOAs

FPBDRigidsSOAs是我们的物理粒子主列表。“SOA”代表“数组结构”,一种常见的内存布局优化,复数形式的“SOAs”是指FPBDRigidsSOAs的指向继承自TGeometryParticlesImp的不同集合的指针,例如FGeometryParticles、FKinematicGeometryParticles或TPBDGeometryCollectionParticles。这些结构包含了为我们场景中的所有粒子编码数据(位置、质量、几何等)的数组。

FChaosMarshallingManager

FChaosMarshallingManager管理FPushPhysicsData和FPullPhysicsData结构的队列,这些队列是将更新从游戏线程传输到物理线程的容器,反之亦然。我们将在Ticking 的部分中更密切地关注这些内容。

FPBDRigidsEvolutionGBF

FPBDRigidsEvolutionGBF(继承自FPBDRigidsEvolutionBase)是魔法发生的地方。它是我们最后在十几个抽象层下找到的可识别的模拟物理方程。自然地,它拥有对解算器的FPBDRigidsSOAs结构的引用,因为这是它正在处理的数据。此外,它使用该粒子数据来维护用来加速碰撞检测的结构,即“Broad Phase”优化,以及我们不会研究的其他特定于实现的结构。

“GBF”代表“Guendelman、Bridson、Fedkiw”,指的是这些作者关于刚体之间碰撞的一篇论文。与 PBD 一样,请放心,详细信息不在本文讨论范围之内。

主人公

本节涵盖了代表运动对象的类 ——上一节中的类正在操纵的事物。

AActor

如果您没有听说过AActor,那么您可能还没有在博文中深入到这里。这些是关卡中的最高级的事物,我们正在混合在一起的可动人物。正如UWorld部分所讨论的,ULevel维护着world中存在的AActor的列表。然而,这里我们对AActor最感兴趣的是它们的组件。最重要的是,我们关心他们是否有UPrimitiveComponent。 没有其中之一,它们对物理模拟没有影响,并且从物理引擎的角度来看确实不存在。2

UPrimitiveComponent

UPrimitiveComponent是包含几何的组件的父类。这包括熟悉的UStaticMeshComponent和USkeletalMeshComponent,我们可能仅将其用于渲染几何体,还包括USphereComponent、UBoxComponent和UCapsuleComponent,这些用于碰撞、替换或与网格中更复杂的几何体协同工作。无论我们正在查看哪个子类,它们都通过UPrimitiveComponent的FBodyInstance 成员变量与物理框架交互。

FBodyInstance

FBodyInstance是“对象物理表示的容器”。它们直接归UPrimitiveComponents 所有,并且它们拥有在编辑器的细节面板的“物理”和“碰撞”部分中设置的大部分属性:碰撞通道/响应、变换约束、质量/阻尼覆盖等。

在架构上,有三个属性特别令人感兴趣。第一个是TWeakObjectPtr BodySetup,它是为FBodyInstance记录信息的。我们将在谈论actor注册时更详细地介绍UBodySetup(继承自UBodySetupCore)。第二个是FBodyInstance* WeldParent,它支持FBodyInstance之间的树状父子关系。我们将在稍后的讨论中看到它的使用,但我们不会太密切地关注它。第三个属性是FPhysicsActorHandle ActorHandle,实际上是 FSingleParticlePhysicsProxy* ActorHandle。

FSingleParticlePhysicsProxy

FSingleParticlePhysicsProxy是我们与“粒子”的接口,“粒子”是物理引擎实际模拟的对象。物理引擎不关心某物是什么颜色或它在哪个团队,它关心纯几何形状、它们的位置以及它们与哪些其他事物交互(即它们的碰撞通道)。这一切都保存在TGeometryParticle中,我们的代理保存指向它的指针,它是游戏线程对物理粒子的表示。代理结构还包含指向TGeometryParticleHandle的指针。这是通向物理线程对粒子表示的一个窗口。请记住,物理线程的粒子信息保存在FPBDRigidsSOAs中,一个TGeometryParticleHandle包含一个对SOA (TGeometryParticlesImp) 的引用和该结构数组的索引。这两条信息唯一地标识了场景中的单个粒子,而 ParticleHandle 提供了一个用于设置/获取该粒子属性(例如位置)的接口。

存在其他类型的物理代理,包括FGeometryCollectionPhysicsProxy(与Chaos Destruction相关),FJointConstraintPhysicsProxy 和FSuspensionConstraintPhysicsProxy。所有这些都继承自同一个基类IPhysicsProxyBase,因此我们经常会在代码的不同位置的switch 语句内部,在FSingleParticlePhysicsProxy旁边,看到处理这些类型的代码。总的来说,FSingleParticlePhysicsProxy是基础物理模拟中最常见和最具代表性的情况,因此我们不会在研究中过多关注其他情况。

API交互

在我们深入研究代码路径之前,先简要说明一下线程。虚幻引擎以各种方式广泛使用多线程。你可能会听到有人区分“游戏”线程和“渲染”线程,它们在游戏过程中并行工作。在物理的上下文中,我们可能会提到“物理线程”,但值得注意的是,没有像游戏或渲染那样的单独的大写 P的“物理线程”——物理计算任务被游戏线程分配给工作线程,并按照Unreal 的调度程序认为合适的方式进行管理。

0.引擎初始化

探索Unreal的物理架构_第3张图片

令人惊讶的是,这不是介绍中列表中的内容之一!好吧,开发人员期望从物理引擎的 API 中获得的隐含的额外功能是启动它。

首先,什么都没有。但随后发生了大爆炸,所有的星星出现在天空中。快进几年,我们刚刚启动了 Unreal Engine 可执行文件。每个平台都有自己的到达方式,但它们都相遇在GuardedMain()。 此函数调用FEngineLoop::PreInit(),然后调用FEngineLoop::Init()(或EditorInit()),然后跳入一个FEngineLoop::Tick()之上的紧密的 while 循环,直到时间结束(或者我们退出,以先到者为准)。显然,在我们忽略的每个函数中都有很多(阅读:引擎的整个其余部分)在进行,但最好让我们自己集中在这样的知识中,即最终,这是一个在main函数里面的while循环。为了更深入地探索引擎初始化,我推荐“虚幻引擎游戏框架:从 int main() 到 BeginPlay”,作者Alex Forsythe。

FEngineLoop::PreInit() 调用InitGamePhys()。一度,这为 PhysX 设置启动了许多样板,但现在它只是加载Chaos和ChaosSolverEngine模块,后者启动ChaosSolversModule。在这个研究中,我并不特别关心每个模块打包了什么,所以我们不会仔细研究这些。

FEngineLoop::Init()是一个非常丰富的函数,但就我们的目的而言,它只命中几个关键项。首先,它创建了GEngine,这是坐在宝座之上的可信赖的全局的UObject,甚至在UWorld之上。然后它用GEngine创建和启动UGameInstance。 UGameInstance::StartGameInstance()主要关注加载第一张3地图,在默认配置的地图上调用UEngine::Browse()(并因此调用UEngine::LoadMap())。

在那之后,我们就脱离了引擎引导领域,现在进入了运行时行为领域。UEngine::LoadMap()是在运行期间加载地图的核心逻辑,无论是通过手动GEngine->Browse()调用还是无缝旅行。UEngine::LoadMap()做了一大堆有趣的事情,但我们最关心的是它创建我们的UWorld,然后调用UWorld::InitWorld(),这是FPhysScene被构造的地方,它利用ChaosSolversModule构造我们的FPBDRigidsSolver实例,进而构造我们的FPBDRigidsEvolutionGBF实例。

所以引擎初始化我们的模块并启动UGameInstance,UGameInstance启动UWorld,然后UWorld启动物理框架。到此为止:舞台已经搭建好了。除了,我们是不是忘记了我们尊敬的AActor们和他们孜孜求利的FBodyInstance们了?没有演员哪有表演,准备好了吗?

1. Actor注册

探索Unreal的物理架构_第4张图片

AActor初始化

现在物理引擎已经启动,是时候让演员上台并用FBodyInstances填充我们的场景了。我们希望在两种情况下注册 actor:在关卡加载时,以及在运行时生成它们时。这两种情况在同一个地方相遇,但从不同的方向到达。

对于前一种情况,在关卡加载时注册,是在UWorld::InitWorld()调用UEngine::LoadMap()之后一点点,UWorld::InitializeActorsForPlay()被调用,这又调用ULevel::IncrementalRegisterComponents(),它遍历关卡中所有的AActor,在它们中的每一个上调用AActor::IncrementalRegisterComponents()。然后这个函数调用UActorComponent::RegisterComponentWithWorld()它们的所有组件。一个非常直接的初始化从UWorldto渗透到ULevel,再到AActor们,再到它们的UActorComponent们。

对于后一种在生成时注册的情况,UWorld::SpawnActor()调用生成actor的AActor::PostSpawnInitialize()方法,它调用AActor::IncrementalRegisterComponents(),就像在关卡加载时注册一样。但是,如果 actor 是蓝图,则它会采取稍微不同的路线。在AActor::PostSpawnInitialize()的最后,它用ExecuteConstruction()开始了蓝图actor构建过程。沿着调用堆栈往下走几步,我们就会看到USimpleConstructionScript::RegisterInstancedComponent(),它调用UActorComponent::RegisterComponentWithWorld(),就像上面AActor::IncrementalRegisterComponents()做的那样。

所以在每种情况下,我们都会发现自己在UActorComponent::RegisterComponentWithWorld()。这是 actor 上的任何组件启动时所采用的路径,无论它是否与物理引擎有关。但是,如果它确实与物理引擎有关,则该方法会调用UActorComponent::CreatePhysicsState(). 现在我们终于到了某个地方 - 上面写着“物理”!这个函数调用虚函数UActorComponent::OnCreatePhysicsState(),它做的不多,但如果它是一个UPrimitiveComponent而不仅仅是一个UActorComponent,那么重写函数中的东西会变得更有趣。正是在这里,我们终于找到了躲在椽子里的蒙面幽灵:FBodyInstance::InitBody()。

FBodyInstance 初始化

探索Unreal的物理架构_第5张图片

FBodyInstance::InitBody()是FBodyInstance在它的黑暗的下属UBodySetup和FInitBodiesHelper的帮助下生成的地方,。

FBodyInstance 的走狗

UBodySetup“包含与单个资产相关的所有碰撞信息”。它保存了与网格关联的真正的几何数据。你可以将其视为模板,FBodyInstance加盖在它上面。当你在静态网格体编辑器 UI的“碰撞”菜单中选择一个选项时,你正直接修改与该网格体相关联的UBodySetup。

FInitBodiesHelper是一个有点不透明的类,它最终按照它名字表达的做,并在FInitBodiesHelperBase::InitBodies()被FBodyInstance::InitBody()调用时进行实际的初始化工作。首先也是最重要的是,它调用FInitBodiesHelperBase::CreateShapesAndActors().

关于“Actors”和“Shapes”

要理解FInitBodiesHelperBase::CreateShapesAndActors()是做什么的,我们首先应该问一下我们正在创建的这些“Actor”和“Shapes”是什么。简而言之,物理引擎尽可能地倾向于将对象视为尽可能简单。这大大简化了数学运算,但是到目前为止,你只能使用球体和立方体4。对于更复杂的东西,我们需要一个“凸网格”,它很像在渲染中一样,是由三角形构成的 3D“对象”。无论哪种方式,纯球体或网格,这些都是在这里创建的“Shapes”——我们的3D几何的表示。从这个意义上说,“Shapes”由两种数据类型表示:FImplicitObject(及其继承者们)和FPerShapeData。FImplicitObject包含一个形状的真正的几何数据,比如球的位置和半径。FPerShapeData携带关于这些形状的其他重要数据,这些数据不是它们的纯几何形状,例如它们的物理材质和它们的碰撞响应通道。

此上下文中的“Actor”是保存形状集合并维护其自身位置的数据的对象。这些是FSingleParticlePhysicsProxy对象。我的假设是,在这个引擎的这个层级,“actors”(就像在FBodyInstance::CreateActor()中)大致与“particles”同义。一般来说,术语“actor”的使用级别不低于FBodyInstance。FSingleParticlePhysicsProxy对象是物理框架的一个非常重要的方面,但它们主要是它们持有的两个指针的接口:TGeometryParticle和TGeometryParticleHandle。正如我们之前提到的,TGeometryParticle是物理粒子的游戏线程表示,而TGeometryParticleHandle是物理线程表示。两者都保存关于粒子位置的数据,并维护一个FPerShapeData对象的数组。此时,我们是在游戏线程中操作的,只有TGeometryParticle是相关的,甚至是设置好的。TGeometryParticleHandle将稍后发挥作用。

CreateShapesAndActors

FInitBodiesHelperBase::CreateShapesAndActors()做的第一件事是调用UBodySetup::CreatePhysicsMeshes(),它为我们不那么简单的物理形状加载凸网格。大多数情况下,这将已经在关卡加载时为每个UBodySetup调用,在这种情况下,它会跳过此处的工作。确定形状数据可获得后,它会检查FBodyInstance是否有父级,有的话它们应该“焊接”在一起。焊接给我们的结构增加了很多复杂性,但归根结底,将子体的“Shapes”添加到父体的“Actor”中需要做大量的簿记工作。如果它确实需要做一些焊接,它现在就做,函数从这里退出,因为焊接逻辑处理设置actors和shapes。如果它不需要焊接,那么现在就是时候了:它会调用FInitBodiesHelperBase::CreateActor_AssumesLocked(),然后调用FInitBodiesHelperBase::CreateShapes_AssumesLocked()。CreateActor_AssumesLocked()创建我们的FGeometryParticle,并生成FSingleParticlePhysicsProxy“Actor Handle”来保存对它的引用。CreateShapes_AssumesLocked把与我们的几何关联的FPerShapeData和FImplicitObject条目添加到FGeometryParticle.

InitBodies的剩余部分

既然CreateShapesAndActors()都结束了,我们可以进入FInitBodiesHelperBase::InitBodies()的剩余部分。 假设我们没有进行任何焊接(因为这意味着已经添加了父实体的演员),是时候将我们的actors添加到场景中了。我们进入一个FPhysicsCommand::ExecuteWrite()lambda 表达式,这是一个简单的互斥模式来锁定我们的FPhysScene对象,并做一些简单的事情。首先,它为碰撞路径的实体和和启用碰撞的枚举做一些簿记。然后它调用FChaosScene::AddActorsToScene_AssumesLocked(),这会在我们的FSingleParticlePhysicsProxy之上调用FPBDRigidsSolver::RegisterObject()—— 稍后会详细介绍,但是完成此操作后,actor现在已被物理引擎知道并准备好进行模拟。最后,InitBodies()用FPhysScene为碰撞事件注册我们的 actor 。

综上所述,当AActors 创建时,它们的组件被初始化;如果一个组件是 一个UPrimitiveComponent,它会初始化一个FBodyInstance,它会生成一个物理粒子FSingleParticlePhysicsProxy及其关联的几何体,然后通过FPBDRigidsSolver将粒子添加到我们的物理表示中。

Actor Destruction

有了所有这些设置,如果我不简要讨论当一个actor被销毁时会发生什么,那我就是失职了。通常这会在UWorld::DestroyActor()调用时发生。该函数做了相当多的簿记工作,物理引擎甚至根本不关心其中的大部分。不过,它真正关心的是它何时调用UActorComponent::UnregisterComponent(),这不仅在actor销毁时被调用,而是无论我们如何到达它,它都会为我们的UPrimitiveComponent及其附加的FBodyInstance拼写行尾。UnregisterComponent()调用ExecuteUnregisterEvents(),ExecuteUnregisterEvents()调用DestroyPhysicsState(),DestroyPhysicsState()调用虚函数OnDestroyPhysicsState()。这对UActorComponent是空操作,但被UPrimitiveComponent重写,来从任何父母或孩子身上拆开FBodyInstance并调用FBodyInstance::TermBody()。这提示FPhysScene做它的一些簿记,并最终在FPBDRigidsSolver::UnregisterObject()到达顶峰,它管理所有最终的簿记,以将我们的FSingleParticlePhysicsProxy从它可能在的任何低级结构中移除。

2.场景Tick

探索Unreal的物理架构_第6张图片

在本节中,我们将尽可能深入这些洞穴,直到找到反映我们对物理模拟如何工作的任何直觉的东西:遍历对象列表并更新它们的位置。

如果你在一篇关于虚幻引擎的干巴巴的文章中已经读到这里,那么你应该熟悉 Ticking 的概念。回到调用堆栈的顶端GuardedMain(),Unreal 程序尽可能频繁地运行FEngineLoop::Tick(),直到时间结束。在该函数中发生了很多事情,因为它兼顾了渲染器、统计信息以及你们知道的其他东西,但那个函数中它调用了UGameEngine::Tick()。就像它的父级一样,UGameEngine::Tick()涉及到很多,但对我们来说最重要的是它什么时候遍历它的UWorld列表,依次调用它们的UWorld::Tick()。

UWorld::Tick()开始时很慢,需要大量簿记。但当它开始遍历其ULevel集合时,事情就会开始升温。如果你对任何关于actor ticking的高级工作,我们在这里会发现一些熟悉的枚举:“tick group”枚举TG_PrePhysics,TG_DuringPhysics和TG_PostPhysics,以及它们不太出名(但低调更重要)的兄弟TG_StartPhysics和TG_EndPhysics。这是UWorld为每个值依次执行tick处理的地方:所有tick组纯粹是为了尽可能快的定位与物理tick相关的tick回调。这些步骤中的每一步都使用FTickTaskManager::RunTickGroup()。

简而言之,FTickTaskManager管理其注册任务的异步执行,并允许在各个枚举阶段注册tick函数。这正是UWorld在第一次调用RunTickGroup()上面几行调用UWorld::SetupPhysicsTickFunctions()时做的事情。这分别在TG_StartPhysics和TG_EndPhysics组中用FTickTaskManager注册FStartPhysicsTickFunction和FEndPhysicsTickFunction。UWorld然后立即调用FTickTaskManager::StartFrame(),它将所有已注册的tick函数排队,然后由每次执行依次调度。默认情况下,AActor们最先tick,在TG_PrePhysics组中。在那之后,在TG_StartPhysics组中,FStartPhysicsTickFunction被执行。这就是魔法发生的地方。

物理框架
探索Unreal的物理架构_第7张图片

FStartPhysicsTickFunction::ExecuteTick()只做一件事,但它做得很好:它调用FChaosScene::StartFrame()。StartFrame() 懒洋洋地开始它的一天,让它的朋友FPhysicsReplication和任何订阅它的OnPhysScenePreTick或OnPhysSceneStep事件的任何人知道,它今天一定会完成一些工作,也许他们也应该开始了5。完成所有这些艰苦的工作后,它让FPhysicsSolverBase知道是时候AdvanceAndDispatch_External(),然后又躺回床上。

FPhysicsSolverBase::AdvanceAndDispatch_External()从一些 deltatime 簿记开始,包括对一些physics substepping的设置。在此之后它调用FPBDRigidsSolver::PushPhysicsState()。这是一个关键步骤,它启动了在计算之前将物理线程的粒子模型与游戏线程的模型同步的过程。最后,它将三个连续任务排队(如果是单线程,则直接调用):FPhysicsSolverProcessPushDataTask 、FPhysicsSolverFrozenGTPreSimCallbacks和FPhysicsSolverAdvanceTask,分别调用ProcessPushData()、GTPreSimCallbacks()和AdvanceSolver()。我们将在下一节中重新访问ProcessPushData(),我们将掩饰GTPreSimCallbacks()—— 这是一个相当不言自明的回调入口点。我们有一个黄金偶像要找到,并且AdvanceSolver()知道该走哪条路!

AdvanceSolver()将我们带到AdvanceOneTimeStepTask::DoWork(),它首先为物理计算设置各种结构做了大量工作,但关键是调用FPBDRigidsEvolutionGBF::AdvanceOneTimeStep(),这是为 PBD 物理模拟完成真正的实际计算的地方。我保证向(向你和我自己),这些细节超出了范围,所以如果你想深入了解物理模拟的真正细节,那就去吧。可以说这是FPBDRigidsEvolutionGBF::Integrate()被调用的地方,对于FPBDRigidsSOAs中的每个粒子,加速度产生速度,速度产生位置,正如先知所预言的那样。

唉呀!我们花了一些时间,用我们的大部分绳索和炸弹钻进了这些洞穴,但最终我们成功了,找到了那个黄金偶像,一切都向前迈进了一步。现在剩下的就是重新找到回去的路了。也就是说,物理引擎已经按照要求对周围的一切进行了洗牌,但是谁会让游戏引擎知道呢?毕竟,我们关心的是AActors,而不是TGeometryParticleHandles!

推拉:移动数据
在我们讨论如何从物理引擎中获取信息之前,我们需要先讨论一下信息是如何进入的。在上一节中,我们一直在寻找逻辑的关键路径,找出哪个函数调用真正导向x += v * dt。在本节中,我们将仔细研究数据的关键路径:输入如何变成输出。

FChaosMarshallingManager是保持整个操作平稳运行的无名主力。一只手连接FPushPhysicsData,另一只手连接FPullPhysicsData,它巧妙地控制游戏和物理线程之间的数据流。这里的“推”和“拉”是从游戏线程的角度来看的:物理引擎是一个黑盒子,我们将输入“推”进去,然后从中“拉”出结果。

FPushPhysicsData
一旦事情开始,物理引擎就会很好地了解场景中发生的事情。在许多方面,它是场景的规范版本——Unreal 的其他部分必须按照物理引擎的说法,actor所处的位置与上一个 tick 时不同。毕竟,是它在跟踪这些疯狂的“力”和“碰撞器”。任由其自由发展,物理引擎非常乐意在不接受任何更多输入的情况下坚持不懈,但这纯粹是模拟。我们被承诺互动!有两种情况我们需要 Push 到物理模拟:当我们从模拟中添加或删除某些东西时,以及当我们直接修改模拟中已经存在的东西时。

我们已经讨论过在场景中添加和删除actors。在那一节中,我们主要讨论了为 actor 的物理表示创建的结构,并掩盖了调用FChaosScene::AddActorsToScene()之后FPBDRigidsSolver::RegisterObject()做了什么。 好吧,我现在在这里告诉你,它调用了FPhysicsSolverBase::AddDirtyProxy(),它将我们创建的FSingleParticlePhysicsProxy粘贴到FPushPhysicsData的DirtyProxiesDataBuffer。

在我们进一步思考之前,让我们先看看另一种情况:直接修改模拟中已有的东西。在实践中有很多方法可以做到这一点,所以我们将讨论一种我认为相当清楚的方法:移动你的角色。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6S5e6Dnk-1675423260744)(https://itscai.us/blog/img/ue-physics-framework/player-input.png#pic_center)]

我们将从ADefaultPawn::MoveForward()开始,这就是您按住 W 键时所调用的内容。此函数只是更新APawn的ControlInputVector字段,当Pawn Tticks(在TG_PrePhysics中)时应用于 APawn的速度字段,这会触发相关USceneComponent们的更新,从而导致组件的OnUpdateTransform()被调用。并且类似于我们在注actor时看到的情况,如果它是一个UPrimitiveComponent而不是简单的USceneComponent,OnUpdateTransform()与我们的FBodyInstance交互, 调用FBodyInstance::SetBodyTransform()。这会调用FSingleParticlePhysicsProxy上的SetX(),它会更改粒子的位置值,然后将其标记为脏,调用我们在上面注册actor时看到的相同的FPhysicsSolverBase::AddDirtyProxy()6

PushPhysicsState
因此,无论我们如何温和地修改我们的游戏状态,就物理引擎而言,最终结果是我们“弄脏”了它宝贵的物理代理之一。当我们调用FPBDRigidsSolver::PushPhysicsState()时,我们带着我们玩的玩具清单来到物理引擎,对于DirtyProxiesDataBuffer中的每个项目,它调用TGeometryParticle::SyncRemoteData(),它遍历粒子的属性(例如它的位置)并调用它们身上的FDirtyChaosProperties::SyncRemote()。如果该特定属性是脏的7,它会在FDirtyPropertiesManager(FPushPhysicsData的一个成员)中更新一个值。

ProcessPushData
探索Unreal的物理架构_第8张图片
让我们回顾一下这里的上下文。运行TG_StartPhysics组的UWorld::Tick()启动FPhysicsSolverBase::AdvanceAndDispatch_External(),它调用PushPhysicsState(),然后分派任务连续运行ProcessPushData()、GTPreSimCallbacks()和AdvanceSolver(),后者我们在上一节中讨论过。现在我们是时候绕回ProcessPushData()。

ProcessPushData()做一些簿记(特别是应用与物理相关的控制台变量),但我们最感兴趣的是它调用FPBDRigidsSolver::ProcessPushedData_Internal()。这是FPushPhysicsData保存的所有数据被消耗的地方。我们关心的工作是在ProcessSinglePushedData_Internal()中执行的。 这个函数遍历PushData.DirtyProxiesDataBuffer(回想一下这是我们脏FSingleParticlePhysicsProxy的集合),首先检查代理是否未初始化,这意味着我们刚刚创建了它。如果是,那么这就是它生成TGeometryParticleHandle(粒子的物理线程表示)并将其附加到代理的地方。在这种情况下,粒子仍然不存在于任何 SOA 结构中,但这很快通过调用FPBDRigidsSOAs::CreateStaticParticles()解决了。在此之后,无论它是否是新的,它都会调用FSingleParticlePhysicsProxy::PushToPhysicsState()。这是我们将弄脏的属性应用于物理模型的地方:通过调用像FPDBRigidsEvolutionGBF::SetParticleTransform()这样的函数,最终调用TGeometryParticleHandle::X(),我们将“脏”属性信息从FDirtyPropertiesManager(从粒子的游戏线程模型中设置) 移动到粒子的物理线程的模型( FPBDRigidsSOAs)中 。

这就是我们在兔子洞中跟踪数据的程度!满足于我们的更新使得所包含的知识内容进入FPBDRigidsSOAs,我们可以忽略尖叫声,因为它们由FPDBRigidsEvolutionGBF处理的; 物理引擎在关起门来做什么是它自己的事情。

FPullPhysicsData
一旦黑匣子中早熟的物理小魔怪完成了他在那里所做的一切,他就会使用FPullPhysicsData滑出结果。此结构包含在特定模拟步骤中所做的所有更改的集合。在这个物理框架中移动的每一件事都需要得到通知,这样我们才能改变屏幕上那些闪亮的发光像素!
探索Unreal的物理架构_第9张图片

让我们重新定位自己。我们刚刚讨论了由FPhysicsSolverBase::AdvanceAndDispatch_External()启动的ProcessPushData()任务,在AdvanceSolver()任务之前。让我们现在重新放大AdvanceSolver(),一直缩小到AdvanceOneTimeStepTask::DoWork(),我们刚刚执行FPBDRigidsEvolutionGBF::AdvanceOneTimeStep()的地方,这是物理模拟的实际集成发生的地方。物理引擎已经完成了它的工作,现在剩下要做的就是提取结果。DoWork()调用一些函数来清理自身,最后调用FPBDRigidsSolver::CompleteSceneSimulation()。 此函数负责组装我们的结果以通过FPBDRigidsSolver::BufferPhysicsResults()被消耗8。BufferPhysicsResults()用FPBDRigidsSOAs::GetDirtyParticlesView()获取“脏粒子”(这次“脏”是从游戏线程的角度来看)并调用FSingleParticlePhysicsProxy::BufferPhysicsResults()获得每个粒子的位置和速度信息,填充FPullPhysicsData::DirtyRigids()。最后,回到FPBDRigidsSolver::AdvanceSolverBy(),FChaosMarshallingManager::PreparePullData()被调用,我们模拟步骤的 PullData 加入队列以供使用。

既然我们的FPullPhysicsData已经填充并入队,我们可以跟着调用堆栈向上(向下?)一直爬回到UWorld::Tick()​​。我们终于完成了TG_StartPhysics tick组!深吸一口气,这里的空气清新多了。不过不要磨磨蹭蹭太久,因为完成后我们会立即回到TG_EndPhysics tick组!
探索Unreal的物理架构_第10张图片

与它的兄弟Start,FEndPhysicsTickFunction只是调用FChaosScene::EndFrame()。就像StartFrame()调用AdvanceAndDispatch_External()迭代每个Solver 一样,EndFrame()调用FChaosScene::SyncBodies()迭代每个 Solver。

SyncBodies()的工作由FPhysScene_Chaos::OnSyncBodies()处理,它通过将一个lambda 传递给FPhysicsSolverBase::PullPhysicsStateForEachDirtyProxy_External() 来沉迷于某种间接寻址。在FChaosResultsManager的帮助下9,此函数包含用于在异步设置中插值物理结果队列的逻辑。这是相当好的评论,这很好,因为我要掩盖它。在我们不进行插值的情况下,它只是用FPullPhysicsDataFChaosResultsManager::PullSyncPhysicsResults()获取最新的更新并为拥有相关脏代理的UPrimitiveComponent们组装一组变换的集合。最后,OnSyncBodies()迭代这个集合,根据收集到的变换调用每个组件的UPrimitiveComponent::MoveComponent()。

这样我们就回到了熟悉的领域!令人欣慰的是,所有物理计算的最终目的是通过调用函数来操作AActor们,这些函数我们作为 API使用者可能自己也会调用。在这些MoveComponent()调用之后,UWorld::Tick()完成了TG_EndPhysics组,然后执行TG_PostPhysics组,并继续其一天。然后它会在几毫秒后重新开始整个过程​​!

3.查询

在浏览了整个物理框架之后,这部分应该是一个相当简单的收尾工作。一次更新后知道自己所处位置的UPrimitiveComponent足以满足我们需要从物理引擎中拿到的东西——变换信息到达渲染引擎,而无需我们作为开发人员进行任何输入,我们可以直接操纵和响应游戏逻辑中的AActor们的位置,无需担心我们花费了最后 5000字左右探索的机制。然而,虽然可以近似碰撞事件处理程序的结果或使用轮询或其他方式进行跟踪,但与简单地绑定到物理引擎已经完成的计算相比,这会导致更糟糕的结果!

追踪、扫描、重叠
探索Unreal的物理架构_第11张图片

线追踪,也称为光线投射(但稍微不那么过载的术语),指的是在空间中定义起点和终点,在它们之间构建一条线,并找出场景中哪些几何对象与该线相交的概念。“扫瞄指的是相同的概念,但我们不是检查与纯数学线段的交点,而是检查与对应于一个三维形状沿着该线段在空间中“扫过”的体积的交点。就像将勺子拖过一盒新鲜冰淇淋的顶部,而不是一根针。“重叠”也许是三者中最不言自明的,检查几个几何对象是否占据相同的空间。

UWorld, 完成了它成为我们物理框架入口点的帽子戏法,为我们提供了跟踪、扫描和重叠查询的接口。 UWorld包含大量用于扫描、跟踪和重叠的函数,具有大量选项用于按碰撞通道或对象类型来过滤查询、用于获取交叉点列表或仅获取第一个交叉点,或者用于异步或同步进行。好消息是,所有这些调用在经过多个级别的模板化或任务管理器恶作剧之后,最终会出现在两个位置之一:对跟踪和扫描来说是TSceneCastCommonImp()10,对重叠来说是GeomOverlapMultiImp()。

有点令人失望的是,TSceneCastCommonImp()的瓶颈经过更多模板恶作剧通过TSQTraits::SceneTrace()分支返回LowLevelRaycast或LowLevelSweep,TSQTraits::SceneTrace()是TSceneCastCommonImp()调用的核心函数。同时GeomOverlapMultiImp()调用LowLevelOverlap。这三个LowLevel函数非常相似,获取由FPBDRigidsEvolutionGBF维护(我们探索过的物理堆栈的底层)的“空间加速结构”,并调用Raycast()、Sweep()或Overlap()查询它。

空间加速结构的实现细节,如 PBD 模拟的细节,不在本博文的讨论范围之内。我希望你会像我一样满意于看到我们的UWorld跟踪 API和FPBDRigidsEvolutionGBF(我们在其中找到物理积分计算的类)之间的联系。

碰撞事件
无论是通过蓝图还是 C++ API ,碰撞事件主要通过UPrimitiveComponent定义的OnComponentHit和OnComponentBeginOverlap 委托来传送,有其它与物理相关的委托与它们一起定义,但我们将重点关注这两个委托,因为它们最常用,并且可以通过细节编辑器看到。

OnComponentBeginOverlap
探索Unreal的物理架构_第12张图片

OnComponentBeginOverlap和OnComponentEndOverlap通过UPrimitiveComponent::UpdateOverlaps()分别调用UPrimitiveComponent::BeginComponentOverlap()或UPrimitiveComponent::EndComponentOverlap()得到传播。UpdateOverlaps()可以从AActor::UpdateOverlaps()调用,主要由边缘情况调用,例如关卡加载,或者是碰撞相关的状态在运行时发生更改——需要立即刷新重叠事件的情况。UPrimitiveComponent::UpdateOverlap()被调用的更普遍和频繁的情况是接近UPrimitiveComponent::MoveComponent()的末尾,与物理框架末尾调用的以反映模拟步骤的是同一个函数。

这篇博文范围内最重要的问题是我们如何组装我们正在上面调用BeginComponentOverlap()的组件列表。UpdateOverlaps()将待处理重叠列表作为参数。如果它是从AActor::UpdateOverlaps()执行的,则这将为空。只有在从MoveComponent()执行,bSweep参数设置为 true 的情况下才是非空的,例如调用AActor::SetActorLocation()时候。在这种情况下,它执行扫描查询并将结果当作待处理的重叠。但最常见的bSweep是 false,特别是当MoveComponent()被FPhysScene_Chaos::OnSyncBodies()调用时。

所有这些待处理的重叠都在UPrimitiveComponent::UpdateOverlaps()中被迭代,传播OnComponentBeginOverlap事件。之后,无论是否存在待处理的重叠,它都会调用自身的UPrimitiveComponent::ComponentOverlapMulti(),最终在与我们在跟踪/扫描部分中看到的相同的LowLevelOverlap处结束。比较重叠查询的结果、传入的重叠、和重叠组件帧列表以及上一帧,来确定哪些组件仍然需要触发OnComponentBeginOverlap,哪些组件现在需要触发OnComponentEndOverlap。

这里的所有都是它的!无论是通过物理模拟还是手动移动,原始组件都通过UpdateOverlaps()函数管理重叠事件处理。

OnComponentHit
探索Unreal的物理架构_第13张图片

虽然OnComponentHit在UPrimitiveComponent中定义并专门属于UPrimitiveComponent,但实际上它仅由AActor成员函数广播。令人讨厌的是,在AActor中有两个几乎相同的函数用于调度命中事件,用于不同的上下文:DispatchPhysicsCollisionHit()和DispatchBlockingHit()。在这两种情况下,它都会调用NotifyHit()(调用BlueprintImplementableEvent ReceiveHit),然后广播AActor::OnActorHit()和UPrimitiveComponent::OnComponentHit()事件。

DispatchPhysicsCollisionHit
AActor::DispatchPhysicsCollisionHit()是广播组件命中事件的最频繁的路径。它由FPhysScene_Chaos::HandleCollisionEvents()所执行,这是一个在SyncBodies()被调用后立即在FChaosScene::EndFrame()中被调度的事件。

FPhysScene_Chaos::HandleCollisionEvents()消耗FCollisionEventData事件,这些事件由FEventDefaults::RegisterCollisionEvent()生成。 我们没有并且将继续不仔细查看FEventManageror或FEventDefaults,除了看到RegisterCollisionEvent()被执行,就在AdvanceOneTimeStepTask::DoWork()在调用FPBDRigidsEvolutionGBF::AdvanceOneTimeStep()后不久调用FEventManager::FillProducerData()时。如何检测碰撞的细节一头扎进了FPBDRigidsEvolutionGBF的内部工作原理,因此我们不会覆盖它们。可以说碰撞几乎是在模拟步骤完成后立即累积,然后在物理框架结束时传播。

然而,值得一提的是FPhysScene_Chaos管理这些碰撞事件做的工作。它只编译碰撞事件,如果它们被监听,它用FPhysScene_Chaos::RegisterForCollisionEvents()处理。这被FInitBodiesHelperBase::InitBodies()调用, 就在FChaosScene::AddActorsToScene_AssumesLocked()之后!如果我们在运行时开始或停止监听碰撞事件,自然也会调用它,例如用UPrimitiveComponent::SetNotifyRigidBodyCollision()。

DispatchBlockingHit
AActor::DispatchBlockingHit()是一种比DispatchPhysicsCollisionHit更“通用”的方式来分发AActor上的命中事件。它的签名与DispatchPhysicsCollisionHit()的签名明显不同:虽然该函数采用特定于物理的结构体FRigidBodyCollisionInfo和FCollisionImpactData,使用它们来确定UPrimitiveComponents 和事件的FHitResult参数,但DispatchBlockingHit()直接采用指向UPrimitiveComponents的指针并传递提供不进行修改的FHitResult。如果你想手动在一个AActor上分发一个碰撞事件,这是一个自然的选择。 除此之外,它用于“范围内移动”,或者如果UPrimitiveComponent::MoveComponent()在bSweep参数设置为true,并且发生阻塞碰撞的情况下被调用,例如在调用AActor::SetActorLocation()时,就像我们看到的重叠一样。

那是为了查询。虽然它不actor表示或模拟tick那样包罗万象,但它确实进入了一些熟悉的地方!

结论

这就是我要说的关于物理框架的全部内容!我们了解了物理引擎如何概念化我们的actors,我们深入研究了tick函数,直到找到真正的与物理相关的数学,然后我们弄清楚了与物理相关的数学如何回到引擎中。我真诚地希望这篇博文在某种程度上有所帮助,或者至少可以帮助你了解在何处设置断点以进行您自己的研究。这对我来说是一项艰巨的任务。令人难以置信的是我花了多少时间在这上面,但我仍然觉得我只是触及了表面。Unreal 确实是一个巨大的代码库,你真的只能通过太大一口差点噎死来了解它的大小。尽管如此,我认为这是一个值得的练习,我在没有谨慎处理虚幻 API 的情况下学到了很多关于 C++ 的知识,而且我感觉更有能力在引擎的深处进行洞穴探险。如果你也从中得到了什么,那将是双倍的价值。

特别感谢Micah Johnston的校对和情感支持。

非常感谢您的阅读!在社交媒体上关注我:

Twitter
Mastodon
Cohost

脚注

单击^符号返回正文。


  1. ^你可能直接注意到PhysicsScene下面还有两个指向FPhysScene_Chaos对象的指针。这些从未在实践中使用过,很可能是从 PhysX 到 Chaos 迁移的遗留问题。 ↩︎

  2. ^这不完全正确,例如物理约束组件不继承自UPrimitiveComponent(尽管它们确实与UPrimitiveComponent交互!),但对于我们的探索来说这是足够真实的。 ↩︎

  3. ^迂腐地,引擎在加载真实地图之前在启动期间加载了一个虚拟世界。 ↩︎

  4. ^Chaos有四种形状的“primitive”类型:球体、盒子、“Sphylinder”(胶囊)和“Tapered Capsule”。Unreal 5.1.0 添加了一种新类型, Level Set——我们不会关注这些。其他一切都是“凸形”。这里是一篇非常有趣的博文,作者是一位 PhysX 开发人员,讲述了为什么他们没有向 PhysX 添加“Cylinder”形状,这很可能是 Chaos 也没有它的原因! ↩︎

  5. ^这里它也调用FPhysScene_Chaos::UpdateKinematicsOnDeferredSkelMeshes(),这对骨骼物理学来说似乎非常重要,但超出了我的范围。 ↩︎

  6. ^交互的另一个例子可能是在primitive component上调用AddImpulse()。这采用了不同的路径(绕过FPhysScene_Chaos和FBodyInstance),但到达了与我们的直接移动示例相似的地方AddDirtyProxy(),最终到达AddDirtyProxy()(以相同的方式被调用)。 ↩︎

  7. ^脏的“Property”于添加到FPushPhysicsData的脏缓冲区的“Proxy”而言是一个单独的对象,当我们更新 actor 时,比如说在玩家移动的情况下,它会在与proxy相同的位置被标记为脏。自然地,当我们在生成物体的时候第一次注册 actor 时,我们并没有弄脏属性,因此我们可以有一个脏代理而没有脏属性,但反之则不行。 ↩︎

  8. ^首先,CompleteSceneSimulation()调用FPBDRigidDirtyParticlesBuffer::BufferPhysicsResults()(不同于的FPBDRigidsSolver的BufferPhysicsResults()),它将粒子组装成FPBDRigidDirtyParticlesBufferOut->DirtyGameThreadParticles,一个FSingleParticlePhysicsProxy对象数组。但是,我找不到它曾经在哪里被使用过。 ↩︎

  9. ^ FChaosResultsManager是FPBDPhysicsSolver的一个辅助类,由它操作从FChaosMarshallingManager中提取数据以进行此步骤。 ↩︎

  10. ^TSceneCastCommon()中的一项注意事项是,根据线程上下文是游戏线程还是物理线程在模板函数之间进行条件选择。这两个路径除了使用Traits还是PTTraits作为Traits模板参数之外是相同的,尽管我没有研究这种差异意味着什么。 ↩︎

你可能感兴趣的:(ue5)