Unity DOTS物理核心:PhysicsWorld与碰撞系统协同机制详解
2026/8/10 2:58:06 网站建设 项目流程

1. 项目概述:为什么需要深入理解DOTS物理组件?

如果你正在或打算使用Unity的DOTS(Data-Oriented Technology Stack)技术栈来开发高性能游戏,尤其是那些需要处理成千上万个动态物体的项目,那么Unity Physics包绝对是你绕不开的核心模块。但很多开发者初次接触时,往往会被PhysicsWorldCollisionSystem这两个核心概念搞得一头雾水。官方文档虽然详尽,但更像是一本说明书,缺少将各个部件串联起来的“故事线”。

我自己在项目里从传统的GameObject物理迁移到DOTS物理时,也踩了不少坑。最典型的问题就是:明明按照ECS的规则创建了PhysicsColliderPhysicsVelocity,但物体要么不动,要么穿模,调试起来像在盲人摸象。后来才发现,问题的根源在于没有真正理解物理世界(PhysicsWorld)是如何被构建的,以及碰撞系统(CollisionSystem)是如何与它协同工作的。这不仅仅是两个孤立的系统,它们共同构成了DOTS物理模拟的“发动机”和“交通规则”。

简单来说,PhysicsWorld是物理状态的快照数据库,它存储了某一帧所有碰撞体、刚体的空间位置、形状和速度等数据。而CollisionSystem(更准确地说,是PhysicsStepSystemBuildPhysicsWorld等系统组成的流水线)是处理器规则执行者,它负责读取PhysicsWorld的数据,计算碰撞、施加力,并最终将结果写回PhysicsWorld和你的实体组件。搞懂它们之间“谁先谁后”、“数据怎么流”,是写出稳定、高效DOTS物理代码的关键。这篇文章,我就结合自己的实战经验,把这套协同机制掰开揉碎了讲清楚。

2. PhysicsWorld:DOTS物理的“中央数据库”

PhysicsWorld是Unity.Physics包中最核心的数据结构。你可以把它理解为一个针对物理模拟高度优化的、只读的(对用户而言)数据仓库。它并不直接挂在某个实体上,而是由BuildPhysicsWorld系统在每帧开始时为你构建好的一个“场景物理状态镜像”。

2.1 PhysicsWorld的内部结构与创建时机

当你为实体添加了PhysicsColliderPhysicsVelocityPhysicsMass等组件后,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之后,如果你直接修改实体的TranslationRotation组件,PhysicsWorld里的数据并不会自动更新,它已经“定格”了。这个设计是出于性能和线程安全考虑,确保物理模拟基于一个稳定的、不会中途改变的数据集进行。

注意:很多新手会困惑“为什么我改了位置,碰撞检测没反应?”原因往往就是你的修改发生在BuildPhysicsWorld系统之后,而碰撞检测系统读取的是之前构建的旧PhysicsWorld。正确的做法是通过修改PhysicsVelocity或施加PhysicsForce来影响物理状态,或者确保你的逻辑在更早的系统组(如InitializationSystemGroup)中运行。

2.2 如何访问与使用PhysicsWorld

既然PhysicsWorld是每帧构建的单例,我们通常通过SystemAPI.GetSingleton<PhysicsWorldSingleton>()来获取它。PhysicsWorldSingleton是一个包含了PhysicsWorld引用以及其他相关数据的容器组件。

// 在某个System中执行射线检测的示例 protected override void OnUpdate() { // 获取当前帧的物理世界 PhysicsWorldSingleton physicsWorldSingleton = SystemAPI.GetSingleton<PhysicsWorldSingleton>(); 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还提供了SphereCastOverlapSphereCalculateDistance等一系列空间查询方法。这些方法是线程安全的,因为它们只读取PhysicsWorld的只读数据。你可以在IJobEntityIJobChunk中放心地使用它们进行大量查询,这也是DOTS物理性能强大的原因之一。

3. CollisionSystem与物理模拟流水线

CollisionSystem并不是一个单一的系统,而是一系列按特定顺序执行的系统组成的物理模拟流水线。Unity Physics包默认提供了一套完整的流水线,理解它的执行顺序是调试物理问题的关键。

3.1 默认物理流水线(PhysicsStepSystem)

FixedStepSimulationSystemGroup中,默认的物理系统执行顺序如下:

  1. BuildPhysicsWorld:如前所述,构建当前帧的物理世界快照。
  2. StepPhysicsWorld:这是物理模拟的核心。它内部又细分为多个阶段:
    • Broadphase:利用BVH进行粗检测,快速找出可能发生碰撞的物体对(Pair)。
    • Narrowphase:对粗检测筛选出的物体对进行精确的几何相交测试,生成接触点(Contact Point)信息。
    • CreateJacobians:根据接触点等信息创建约束雅可比矩阵(Jacobians),为求解器准备数据。
    • SolveJacobians:求解约束系统,计算出解决碰撞和关节约束所需的冲量(Impulse)。
  3. ExportPhysicsWorld:将StepPhysicsWorld计算出的结果(主要是新的位置和速度)写回到实体的TranslationRotationPhysicsVelocity组件中。

这个流水线是完全自动的。只要你为实体添加了必要的物理组件,并将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提供了ICollisionEventsJobITriggerEventsJob接口。你需要创建一个System,在StepPhysicsWorld之后ExportPhysicsWorld之前,调度一个实现了这些接口的Job来处理碰撞或触发事件。
    [UpdateAfter(typeof(StepPhysicsWorld))] [UpdateBefore(typeof(ExportPhysicsWorld))] public partial struct MyCollisionEventSystem : ISystem { public void OnUpdate(ref SystemState state) { // 从单例中获取碰撞事件流 SimulationSingleton simSingleton = SystemAPI.GetSingleton<SimulationSingleton>(); var collisionEvents = simSingleton.CollisionEvents; // 创建并调度一个Job来处理这些事件 state.Dependency = new MyCollisionEventJob { CollisionEvents = collisionEvents }.Schedule(state.Dependency); } }
  • 模拟后(Read Results):在ExportPhysicsWorld之后,实体的变换组件已经被更新。此时你可以安全地读取TranslationRotation来驱动图形渲染或其他逻辑。这也是进行“物理查询”的好时机,因为此时PhysicsWorld(虽然即将被下一帧重建)和实体状态是同步的。

实操心得:物理系统顺序混乱是DOTS物理bug的主要来源。我的建议是,在项目初期就用-logSystemOrder命令行参数启动Unity,或者在代码中打印系统执行顺序,画出一张你自己的系统时序图。明确你的每个自定义系统“活在”哪两个标准系统之间,能避免大量匪夷所思的问题。

4. 协同机制深度解析:数据流与线程安全

现在我们把PhysicsWorld和物理流水线串起来,看看一帧之内数据是如何流动的。

4.1 一帧内的完整数据流

假设我们运行在固定时间步长(Fixed Timestep)下:

  1. 帧开始:所有实体的Translation/Rotation/PhysicsVelocity组件处于上一帧结束后的状态。
  2. BuildPhysicsWorld系统执行
    • 读取所有实体的物理相关组件。
    • 基于这些数据,新建一个全新的PhysicsWorld实例(称为PhysicsWorld_A)。
    • PhysicsWorld_A存入PhysicsWorldSingleton
    • 此时PhysicsWorld_A是当前帧物理模拟的权威数据源。实体组件后续的修改不会影响它。
  3. 自定义力系统执行(可选):
    • 读取PhysicsWorld_A进行查询(如判断是否着地)。
    • 修改实体的PhysicsVelocity组件。
    • 注意:这些修改不会反映到PhysicsWorld_A中,但会被后续的StepPhysicsWorld读取。
  4. StepPhysicsWorld系统执行
    • 读取PhysicsWorld_A作为输入。
    • 读取实体当前PhysicsVelocity等组件(注意,这里可能和PhysicsWorld_A中的速度数据不同!)。
    • 执行碰撞检测、约束求解。
    • 计算出所有实体新的速度(和可能的位置修正)。
    • 将这些结果存储在一个内部缓冲区,但尚未写回实体
  5. 碰撞事件处理系统执行(可选):
    • SimulationSingleton中读取由StepPhysicsWorld生成的碰撞事件。
    • 处理事件(如播放声音、造成伤害)。
  6. ExportPhysicsWorld系统执行
    • StepPhysicsWorld计算出的新位置和速度,写回到每个实体的TranslationRotationPhysicsVelocity组件。
    • 此时:实体组件的状态更新为物理模拟后的新状态。
  7. 帧结束:渲染系统读取更新后的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. 创建CollisionWorld(PhysicsWorld的一部分) var collisionWorld = new CollisionWorld(); // 3. 假设你有一组静态物体的数据(位置、缩放、碰撞体形状) foreach (var staticObject in staticObjectsData) { // 创建碰撞体Blob BlobAssetReference<Collider> 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.GetSingleton<StaticScenePhysicsWorld>().World; bool hit = staticWorld.CastRay(myRayInput, out RaycastHit hitResult);

注意事项:手动管理PhysicsWorld需要你负责其生命周期,包括在场景卸载或游戏结束时,正确释放CollisionWorldBlobAssetStore(调用Dispose()方法),否则会导致内存泄漏。此外,这个独立世界不会自动更新,如果静态物体需要移动,你需要手动更新对应RigidBodyWorldFromBody并重新调用BuildBroadPhase()

5.2 性能调优要点

  1. 碰撞体复杂度:在DOTS物理中,复杂网格碰撞体(Mesh Collider)的性能开销依然很大。优先使用基础形状(球、盒、胶囊、圆柱)的组合来近似。ConvexHullCollider(凸包碰撞体)是复杂形状的良好平衡选择,它在Baking时预计算为凸包,运行时效率较高。
  2. Broadphase优化PhysicsWorld的BVH构建是单线程的。如果静态物体巨多且从不移动,考虑使用上述的独立静态世界。对于动态物体,避免每帧都改变所有物体的CollisionFilter(层),因为这会触发BVH的部分重构。
  3. 查询批量化:避免在SystemOnUpdate中循环调用physicsWorld.CastRay。应该将查询条件收集到NativeArray中,然后在一个IJob里并行执行所有查询。Unity Physics提供了RaycastCommandOverlapQueryCommand等用于Job化的命令结构。
    NativeArray<RaycastCommand> commands = ... // 准备一批射线命令 NativeArray<RaycastHit> results = new NativeArray<RaycastHit>(commands.Length, Allocator.TempJob); // 依赖关系:确保在PhysicsWorld构建完成后执行 JobHandle handle = RaycastCommand.ScheduleBatch(commands, results, 1, default(JobHandle)); handle.Complete(); // 处理results results.Dispose();
  4. 实体组织:将需要物理模拟的实体与不需要的实体在**原型(Archetype)**上分开。例如,纯静态装饰物不要添加任何物理组件。这可以减少BuildPhysicsWorld系统需要遍历的实体数量。

6. 常见问题排查与调试技巧

即使理解了原理,实战中还是会遇到各种问题。下面是一些常见坑点和排查手段。

6.1 典型问题速查表

问题现象可能原因排查步骤与解决方案
物体不受重力下落1. 实体缺少PhysicsVelocity组件。
2. 实体缺少PhysicsMass组件,或其InverseMass为0(无限质量)。
3. 重力未启用(PhysicsStepSystemGravity设置)。
1. 检查实体必备组件:PhysicsCollider,PhysicsVelocity,PhysicsMass,LocalTransform
2. 检查PhysicsMass组件的值,默认创建的质量倒数应为1。
3. 确认物理世界已创建,且PhysicsStepSystem已添加到默认世界。
物体穿透或碰撞无反应1. 碰撞体层级(CollisionFilter)设置错误,相互不检测。
2. 物体速度过快(子弹时间问题)。
3. 自定义逻辑修改Translation的位置与物理模拟冲突。
1. 使用Unity的Entity Debugger查看实体的PhysicsColliderFilter属性。
2. 启用PhysicsStepSystemSolverIterationCount或使用连续碰撞检测(CCD),为高速物体添加PhysicsVelocityEnableCCD标签。
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.DefaultCollisionFilter.Zero(表示所有层)进行测试。
自定义力系统不起作用自定义系统执行顺序错误,在StepPhysicsWorld之后才运行。为自定义系统添加[UpdateBefore(typeof(StepPhysicsWorld))]属性,确保它在物理计算前执行。

6.2 实用调试技巧

  1. 可视化调试:在编辑器中,勾选Window > Analysis > Entity Debugger。选择你的实体,查看其物理组件(PhysicsCollider,PhysicsVelocity等)的详细数据。你还可以在Play模式下暂停,通过Component Inspector观察这些值的实时变化。
  2. 系统顺序可视化:在Unity启动参数中添加-logSystemOrder,或在代码中调用SystemAPI.GetSystemOrder()并打印。这能生成所有系统执行顺序的日志,是理清依赖关系的终极武器。
  3. 绘制碰撞体:编写一个简单的System,在Update中遍历所有带PhysicsColliderLocalTransform的实体,使用PhysicsDebugDisplaySystem(如果已启用)或手动调用Debug.Draw方法来绘制碰撞体轮廓。眼见为实,能快速发现碰撞体形状、位置是否正确。
  4. 简化重现:当遇到诡异物理bug时,尝试创建一个全新的最小化场景,只包含问题实体和必要环境。排除其他系统干扰,往往能更快定位到是组件数据问题、系统顺序问题还是物理参数问题。

DOTS物理是一套强大但略显复杂的底层系统,它的设计哲学是将控制权充分交给开发者。PhysicsWorld和物理流水线的协同,正是这种哲学的体现:通过清晰的数据流和阶段划分,既保证了高性能与线程安全,又提供了足够的扩展点。刚开始可能会觉得束手束脚,但一旦掌握了这套机制,你就能像指挥交响乐一样,精准地控制游戏中每一个物理细节的实现。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询