公司动态
Unity DOTS物理核心:PhysicsWorld与碰撞系统协同机制详解
1. 项目概述为什么需要深入理解DOTS物理组件如果你正在或打算使用Unity的DOTSData-Oriented Technology Stack技术栈来开发高性能游戏尤其是那些需要处理成千上万个动态物体的项目那么Unity Physics包绝对是你绕不开的核心模块。但很多开发者初次接触时往往会被PhysicsWorld和CollisionSystem这两个核心概念搞得一头雾水。官方文档虽然详尽但更像是一本说明书缺少将各个部件串联起来的“故事线”。我自己在项目里从传统的GameObject物理迁移到DOTS物理时也踩了不少坑。最典型的问题就是明明按照ECS的规则创建了PhysicsCollider和PhysicsVelocity但物体要么不动要么穿模调试起来像在盲人摸象。后来才发现问题的根源在于没有真正理解物理世界PhysicsWorld是如何被构建的以及碰撞系统CollisionSystem是如何与它协同工作的。这不仅仅是两个孤立的系统它们共同构成了DOTS物理模拟的“发动机”和“交通规则”。简单来说PhysicsWorld是物理状态的快照和数据库它存储了某一帧所有碰撞体、刚体的空间位置、形状和速度等数据。而CollisionSystem更准确地说是PhysicsStepSystem或BuildPhysicsWorld等系统组成的流水线是处理器和规则执行者它负责读取PhysicsWorld的数据计算碰撞、施加力并最终将结果写回PhysicsWorld和你的实体组件。搞懂它们之间“谁先谁后”、“数据怎么流”是写出稳定、高效DOTS物理代码的关键。这篇文章我就结合自己的实战经验把这套协同机制掰开揉碎了讲清楚。2. PhysicsWorldDOTS物理的“中央数据库”PhysicsWorld是Unity.Physics包中最核心的数据结构。你可以把它理解为一个针对物理模拟高度优化的、只读的对用户而言数据仓库。它并不直接挂在某个实体上而是由BuildPhysicsWorld系统在每帧开始时为你构建好的一个“场景物理状态镜像”。2.1 PhysicsWorld的内部结构与创建时机当你为实体添加了PhysicsCollider、PhysicsVelocity、PhysicsMass等组件后BuildPhysicsWorld系统会在InitializationSystemGroup中运行。它的工作就是遍历所有包含这些物理组件的实体将它们的数据收集、整理并填充到一个全新的PhysicsWorld实例中。这个过程主要包括构建**Broadphase粗检测和Narrowphase细检测**所需的数据结构。// 这是一个概念性示意帮助你理解BuildPhysicsWorld在做什么 public partial struct BuildPhysicsWorldSystem : ISystem { public void OnUpdate(ref SystemState state) { // 1. 从EntityQuery中获取所有具有物理组件的实体 // 2. 为这些实体计算当前帧的AABB轴对齐包围盒 // 3. 基于AABB构建BVH层次包围盒树用于高效的空间查询和粗检测 // 4. 将实体的碰撞体形状、变换矩阵、速度等数据打包 // 5. 将所有数据存入一个新创建的PhysicsWorld实例 // 这个PhysicsWorld实例随后会被存储在单例组件PhysicsWorldSingleton中 } }关键点在于这个PhysicsWorld是每帧新建的。它捕获的是当前帧Update开始时所有物理实体的状态。这意味着在BuildPhysicsWorld之后如果你直接修改实体的Translation或Rotation组件PhysicsWorld里的数据并不会自动更新它已经“定格”了。这个设计是出于性能和线程安全考虑确保物理模拟基于一个稳定的、不会中途改变的数据集进行。注意很多新手会困惑“为什么我改了位置碰撞检测没反应”原因往往就是你的修改发生在BuildPhysicsWorld系统之后而碰撞检测系统读取的是之前构建的旧PhysicsWorld。正确的做法是通过修改PhysicsVelocity或施加PhysicsForce来影响物理状态或者确保你的逻辑在更早的系统组如InitializationSystemGroup中运行。2.2 如何访问与使用PhysicsWorld既然PhysicsWorld是每帧构建的单例我们通常通过SystemAPI.GetSingletonPhysicsWorldSingleton()来获取它。PhysicsWorldSingleton是一个包含了PhysicsWorld引用以及其他相关数据的容器组件。// 在某个System中执行射线检测的示例 protected override void OnUpdate() { // 获取当前帧的物理世界 PhysicsWorldSingleton physicsWorldSingleton SystemAPI.GetSingletonPhysicsWorldSingleton(); ref PhysicsWorld physicsWorld ref physicsWorldSingleton.PhysicsWorld; // 准备射线查询输入 RaycastInput raycastInput new RaycastInput() { Start startPos, End endPos, Filter CollisionFilter.Default // 碰撞过滤器用于层过滤等 }; // 执行射线检测 bool hasHit physicsWorld.CastRay(raycastInput, out RaycastHit closestHit); if (hasHit) { // 通过EntityIndexFromBodyIndex可以找到命中的实体 Entity hitEntity physicsWorld.Bodies[closestHit.RigidBodyIndex].Entity; // ... 处理命中逻辑 } }除了CastRayPhysicsWorld还提供了SphereCast、OverlapSphere、CalculateDistance等一系列空间查询方法。这些方法是线程安全的因为它们只读取PhysicsWorld的只读数据。你可以在IJobEntity或IJobChunk中放心地使用它们进行大量查询这也是DOTS物理性能强大的原因之一。3. CollisionSystem与物理模拟流水线CollisionSystem并不是一个单一的系统而是一系列按特定顺序执行的系统组成的物理模拟流水线。Unity Physics包默认提供了一套完整的流水线理解它的执行顺序是调试物理问题的关键。3.1 默认物理流水线PhysicsStepSystem在FixedStepSimulationSystemGroup中默认的物理系统执行顺序如下BuildPhysicsWorld如前所述构建当前帧的物理世界快照。StepPhysicsWorld这是物理模拟的核心。它内部又细分为多个阶段Broadphase利用BVH进行粗检测快速找出可能发生碰撞的物体对Pair。Narrowphase对粗检测筛选出的物体对进行精确的几何相交测试生成接触点Contact Point信息。CreateJacobians根据接触点等信息创建约束雅可比矩阵Jacobians为求解器准备数据。SolveJacobians求解约束系统计算出解决碰撞和关节约束所需的冲量Impulse。ExportPhysicsWorld将StepPhysicsWorld计算出的结果主要是新的位置和速度写回到实体的Translation、Rotation和PhysicsVelocity组件中。这个流水线是完全自动的。只要你为实体添加了必要的物理组件并将PhysicsStepSystem添加到你的世界World中它就会在每帧或每个固定时间步长自动执行。3.2 自定义物理逻辑的插入点我们很少需要重写整个流水线但经常需要在物理模拟的前、中、后插入自己的逻辑。这就需要理解系统组System Group的依赖关系。模拟前Apply Forces如果你想在物理计算开始前施加自定义的力或速度比如角色控制器、风力、爆炸冲击波你应该创建一个System并将其放在BuildPhysicsWorld之后、StepPhysicsWorld之前。通常你可以将它添加到FixedStepSimulationSystemGroup中并手动指定[UpdateBefore(typeof(StepPhysicsWorld))]。[UpdateBefore(typeof(StepPhysicsWorld))] public partial struct MyCustomForceSystem : ISystem { public void OnUpdate(ref SystemState state) { // 遍历实体修改它们的PhysicsVelocity或添加PhysicsForce // 这些修改会在紧接着的StepPhysicsWorld中被考虑进去 } }模拟中访问中间数据直接干预模拟过程如修改接触点比较高级通常需要自定义ISimulation。更常见的需求是碰撞事件。Unity Physics提供了ICollisionEventsJob和ITriggerEventsJob接口。你需要创建一个System在StepPhysicsWorld之后、ExportPhysicsWorld之前调度一个实现了这些接口的Job来处理碰撞或触发事件。[UpdateAfter(typeof(StepPhysicsWorld))] [UpdateBefore(typeof(ExportPhysicsWorld))] public partial struct MyCollisionEventSystem : ISystem { public void OnUpdate(ref SystemState state) { // 从单例中获取碰撞事件流 SimulationSingleton simSingleton SystemAPI.GetSingletonSimulationSingleton(); var collisionEvents simSingleton.CollisionEvents; // 创建并调度一个Job来处理这些事件 state.Dependency new MyCollisionEventJob { CollisionEvents collisionEvents }.Schedule(state.Dependency); } }模拟后Read Results在ExportPhysicsWorld之后实体的变换组件已经被更新。此时你可以安全地读取Translation和Rotation来驱动图形渲染或其他逻辑。这也是进行“物理查询”的好时机因为此时PhysicsWorld虽然即将被下一帧重建和实体状态是同步的。实操心得物理系统顺序混乱是DOTS物理bug的主要来源。我的建议是在项目初期就用-logSystemOrder命令行参数启动Unity或者在代码中打印系统执行顺序画出一张你自己的系统时序图。明确你的每个自定义系统“活在”哪两个标准系统之间能避免大量匪夷所思的问题。4. 协同机制深度解析数据流与线程安全现在我们把PhysicsWorld和物理流水线串起来看看一帧之内数据是如何流动的。4.1 一帧内的完整数据流假设我们运行在固定时间步长Fixed Timestep下帧开始所有实体的Translation/Rotation/PhysicsVelocity组件处于上一帧结束后的状态。BuildPhysicsWorld系统执行读取所有实体的物理相关组件。基于这些数据新建一个全新的PhysicsWorld实例称为PhysicsWorld_A。将PhysicsWorld_A存入PhysicsWorldSingleton。此时PhysicsWorld_A是当前帧物理模拟的权威数据源。实体组件后续的修改不会影响它。自定义力系统执行可选读取PhysicsWorld_A进行查询如判断是否着地。修改实体的PhysicsVelocity组件。注意这些修改不会反映到PhysicsWorld_A中但会被后续的StepPhysicsWorld读取。StepPhysicsWorld系统执行读取PhysicsWorld_A作为输入。读取实体当前的PhysicsVelocity等组件注意这里可能和PhysicsWorld_A中的速度数据不同。执行碰撞检测、约束求解。计算出所有实体新的速度和可能的位置修正。将这些结果存储在一个内部缓冲区但尚未写回实体。碰撞事件处理系统执行可选从SimulationSingleton中读取由StepPhysicsWorld生成的碰撞事件。处理事件如播放声音、造成伤害。ExportPhysicsWorld系统执行将StepPhysicsWorld计算出的新位置和速度写回到每个实体的Translation、Rotation和PhysicsVelocity组件。此时实体组件的状态更新为物理模拟后的新状态。帧结束渲染系统读取更新后的Translation/Rotation来渲染物体。这个流程的核心是数据的分阶段隔离Build阶段创建只读快照Step阶段基于快照和实时组件数据计算Export阶段将结果写回。这保证了模拟的确定性和线程安全。4.2 关键协同场景与陷阱场景一如何在自定义系统中进行正确的物理查询如果你想在施加力的系统中判断角色是否着地你应该查询PhysicsWorldSingleton中的PhysicsWorld即PhysicsWorld_A。因为它包含了所有碰撞体的最新位置信息。但要注意这个位置信息是BuildPhysicsWorld时的对于高速运动的物体可能会有微小的延迟。对于绝大多数情况这足够了。陷阱不要尝试在StepPhysicsWorld之后、ExportPhysicsWorld之前通过直接读取Translation来获取物理模拟后的位置。因为此时Export还没执行Translation仍是旧数据。正确的做法是等待ExportPhysicsWorld执行完毕或者查询存储在SimulationSingleton中的模拟结果这更复杂。场景二动态创建/销毁物理实体如果你在帧中动态实例化了一个带有PhysicsCollider的实体它不会被当前帧的PhysicsWorld_A包含。因此它在本帧不会参与任何碰撞检测。它会在下一帧的BuildPhysicsWorld中被收录从而参与物理模拟。陷阱期望动态生成的子弹立刻与目标碰撞。解决方案要么接受一帧的延迟通常可接受要么对于子弹等需要即时命中的物体在生成后手动进行一轮射线检测使用当前的PhysicsWorld_A作为补偿。场景三修改碰撞体形状或Filter直接修改实体的PhysicsCollider组件更改其形状或CollisionFilter。这个修改会立刻生效吗不会立即影响PhysicsWorld_A。但它会被下一帧的BuildPhysicsWorld系统捕捉到并更新到新的PhysicsWorld中。因此碰撞过滤的更改也存在一帧延迟。5. 高级应用与性能优化实战理解了基础机制我们来看看如何利用它们解决实际问题并提升性能。5.1 创建独立的PhysicsWorld用于复杂查询正如网络资料中daniel-holz大神提到的你可以手动创建和管理一个独立的PhysicsWorld实例专门用于复杂的空间查询而不干扰主物理模拟。这在以下场景非常有用AI寻路需要大量射线/形状投射来探测环境。子弹时间/预测轨迹像“Pool”桌球demo那样需要一份独立的物理世界来模拟未来的轨迹。静态场景查询拥有一个巨大的、永不移动的静态碰撞环境不想每帧都参与主物理世界的完整构建和模拟。// 创建并维护一个独立的PhysicsWorld示例 public class StaticScenePhysicsWorld : IComponentData { public PhysicsWorld World; public BlobAssetStore BlobAssetStore; // 用于存储碰撞体Blob数据 } // 在Baking或初始化时创建 public void InitializeStaticWorld(EntityManager entityManager) { var staticWorldEntity entityManager.CreateEntity(); var staticWorld new StaticScenePhysicsWorld(); // 1. 创建BlobAssetStore staticWorld.BlobAssetStore new BlobAssetStore(); // 2. 创建CollisionWorldPhysicsWorld的一部分 var collisionWorld new CollisionWorld(); // 3. 假设你有一组静态物体的数据位置、缩放、碰撞体形状 foreach (var staticObject in staticObjectsData) { // 创建碰撞体Blob BlobAssetReferenceCollider collider ... // 使用BlobAssetStore创建 // 向CollisionWorld中添加一个静态物体 // 注意这里使用的是底层API需要手动构建RigidBody和CollisionFilter var rigidBody RigidBody.Zero; rigidBody.Collider collider; rigidBody.WorldFromBody new RigidTransform(staticObject.rotation, staticObject.position); rigidBody.Entity Entity.Null; // 可以设为Null因为我们不关联实体 collisionWorld.AddRigidBody(rigidBody); } // 4. 构建BVH collisionWorld.BuildBroadPhase(); // 5. 组装PhysicsWorld staticWorld.World new PhysicsWorld(collisionWorld, new DynamicsWorld()); // 6. 添加到单例实体 entityManager.AddComponentData(staticWorldEntity, staticWorld); } // 在需要查询的System中使用 var staticWorld SystemAPI.GetSingletonStaticScenePhysicsWorld().World; bool hit staticWorld.CastRay(myRayInput, out RaycastHit hitResult);注意事项手动管理PhysicsWorld需要你负责其生命周期包括在场景卸载或游戏结束时正确释放CollisionWorld和BlobAssetStore调用Dispose()方法否则会导致内存泄漏。此外这个独立世界不会自动更新如果静态物体需要移动你需要手动更新对应RigidBody的WorldFromBody并重新调用BuildBroadPhase()。5.2 性能调优要点碰撞体复杂度在DOTS物理中复杂网格碰撞体Mesh Collider的性能开销依然很大。优先使用基础形状球、盒、胶囊、圆柱的组合来近似。ConvexHullCollider凸包碰撞体是复杂形状的良好平衡选择它在Baking时预计算为凸包运行时效率较高。Broadphase优化PhysicsWorld的BVH构建是单线程的。如果静态物体巨多且从不移动考虑使用上述的独立静态世界。对于动态物体避免每帧都改变所有物体的CollisionFilter层因为这会触发BVH的部分重构。查询批量化避免在System的OnUpdate中循环调用physicsWorld.CastRay。应该将查询条件收集到NativeArray中然后在一个IJob里并行执行所有查询。Unity Physics提供了RaycastCommand、OverlapQueryCommand等用于Job化的命令结构。NativeArrayRaycastCommand commands ... // 准备一批射线命令 NativeArrayRaycastHit results new NativeArrayRaycastHit(commands.Length, Allocator.TempJob); // 依赖关系确保在PhysicsWorld构建完成后执行 JobHandle handle RaycastCommand.ScheduleBatch(commands, results, 1, default(JobHandle)); handle.Complete(); // 处理results results.Dispose();实体组织将需要物理模拟的实体与不需要的实体在**原型Archetype**上分开。例如纯静态装饰物不要添加任何物理组件。这可以减少BuildPhysicsWorld系统需要遍历的实体数量。6. 常见问题排查与调试技巧即使理解了原理实战中还是会遇到各种问题。下面是一些常见坑点和排查手段。6.1 典型问题速查表问题现象可能原因排查步骤与解决方案物体不受重力下落1. 实体缺少PhysicsVelocity组件。2. 实体缺少PhysicsMass组件或其InverseMass为0无限质量。3. 重力未启用PhysicsStepSystem的Gravity设置。1. 检查实体必备组件PhysicsCollider,PhysicsVelocity,PhysicsMass,LocalTransform。2. 检查PhysicsMass组件的值默认创建的质量倒数应为1。3. 确认物理世界已创建且PhysicsStepSystem已添加到默认世界。物体穿透或碰撞无反应1. 碰撞体层级CollisionFilter设置错误相互不检测。2. 物体速度过快子弹时间问题。3. 自定义逻辑修改Translation的位置与物理模拟冲突。1. 使用Unity的Entity Debugger查看实体的PhysicsCollider的Filter属性。2. 启用PhysicsStepSystem的SolverIterationCount或使用连续碰撞检测CCD为高速物体添加PhysicsVelocity的EnableCCD标签。3. 确保逻辑系统顺序正确避免在物理模拟后强行覆盖位置。物理模拟卡顿或不稳定1. 单帧内动态物体增加/删除过多触发BVH重构。2. 复杂碰撞体过多或迭代次数不足。3. 固定时间步长Fixed Timestep设置不当。1. 优化实体生成/销毁策略或使用对象池。2. 简化碰撞体适当增加SolverIterationCount默认4-8。3. 在Project Settings - Time中调整Fixed Timestep如0.016667对应60FPS值越小越平滑但计算量越大。射线检测Raycast无结果1. 射线起点/终点错误。2. 使用的PhysicsWorld不是当前帧的例如用了缓存的世界。3.CollisionFilter不匹配。1. 可视化绘制射线Debug.DrawLine。2. 确保在BuildPhysicsWorld之后获取PhysicsWorldSingleton。3. 将CollisionFilter设为CollisionFilter.Default或CollisionFilter.Zero表示所有层进行测试。自定义力系统不起作用自定义系统执行顺序错误在StepPhysicsWorld之后才运行。为自定义系统添加[UpdateBefore(typeof(StepPhysicsWorld))]属性确保它在物理计算前执行。6.2 实用调试技巧可视化调试在编辑器中勾选Window Analysis Entity Debugger。选择你的实体查看其物理组件PhysicsCollider,PhysicsVelocity等的详细数据。你还可以在Play模式下暂停通过Component Inspector观察这些值的实时变化。系统顺序可视化在Unity启动参数中添加-logSystemOrder或在代码中调用SystemAPI.GetSystemOrder()并打印。这能生成所有系统执行顺序的日志是理清依赖关系的终极武器。绘制碰撞体编写一个简单的System在Update中遍历所有带PhysicsCollider和LocalTransform的实体使用PhysicsDebugDisplaySystem如果已启用或手动调用Debug.Draw方法来绘制碰撞体轮廓。眼见为实能快速发现碰撞体形状、位置是否正确。简化重现当遇到诡异物理bug时尝试创建一个全新的最小化场景只包含问题实体和必要环境。排除其他系统干扰往往能更快定位到是组件数据问题、系统顺序问题还是物理参数问题。DOTS物理是一套强大但略显复杂的底层系统它的设计哲学是将控制权充分交给开发者。PhysicsWorld和物理流水线的协同正是这种哲学的体现通过清晰的数据流和阶段划分既保证了高性能与线程安全又提供了足够的扩展点。刚开始可能会觉得束手束脚但一旦掌握了这套机制你就能像指挥交响乐一样精准地控制游戏中每一个物理细节的实现。