Unity物理引擎源码解析:从碰撞检测到性能优化的底层原理 1. 项目概述为什么我们要深入Unity物理引擎源码如果你在Unity里做过一个简单的盒子从斜坡上滚下来的效果或者让角色跳起来再落下那你已经和Unity的物理系统打过交道了。对于大多数日常开发我们只需要知道给物体挂上Rigidbody和Collider设置好参数物理引擎就会自动处理碰撞、重力、摩擦力这些事。这很方便但当你遇到一些“诡异”的物理现象时——比如两个高速运动的物体穿模而过、复杂的关节系统突然抽搐、或者网络同步时物理状态对不上——仅仅调整参数可能就无能为力了。这时理解引擎底层是如何运作的就从一个“加分项”变成了“必需品”。这个系列我们就来啃一啃Unity物理引擎源码这块硬骨头。我们的目标不是通读数百万行代码而是像侦探一样带着具体问题去源码中寻找线索碰撞检测究竟在哪一帧生效FixedUpdate的调用时机背后有什么玄机物理材质Physic Material的参数是如何最终影响碰撞行为的通过这次探索你不仅能学会如何定位和阅读引擎源码更能建立起对物理系统运行机制的直觉。下次再遇到棘手的物理Bug你就能从“凭感觉瞎调”升级到“有根据地排查”甚至能通过编写自定义的物理组件来优雅地解决问题。这对于追求极致性能、实现复杂物理交互如布娃娃系统、载具物理、破坏效果或从事网络同步开发的开发者来说价值巨大。2. 物理系统架构总览从用户接口到底层计算Unity的物理系统是一个典型的分层架构理解这个架构是阅读源码的路线图。我们可以把它从上到下分为四层脚本交互层、物理组件层、引擎抽象层和原生计算层。2.1 脚本交互层我们写的C#代码这是我们最熟悉的一层。所有以OnCollisionEnter、OnTriggerStay、Rigidbody.AddForce开头的代码都属于这一层。这一层的代码运行在MonoBehaviour的生命周期中主要作用是向物理系统提交请求如施加力和接收物理系统的回调如碰撞事件。注意这一层发生的所有事情本质上都是物理系统在上一帧物理模拟完成后将结果“通知”给游戏逻辑层。这意味着你在Update中读取的Rigidbody.velocity其实是上一帧物理模拟结束时的速度。2.2 物理组件层Rigidbody, Collider Co.这一层由Unity引擎提供的各种物理组件构成如Rigidbody、BoxCollider、SphereCollider、CharacterController以及各种关节HingeJoint,ConfigurableJoint等。这些组件是脚本层与底层引擎沟通的桥梁。每个组件在内部都持有一个对底层物理引擎中对应“Actor”或“Shape”的引用通常是一个指针或句柄。当你修改Rigidbody.mass时组件并不会立即计算而是将这个修改标记为“脏数据”等到物理系统开始新一轮模拟前再将这些变更同步到底层引擎。2.3 引擎抽象层PhysX的C#封装这是Unity源码中我们可以直接阅读和调试的部分主要是C#代码。Unity并没有从头实现一个物理引擎而是集成了业界成熟的解决方案对于3D物理是NVIDIA PhysX对于2D物理是Box2D一个开源的2D物理引擎。Unity在这一层做了大量的封装工作提供了一个统一的、面向对象的C# API。它的核心职责包括对象生命周期管理当你在场景中创建一个带Rigidbody的GameObject时这一层的代码会调用PhysX的API在PhysX的世界中创建一个对应的刚体对象。数据转换与同步将Unity的Transform位置、旋转数据转换为PhysX需要的格式并在每帧物理模拟前后进行同步。查询与回调执行射线检测、形状重叠检测等查询并收集PhysX模拟产生的碰撞、触发事件准备分发给脚本层。这一层的代码位于Unity引擎安装目录下的类似Editor\Data\Managed\UnityEngine\UnityEngine.PhysicsModule.dll及相关源码中如果你有源码访问权限。对于大多数开发者通过反编译工具如ILSpy, dnSpy或Unity官方提供的部分源码参考可以查看这一层。2.4 原生计算层PhysX引擎核心这是物理计算的“心脏”由C编写的PhysX库实现。Unity在发布游戏时会将对应平台Windows、Android、iOS等的PhysX原生库一起打包。这一层执行所有重量级的计算宽相位碰撞检测快速筛选出可能发生碰撞的物体对避免进行所有物体间的两两检测。窄相位碰撞检测精确计算筛选后物体对之间的接触点、法线和穿透深度。约束求解计算关节、接触点产生的约束力确保物体不会相互穿透并满足关节的运动限制。积分器根据物体受到的合力重力、力、约束力等通过积分运算更新物体的速度和位置。这一层的代码对我们是“黑盒”但通过Profiler、日志和其公开的文档我们可以推断其行为。理解这一层的存在至关重要因为它解释了为什么某些操作如每帧修改大量刚体的位置会非常耗时——这涉及到频繁的C#到C的跨语言调用和数据同步。3. 核心循环揭秘FixedUpdate、物理步与渲染帧的舞蹈物理模拟最让人困惑的一点就是它的更新节奏。为什么要有FixedUpdate它和Update到底是什么关系源码揭示了这背后精密的协作机制。3.1 物理模拟的独立时钟Fixed Timestep物理模拟需要确定性和稳定性。确定性意味着在相同的初始状态下给予相同的输入模拟的结果必须完全相同。稳定性意味着即使帧率Update的调用频率波动物理模拟也不会“爆炸”比如物体因计算误差获得巨大速度飞出去。为了实现这两点物理系统在一个固定的时间步长上运行这个步长就是Time.fixedDeltaTime默认0.02秒即每秒50次。无论游戏实际帧率是60fps还是30fps物理引擎都试图以每秒50次的频率稳定地推进模拟时间。这个固定的更新周期就是FixedUpdate函数被调用的时刻。在Unity源码中存在一个独立的“物理管理器”PhysicsManager或类似模块它内部维护着一个累积的“物理时间”。每一帧Update开始前引擎会检查自上一帧以来过去了多少真实时间Time.deltaTime并将其累加到物理时间中。然后只要累积的物理时间大于或等于fixedDeltaTime引擎就执行一次物理模拟步进并调用所有FixedUpdate方法。// 概念性伪代码解释物理步进逻辑 float accumulatedTime 0; void GameLoop() { float deltaTime GetRealDeltaTime(); // 获取真实帧间隔 accumulatedTime deltaTime; while (accumulatedTime fixedDeltaTime) { Physics.Simulate(fixedDeltaTime); // 执行一次物理模拟 CallAllFixedUpdate(); // 调用所有FixedUpdate accumulatedTime - fixedDeltaTime; } CallAllUpdate(); // 调用所有Update Render(); // 渲染 }这种设计意味着在一帧渲染周期内可能会执行零次、一次或多次FixedUpdate。当帧率很高时比如120fps可能每两帧渲染才执行一次FixedUpdate当帧率很低时比如25fps为了追上物理时间一帧内可能需要执行两次FixedUpdate。这就是为什么物理计算如力的施加要放在FixedUpdate里——它能保证与物理引擎的模拟步调一致。3.2 物理模拟Physics.Simulate内部发生了什么一次Physics.Simulate(fixedDeltaTime)的调用在底层会触发一系列有序的操作同步变换Transform Synchronization To Physics将所有Rigidbody关联的Transform的最新位置和旋转从游戏逻辑层同步到物理引擎层。注意对于由物理引擎控制的刚体Rigidbody.isKinematic false这通常只是同步初始状态或由动画等外部系统驱动的运动 kinematic rigidbody。应用力与速度Apply Forces and Velocities处理在上一帧中通过Rigidbody.AddForce、AddTorque或直接设置velocity/angularVelocity的请求。碰撞检测Collision Detection离散检测Discrete这是默认模式。检测当前时刻静态物体之间是否重叠。对于快速移动的物体可能发生“隧道效应”子弹穿过薄墙。连续检测Continuous通过预测物体在本帧内的运动轨迹从上一帧位置到当前帧位置进行扫描来检测是否与特定物体标记为Continuous Dynamic或所有物体Continuous发生碰撞。这能有效防止隧道效应但计算开销大。源码中会为需要连续检测的物体创建特定的“扫描形状”进行计算。约束求解与积分Constraint Solving and Integration这是PhysX核心的计算环节。求解器会处理所有接触点产生的约束防止穿透和关节约束计算出一个满足所有约束的冲量。然后根据物体受到的合力重力外力约束力使用数值积分方法如Symplectic Euler更新物体的线速度和角速度最后根据速度更新位置。同步变换回游戏逻辑Transform Synchronization From Physics将物理引擎计算出的新位置和旋转写回到Rigidbody关联的Transform组件上。这样在Update中读取transform.position时你看到的就是经过物理模拟后的最新位置。事件收集与分发Event Collection and Dispatching收集在本次模拟中发生的所有碰撞Collision和触发Trigger事件暂存起来。这些事件不会立即调用OnCollisionEnter等函数。它们要等到所有物理模拟步骤都完成后在Update之前的某个特定阶段通常是PhysicsUpdate阶段才被批量分发到对应的游戏对象上。3.3 关键源码文件与类指引如果你想亲自探索源码以下是一些关键路径和类名基于常见的Unity版本结构具体路径可能略有不同物理系统入口与管理UnityEngine.Physics/Physics2D静态类包含Simulate,Raycast,OverlapBox等全局API。UnityEngine.PhysicsManager(Internal)管理物理模拟的全局设置和更新循环。3D物理组件核心UnityEngine.Rigidbody封装刚体属性内部通过RigidbodyInteral等类与PhysX交互。UnityEngine.Collider及其子类BoxCollider,SphereCollider等管理碰撞形状。UnityEngine.PhysicMaterial定义摩擦力和反弹系数。与PhysX的接口查找包含PhysX、Px(PhysX API前缀) 的包装类。Unity使用一个C/CLI或P/Invoke层来调用原生的PhysX库。实操心得调试物理问题的一个有效方法是使用Physics Debug Draw。在编辑器模式下通过Physics设置或代码Debug.DrawLine、Debug.DrawRay可以可视化碰撞体、接触法线、受力方向等。这比单纯看日志更能直观理解物理系统的瞬时状态。4. 碰撞检测流程深度解析从Broad Phase到Contact Points碰撞检测是物理引擎最核心的功能之一其性能直接决定了游戏的流畅度。UnityPhysX采用经典的两阶段宽相位窄相位甚至三阶段检测流程来优化。4.1 宽相位Broad Phase快速筛选宽相位的目标是用极低的代价从场景中所有可能发生碰撞的物体对N*(N-1)/2对复杂度O(N²)中快速剔除那些绝对不可能碰撞的物体对得到一个较小的“潜在碰撞对”列表。PhysX主要使用两种数据结构进行宽相位轴对齐包围盒层次树AABB Tree为每个碰撞体计算一个轴对齐包围盒AABB并将这些AABB组织成一棵二叉树。通过遍历树可以快速找到所有AABB相交的物体对。这种结构对静态或移动缓慢的物体群组非常高效。增量式扫描与剪裁Sweep and Prune, SAP动态维护物体在X、Y、Z轴上的投影区间及其排序。当物体移动时只需更新其区间端点位置并调整排序就能快速找出在三个轴上区间都重叠的物体对。这对大量动态物体场景有优势。在Unity中你可以通过Physics设置中的Default Contact Offset和Tolerance Length等参数间接影响宽相位的精度和性能。更小的Contact Offset意味着AABB的膨胀更小宽相位筛选更精确但可能增加漏检风险尤其是对高速物体。4.2 窄相位Narrow Phase精确接触计算经过宽相位筛选后剩下的每一对潜在碰撞物体都会进入窄相位。这里会根据碰撞体的具体几何形状球、盒、胶囊、凸包、网格进行精确的相交测试并计算出详细的接触信息。基础图元检测对于球-球、球-盒、盒-盒等组合有高度优化的解析解数学公式。凸包与网格检测凸包Convex Hull间的检测使用Gilbert–Johnson–Keerthi (GJK) 算法和 Expanding Polytope Algorithm (EPA) 算法来求取最近距离和穿透深度。网格Mesh碰撞体由于是非凸的计算非常昂贵通常只用于静态环境。PhysX会将其分解为多个凸块Convex Decomposition或使用其三角形数据进行检测。窄相位输出的核心数据是接触流形Contact Manifold。它包含了接触点Contact Points通常为1-4个点描述了物体接触的位置。接触法线Contact Normal从物体A指向物体B的单位向量表示碰撞的方向。穿透深度Penetration Depth物体相互嵌入的距离。这些数据是后续约束求解器计算碰撞反作用力的基础。4.3 连续碰撞检测CCD对抗隧道效应对于高速运动的物体如子弹离散检测会失效因为物体从墙的一侧“瞬移”到了另一侧中间没有重叠的帧。CCD通过追踪物体在本帧时间步长内的**运动扫描体Swept Volume**来解决这个问题。对于启用CCD的刚体PhysX不仅检测其当前时刻的形状还会检测从上一帧位置到当前帧位置这条线段所扫过的空间区域。实现方式一种常见的方法是进行“射线投射”或“形状扫描”。例如将一个高速运动的球体看作一条射线其运动轨迹加上一个半径检测这条“厚射线”是否与环境相交。在Unity中通过设置Rigidbody.collisionDetectionMode为CollisionDetectionMode.Continuous或ContinuousDynamic来启用。需要注意的是性能开销CCD的计算成本远高于离散检测应仅对少数高速物体启用。与静态碰撞体Continuous模式只检测与静态网格碰撞体的连续碰撞。ContinuousDynamic则还会检测与其他连续动态刚体的连续碰撞。配置Physics设置中的Default Contact Offset和CCD** 设置中的Min Penetration For Penalty等参数会影响CCD的灵敏度和稳定性。5. 物理材质与碰撞响应摩擦力与反弹的微观世界当两个物体碰撞时它们如何滑动、如何反弹主要由附加在碰撞体上的Physic Material物理材质控制。理解其参数如何影响求解器是调出“手感”的关键。5.1 摩擦模型Friction Model摩擦力阻止物体相对滑动。PhysX使用库伦摩擦模型的一个近似版本。动态摩擦力Dynamic Friction物体相对滑动时的摩擦力。PhysicMaterial.dynamicFriction系数乘以接触点法向力的大小得到滑动摩擦力的大小方向与相对滑动速度方向相反。静态摩擦力Static Friction物体相对静止时抵抗开始滑动的力。通常静态摩擦系数(staticFriction)略大于动态摩擦系数。求解器会计算一个“摩擦力锥”只要合力在锥内物体就保持静止。Friction Combine模式决定了当两个拥有不同摩擦系数的材质碰撞时如何计算最终使用的摩擦系数。Average取平均值。Min取最小值会导致更滑。Max取最大值会导致更涩。Multiply两者相乘通常会产生非常小的系数极其光滑。5.2 反弹Bounciness与恢复系数反弹由PhysicMaterial.bounciness取值0-1控制它对应于物理中的恢复系数Coefficient of Restitution, COR。COR 0完全非弹性碰撞碰撞后粘在一起没有法向分离速度。COR 1完全弹性碰撞碰撞后沿法线方向分离的速度大小等于碰撞前接近的速度大小理想情况无能量损失。求解器在计算碰撞冲量时会使用这个系数来调整法线方向的冲量大小从而模拟能量损失。同样Bounce Combine模式决定了两个材质碰撞时反弹系数的组合方式。常见问题排查为什么我的球弹不起来首先检查物理材质的Bounciness是否大于0。其次检查碰撞双方是否都附加了物理材质。如果只有一方有反弹可能不会按预期工作。最后过高的质量比比如一个质量极大的地板也会吸收小球的反弹能量使其看起来弹不起来。5.3 高级参数接触偏移与求解器迭代Contact Offset这个值定义了一个“皮肤”厚度。当两个物体的距离小于这个偏移量时PhysX就认为它们已经“接触”并开始产生排斥力。这可以防止物体因数值误差而轻微穿透也能让求解器更平滑地处理即将发生的碰撞。设置过小会导致抖动设置过大会导致物体在明显分开时仍有“吸附感”。Solvers在Physics设置中Default Solver Iterations和Default Solver Velocity Iterations控制着约束求解器的迭代次数。迭代次数越多求解越精确例如堆叠更稳定关节约束更硬但CPU开销也越大。通常6-10次位置迭代和1-2次速度迭代是平衡点。6. 性能优化与调试实战指南理解了原理最终要服务于性能。物理系统是CPU密集型模块不当的使用会导致帧率骤降。6.1 性能瓶颈分析与工具Unity Profiler这是首要工具。在Profiler的CPU使用率详情中关注Physics.Processing、Physics.Simulate和Physics.UpdateBodies等项。它们直接反映了物理模拟的耗时。高Physics.Processing时间通常意味着场景中活动刚体或复杂碰撞体过多。高Physics.UpdateBodies时间可能意味着频繁地通过脚本修改大量刚体的位置/旋转导致频繁的C#到C数据同步。Physics Debug Visualization在Game视图下拉菜单或Physics设置窗口中可以启用Colliders、Contact Points、Collision AABBs等可视化选项。这能帮你快速发现不必要的复杂网格碰撞体。意外激活或数量过多的刚体。碰撞体大小与渲染网格严重不匹配。6.2 关键优化策略碰撞体简化是第一要务永远不要将高面数的渲染网格直接用作MeshCollider除非是静态背景且必需。对于复杂物体使用多个基础碰撞体盒、球、胶囊组合近似或使用低面数的凸包代理。对于移动平台考虑将3D碰撞降级为2D如用胶囊体代替角色用盒子代替车辆。明智地使用刚体将不会移动的环境物体设置为Static无Rigidbody。这能让物理引擎对其进行特殊优化如纳入静态AABB树。对于需要移动但不受力影响的物体如移动平台使用Kinematic刚体并通过脚本控制其运动。这比用巨大的力去推一个动态刚体要高效和稳定得多。合理使用Rigidbody.sleepMode。进入睡眠状态的刚体在未被碰撞唤醒前不会参与每帧的物理计算。分层碰撞管理Layer Collision Matrix在Physics Settings中精心配置层碰撞矩阵。让不需要相互碰撞的物体层完全忽略对方如子弹层与子弹层、特效层与环境层。这能在宽相位阶段就剔除大量无效检测。控制物理更新频率在保证物理稳定性的前提下可以尝试适当增大Time.fixedDeltaTime例如从0.02s增加到0.033s即30Hz。这会降低物理更新的频率直接减少CPU负担但会降低物理模拟的平滑度。对于手机等性能受限平台这是一个重要的权衡手段。减少每帧的物理查询Physics.Raycast、OverlapSphere等查询函数开销不小尤其是在每帧Update中对大量物体进行查询时。考虑降低查询频率如每3帧一次。使用Physics.SphereCastNonAlloc等非分配内存的版本避免GC。将结果缓存起来复用。6.3 复杂问题排查清单当你遇到棘手的物理Bug时可以按以下清单逐步排查问题现象可能原因排查步骤与解决方案物体抖动或振动1. 碰撞体轻微穿插。2. 多个约束冲突。3. 物理更新频率(Fixed Timestep)与渲染帧率不匹配导致的“时间伸缩”。1. 检查Contact Offset是否过小适当增大如从0.01调到0.02。2. 检查是否有多个关节或碰撞体在争夺同一个物体的位置。简化约束。3. 确保Rigidbody.interpolation设置为Interpolate对由物理驱动的物体或Extrapolate对由脚本驱动的运动物体这可以在渲染帧间平滑位置。高速物体穿模离散碰撞检测CCD未启用。1. 为高速物体如子弹、炮弹的Rigidbody设置CollisionDetectionMode为ContinuousDynamic。2. 同时将被穿透的薄墙等碰撞体的CollisionDetectionMode设为Continuous或ContinuousDynamic。堆叠物体不稳定1. 求解器迭代次数不足。2. 质量比过于极端。3. 碰撞体形状不规则接触点少。1. 增加Physics设置中的Default Solver Iterations如从6增加到10。2. 确保堆叠物体的质量在同一数量级避免一个极轻的物体放在一个极重的物体上。3. 为堆叠物体使用稳定的基础形状如盒子避免使用球体或胶囊体作为底座。关节如铰链过于柔软或断裂1. 关节约束力不足。2. 作用在关节上的外力如碰撞过大。3. 求解器迭代次数不足。1. 检查关节的Break Force和Break Torque是否设置过低。2. 尝试增加关节的Spring和Damper参数来增强刚性对于ConfigurableJoint。3. 增加Solver Iterations特别是Solver Velocity Iterations。物理表现不一致非确定性1. 浮点数计算在不同硬件/平台上的细微差异。2. 更新顺序依赖如多个脚本在FixedUpdate中修改同一个刚体。3. 使用了Time.deltaTime而非Time.fixedDeltaTime进行物理相关计算。1. 对于需要严格确定性的游戏如RTS、格斗这是固有挑战。Unity的新DOTS Physics旨在解决此问题。2. 确保所有物理相关的力/速度修改都在FixedUpdate中进行并使用Time.fixedDeltaTime。3. 避免在物理计算中使用任何可能产生随机性的函数。深入源码的世界起初可能会被其复杂性和庞大的规模所震慑但只要你带着明确的问题沿着我们梳理的架构和流程去追踪每一次探索都会有收获。物理引擎不再是神秘的黑盒而是一个由精妙算法和严谨工程构建的系统。这份理解最终会转化为你游戏中更稳定、更高效、更可信的物理交互以及面对难题时那份从容不迫的调试能力。在接下来的篇章中我们将继续深入探讨刚体运动控制、关节系统、射线检测的底层实现以及如何利用这些知识编写自定义的物理交互组件。