Unity物理系统核心原理:从碰撞检测到约束求解的源码级解析
1. 项目概述:从“黑盒”到“白盒”的物理系统探索
如果你在Unity里做过一个简单的盒子从斜坡上滚下来的效果,或者实现过一个复杂的布娃娃系统,那你一定和物理系统打过交道。大多数时候,我们把它当作一个“黑盒”:设置刚体(Rigidbody)、碰撞体(Collider),调整几个参数,然后物理引擎就会自动处理碰撞、重力、关节约束这些复杂的事情。但当你遇到一些棘手的问题,比如物体莫名穿透、关节抖动、或者性能突然下降时,仅仅调整参数往往无济于事。这时,深入引擎源码,理解物理系统内部的运作机制,就成了解决问题的关键。这也是我们继上一篇基础架构分析后,继续深入“Unity引擎源码-物理系统详解”的原因。
这次,我们把焦点从宏观架构转向更核心的模拟流程与算法实现。物理系统的核心任务,是在每一帧将场景中所有物理实体的状态(位置、旋转、速度)向前推进一小步。这个过程看似简单,实则内部包含了碰撞检测(Broad Phase & Narrow Phase)、碰撞求解(Constraint Solver)和积分(Integration)等多个精密环节。理解这些环节,不仅能帮你精准定位“物体为什么穿墙了”、“关节为什么抖个不停”这类问题,更能让你在开发赛车游戏、物理解谜或复杂的角色交互时,拥有优化性能和稳定性的主动权。无论是使用内置的PhysX,还是探索基于DOTS的Unity Physics或Havok Physics,其底层逻辑是相通的。我们将剥开这层外壳,看看Unity是如何将物理世界的法则,转化为一行行可执行的代码。
2. 物理模拟的核心循环拆解
Unity的物理模拟,无论是基于GameObject的传统系统还是基于ECS的DOTS物理,都遵循一个经典的“模拟循环”。这个循环是物理引擎的心脏,驱动着整个虚拟世界的运动。理解这个循环,是理解一切物理现象和调试问题的基石。
2.1 传统物理系统(PhysX)的帧内流程
在传统的、基于MonoBehaviour的系统中,物理模拟独立于渲染帧运行,其频率由Time.fixedDeltaTime控制。一个完整的FixedUpdate物理帧内,引擎内部大致执行以下顺序:
应用力与扭矩(Apply Forces/Torques):在
FixedUpdate函数调用后,物理引擎会收集所有作用于刚体上的力(如AddForce)、扭矩(AddTorque)和冲量(AddImpulse)。这是改变物体运动状态的输入阶段。宽阶段碰撞检测(Broad Phase):这是性能优化的第一道关卡。引擎不会愚蠢地让场景中每一个物体都去和另一个物体检测碰撞。宽阶段的目标是快速找出可能发生碰撞的物体对(Pair)。Unity PhysX通常使用轴对齐包围盒(AABB)层次结构(如动态AABB树)来实现。它会遍历所有碰撞体,用它们的AABB进行快速的重叠测试,将大量不可能碰撞的组合剔除,生成一个“潜在碰撞对”列表。你可以通过
Physics类的设置来调整宽阶段算法,例如在复杂场景中,合理的AABB膨胀(Physics.defaultContactOffset)能平衡精度和性能。窄阶段碰撞检测(Narrow Phase):对上一步筛选出的每个潜在碰撞对,进行精确的几何相交测试。这是计算密集型操作。例如,一个BoxCollider和一个MeshCollider的检测,就需要进行多边形层面的精确计算。此阶段会计算出碰撞的详细信息:接触点(Contact Point)、接触法线(Contact Normal)和穿透深度(Penetration Depth)。这些数据是后续求解的基础。
求解器准备(Solver Setup):将碰撞信息、关节约束等,全部转化为物理引擎内部求解器能处理的数学形式——通常是约束方程。例如,一个碰撞约束可以理解为“两个物体在接触点法线方向上,相对速度不能为负(即不能继续穿透)”。关节则更复杂,可能包含位置、旋转、角度限制等多个约束。
约束求解(Constraint Solver):这是物理模拟中最核心、最复杂的步骤。求解器(如PhysX使用的PGS迭代求解器)的任务是,解算所有约束方程,计算出为了满足这些约束(不穿透、关节连接),每个刚体所需要的速度(或冲量)调整量。这个过程是迭代的,迭代次数(
Physics.defaultSolverIterations)直接影响模拟的稳定性和精度。迭代次数少,计算快但可能不稳定(关节松散、堆叠晃动);迭代次数多,更稳定但更耗时。积分(Integration):根据求解器计算出的最终速度(已考虑了碰撞和约束的反作用),结合上一帧的位置,通过积分公式(如半隐式欧拉法)更新每个刚体的位置(Position)和旋转(Rotation)。同时,也会更新一些内部状态,如用于睡眠判断的速度缓存。
触发与回调(Triggers & Callbacks):如果发生碰撞的物体设置了
isTrigger,或者脚本中监听了OnCollisionEnter等消息,物理引擎会在此阶段组织并派发这些回调事件到游戏逻辑层。睡眠管理(Sleep Management):为了节省性能,几乎静止的物体会进入“睡眠”状态。引擎会检查刚体的动能,如果低于某个阈值(
Physics.sleepThreshold)且一段时间内没有受到外力,则让其“入睡”,在后续帧中跳过该物体的绝大部分物理计算,直到它被碰撞或外力唤醒。
注意:这个顺序是逻辑上的,实际在PhysX原生代码中可能交错或并行。但作为使用者,建立这个顺序模型对调试至关重要。例如,在
FixedUpdate中修改刚体位置,会影响宽/窄阶段检测;而在OnCollisionEnter中施加力,则要等到下一帧的“应用力”阶段才会生效。
2.2 DOTS物理(Unity Physics/Havok Physics)的并行化革新
基于ECS架构的DOTS物理系统,核心目标是将上述流程并行化、批量化,以榨干多核CPU的性能。其模拟循环在概念上相似,但数据组织和执行方式有本质区别:
数据布局:所有物理组件(如
PhysicsVelocity,PhysicsCollider,PhysicsMass)都是IComponentData,存储在紧密排列的Chunk内存中。这种布局对CPU缓存极其友好,是高性能的基石。作业系统调度:模拟循环中的每一个阶段(如碰撞检测、求解器、积分)都被封装成一个或多个
IJobEntity或IJob。Unity的作业系统(Job System)会自动将这些作业调度到多个CPU核心上并行执行。例如,计算所有刚体下一帧速度的积分作业,可以轻松地并行处理成千上万个实体。碰撞检测的并行化:DOTS物理使用全新的、为并行计算设计的碰撞检测算法。宽阶段可能使用并行化的Sweep and Prune或并行边界体积层次结构(BVH)构建。窄阶段的几何测试也被设计成可并行执行。
求解器的差异:Unity Physics作为“无状态”物理,其求解器设计更倾向于避免依赖上一帧的缓存状态,这使得它在网络同步和确定性模拟方面有潜在优势。而Havok Physics for Unity则集成了经过工业验证的、带智能缓存的求解器,在复杂堆叠和稳定性上表现更佳,但其底层核心是闭源的C++引擎。
同步点:尽管作业可以并行,但阶段之间存在依赖关系。比如,必须等所有碰撞检测作业完成,才能开始求解器作业。ECS通过
System的[UpdateBefore/After]属性或EntityCommandBuffer来管理这些依赖和同步。
实操心得:从传统PhysX转向DOTS物理时,最大的思维转变是从“面向对象”到“面向数据”。你不再是通过GetComponent<Rigidbody>来操作单个物体,而是通过Entities.ForEach来批量处理所有具备特定物理组件的实体。这种范式对于大规模、同质化的物理对象(如成千上万的子弹、碎片、粒子)性能提升是颠覆性的,但对于少量、异质性强的复杂交互(如一个主角与复杂环境的多种交互),传统模式在开发效率上可能仍有优势。
3. 碰撞检测的深度解析:从AABB到接触流形
碰撞检测是物理系统中最耗时的部分之一,也是bug的高发区。我们深入看看Unity是如何实现它的。
3.1 宽阶段(Broad Phase)的策略与优化
宽阶段的目标是快速缩减需要精确检测的物体对数量。Unity PhysX主要采用动态AABB树(Dynamic Bounding Volume Hierarchy Tree)。
- 工作原理:引擎为场景中每个活动的碰撞体维护一个轴对齐包围盒(AABB)。这些AABB被组织成一棵二叉树。当物体移动时,其AABB更新,树的结构也会进行局部调整(重插或重构)。每一帧,通过遍历这棵树,可以高效地找出所有AABB重叠的叶子节点对。
- 关键参数影响:
Physics.defaultContactOffset:这个值不仅用于解决浮点误差,在宽阶段中,它会被用来“膨胀(inflate)”物体的AABB。更大的值意味着AABB更大,能更早地触发碰撞检测,避免高速物体穿透,但也会导致更多的潜在碰撞对进入窄阶段,增加计算量。这是一个典型的精度与性能的权衡。Physics.broadphaseType:在某些版本或自定义中,你可以选择宽阶段算法(如Sweep and Prune)。对于动态物体多、运动频繁的场景,动态AABB树通常综合性能更好。
- 调试技巧:在Scene视图开启
Gizmos -> Physics下的Colliders显示,你可以看到碰撞体的线框。高速移动的物体如果发生穿透,可以尝试适当增大defaultContactOffset。同时,观察Profiler窗口的Physics.Processing部分,如果Broadphase耗时异常高,可能是场景中动态物体过多或AABB设置不合理。
3.2 窄阶段(Narrow Phase)的几何对决
窄阶段接收宽阶段传来的物体对,进行精确的几何相交测试。不同类型的碰撞体组合,测试算法完全不同:
- 基础图元对图元:如Sphere-Sphere, Box-Box, Capsule-Capsule。这些有直接的数学公式,速度最快。
- 图元对凸包(Convex Hull):凸包碰撞体(Convex Hull Collider)是性能与精度折中的选择。检测算法通常使用分离轴定理(Separating Axis Theorem, SAT)或Gilbert–Johnson–Keerthi (GJK) 算法配合扩张多面体/平移向量(EPA)算法。GJK用于快速判断是否相交,EPA则在相交时计算穿透深度和方向。
- 网格碰撞体(Mesh Collider):这是性能杀手。当Mesh Collider(特别是非凸的)参与检测时,引擎通常需要将其三角面片与另一个碰撞体进行逐一或分区测试。务必勾选
Convex选项,将其转换为凸包,性能会提升数个数量级。对于静态的环境网格,使用Mesh Collider并标记为Static,引擎会为其生成优化的空间数据结构(如BVH),但动态物体与之碰撞的成本依然很高。
一个关键概念:接触流形(Contact Manifold)窄阶段输出的不是单个接触点,而是一个接触流形——即一组(通常是1-4个)能最好地描述两个碰撞体接触区域的接触点。例如,一个立方体平面放在地上,理想的接触流形是四个角点。使用流形而非单点,能使后续的求解更稳定,防止物体在接触边缘摇摆或旋转。在Unity PhysX中,你可以通过Physics.Contact相关的API(部分需通过底层接口)来获取这些信息,这对于实现自定义的碰撞效果(如根据接触点播放音效)非常有用。
注意事项:
Mesh Collider的Cooking Options(在导入设置或组件上)对性能和稳定性有巨大影响。Use Fast Midphase和Cook For Faster Simulation通常应该开启。对于移动平台,要严格控制网格碰撞体的面数,并积极使用凸包近似或层次化简单碰撞体组合来代替单一复杂网格碰撞体。
4. 约束求解器:物理稳定的幕后功臣
求解器是物理引擎的“大脑”,它负责解决所有冲突的约束。想象一下,有十个盒子堆成一个塔,每个盒子都受到重力,同时盒子之间又有碰撞约束防止相互穿透。求解器的任务就是计算出每个盒子在这一帧应有的移动,让所有约束都尽可能得到满足。
4.1 迭代求解:速度与稳定的平衡
Unity PhysX默认使用顺序冲量法(Sequential Impulse),这是一种投影高斯-赛德尔(Projected Gauss-Seidel, PGS)迭代求解器。它的工作方式很直观:
- 列出所有约束方程(碰撞、关节等)。
- 遍历每一个约束,独立地计算满足该约束所需的冲量调整,并立即应用到相关物体上。
- 由于物体可能同时参与多个约束,一次调整可能会破坏之前已满足的约束。因此,需要重复步骤2多次(即迭代)。
- 经过多次迭代后,所有约束会趋于一个近似解。
关键参数:
Physics.defaultSolverIterations:全局迭代次数。增加此值可以提高堆叠稳定性、关节刚性,但代价是CPU时间线性增加。对于简单的场景,4-6次可能就够了;对于复杂的布娃娃或车辆,可能需要10-20次。Physics.defaultSolverVelocityIterations:速度约束的迭代次数,主要影响接触和关节的反弹、摩擦力求解。通常比位置迭代次数设置得高一些效果更好。
实操心得:不要盲目增加迭代次数。首先应该优化碰撞体:确保没有过于细长或尺度差异巨大的碰撞体(这会导致数值病态);检查是否有持续深度穿透的情况(可能是defaultContactOffset太小或物体生成位置重叠)。对于特定的、需要高稳定性的关节(如角色铰链),可以使用ConfigurableJoint的solverIterationCount属性进行单独覆盖,而不是全局提高开销。
4.2 关节约束的内部实现
关节(Joint)是比碰撞约束更复杂的约束类型。以最灵活的ConfigurableJoint为例,在求解器内部,它会被分解为多个简单的线性约束和角约束:
- 线性限制(Linear Limit):相当于在特定轴上设置了一个“不可逾越”的位置范围约束。
- 角度限制(Angular Limit):限制了绕特定轴旋转的角度。
- 弹簧(Spring)和阻尼(Damper):这些是“软约束”,求解器会将其转化为试图达到目标位置或速度的力,而不是硬性限制。
在源码层面,每个关节类型都会在初始化时,向物理场景注册对应的约束器(Constraint)。在每帧求解阶段,这些约束器会将其当前的物理状态(如连接的两物体的位置差、角度差)转化为求解器能处理的线性互补问题(LCP)形式。
常见问题排查:关节抖动或“爆炸”(突然产生巨大力量飞出去)通常源于:
- 数值误差累积:尝试稍微增加关节的
breakForce(一个不可能达到的值)或调整massScale,改变连接体的有效质量比。 - 约束冲突:例如,同时锁定了某个方向的移动和在该方向设置了弹簧,可能导致求解器振荡。
- 时间步长不稳定:确保
Time.fixedDeltaTime是固定的,且Time.maximumAllowedTimestep能防止卡顿帧导致过大的物理步进。
5. 性能优化与调试实战指南
理解了原理,最终要服务于实践。以下是一些基于源码逻辑的实战优化和调试技巧。
5.1 性能剖析与瓶颈定位
首先,必须善用Unity Profiler:
- 打开
Profiler窗口,切换到Physics或Physics2D子面板。 - 观察
Physics.Processing的总耗时,以及其下的子项:Broadphase:耗时高说明动态物体太多或AABB更新频繁。考虑将静止物体设为Static,或使用Rigidbody的Sleep模式。Narrowphase:耗时高说明复杂碰撞检测多。检查是否大量使用了非凸MeshCollider,或碰撞体三角面片过多。Solver:耗时高说明约束复杂(堆叠多、关节多)。尝试优化迭代次数,或简化物理场景。
- 使用
Physics Debugger(Window -> Analysis -> Physics Debugger, 需安装Package)。它可以可视化物理世界的状态,如显示碰撞体、接触点、刚体睡眠状态等,是定位问题区域的利器。
5.2 针对性的优化策略
- 分层碰撞(Layer Collision Matrix):这是最有效的优化手段之一。在
Edit -> Project Settings -> Physics中,精心设计碰撞矩阵。让不需要相互碰撞的物体层彻底忽略对方(如子弹和子弹、远处的装饰物之间),能直接减少宽阶段和窄阶段的工作量。 - 刚体属性优化:
Interpolate:只在视觉抖动时才开启,它会消耗额外内存和计算进行插值。Collision Detection:对于高速物体,Continuous或Continuous Dynamic可以防止穿透,但性能开销巨大。Continuous Dynamic只对标记为动态的物体进行连续检测,是较好的折中。尽可能使用Discrete,并通过合理设计游戏规则(如限制速度)来避免穿透。- 将不需要受物理驱动的物体(如跟随玩家的摄像机、粒子效果发射器)的
Rigidbody设为Kinematic。
- 碰撞体优化:
- 用简单形状复合:一个复杂物体用多个Box、Sphere、Capsule组合,远比一个Mesh Collider高效。
- 简化网格碰撞体:如果必须用Mesh Collider,使用简化后的低模网格。
- 合理使用Trigger:Trigger不参与物理求解,开销小于普通碰撞体。对于仅需检测重叠的区域,使用Trigger。
5.3 高级调试:深入引擎内部
当你遇到无法用常规手段解释的物理bug时,可能需要更深入的洞察:
- 可视化接触与法线:可以编写一个调试脚本,在
OnCollisionStay或通过Physics.Contact查询,用Debug.DrawLine或Gizmos.DrawRay绘制出每个接触点的位置和法线方向。这能帮你判断碰撞检测是否准确,法线方向是否符合预期。 - 模拟确定性测试:对于需要网络同步或录像回放的游戏,物理的确定性至关重要。确保所有物理相关的计算(包括随机数)都在
FixedUpdate中进行,并使用固定的Time.fixedDeltaTime。可以记录关键刚体几帧内的位置/旋转数据,在相同输入下反复运行,检查输出是否一致。 - 源码辅助理解:虽然看不到PhysX的完整C++源码,但Unity提供了部分封装层的C#源码(通过Unity源码访问或反编译工具)。阅读
Rigidbody、Collider等类的底层接口调用,以及Physics、RaycastHit等结构的定义,能帮助你理解参数是如何传递到底层引擎的。对于DOTS物理,其C#源码是开放的,直接阅读Unity.Physics包中的SimulationStep.cs、CollisionQuery.cs等文件,是理解其并行化实现的最佳途径。
我个人在实际项目中的一个深刻教训:曾有一个赛车游戏,车辆在特定弯道偶尔会莫名弹飞。通过Profiler发现该帧Solver耗时激增。用Physics Debugger可视化后,发现是赛道边缘的一个复杂装饰物Mesh Collider(未勾选Convex)与车轮的多个胶囊碰撞体产生了大量非预期的接触点,导致求解器在该处“卡住”并产生巨大冲量。解决方案是将那个装饰物的碰撞体替换为一个简单的Box Collider,问题立即消失。这个案例让我明白,物理性能问题往往不是均匀分布的,而是由场景中少数几个“热点”引起的,精准定位这些热点是关键。
