ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity DOTS Physics Raycast实战:从原理到代码排查全解析

Unity DOTS Physics Raycast实战:从原理到代码排查全解析 做 DOTS 系列学习时物理模块里最常被问到的一个功能就是射线检测 Raycast。网上关于 DOTS 的资料不少但完整讲 Physics Raycast 的中文实操内容依然偏少。正好最近把 Unity DOTS 学习系列第 020 篇的主题完整跑了一遍结合项目调试过程整理了这篇笔记把 DOTS Physics Raycast 从概念、环境、原理到代码实战和排错一次性讲清楚。本文既适合刚接触 DOTS 的 Unity 开发者也适合已经在用 Entities 1.x 但卡在物理检测模块的读者。读完后你能掌握DOTS Physics 中 Raycast 和传统Physics.Raycast的本质区别CollisionWorld、RaycastInput、CollisionFilter等核心概念一个可运行的 DOTS Raycast 最小案例常见命中失败、命中自身、性能问题的定位方法。1. DOTS Physics Raycast 概念与背景1.1 什么是 DOTS PhysicsDOTS 是 Unity 的面向数据技术栈包含 ECS、Job System、Burst Compiler 三大部分。DOTS Physics 是为这套框架设计的物理仿真模块和传统 UnityEngine.Physics 不是一个东西。传统物理系统基于 MonoBehaviour 和 GameObject物理引擎内部仍然有一套自己的数据表示开发者通过Rigidbody、Collider、Physics.Raycast这类 API 操作。DOTS Physics 则允许直接用 Entity、Component 描述物理对象物理数据连续存储在 Chunk 中配合 Burst 编译后可以在极短时间内处理大量物理对象。一句话概括DOTS Physics 是把“物理模拟”这件事从面向对象改成了面向数据射线检测也因此在查询效率、批量处理能力上有非常明显的提升。1.2 Raycast 在 DOTS 物理里的位置Raycast 是游戏开发中最基础的查询工具之一。FPS 射击、点击拾取、镜头遮挡检测、寻路障碍探测、AI 视线判断背后都依赖射线检测。在 DOTS Physics 里射线检测不是通过遍历场景里所有 Collider 来判断的而是基于物理引擎生成的加速结构进行查询。物理世界中的 Collider 会先被写入CollisionWorld引擎内部维护 Broadphase 等加速数据。每次调用 Raycast 时只需要在加速结构中搜索可能重叠的叶子节点再对候选碰撞体做精细检测即可。所以 DOTS Physics Raycast 很适合大规模使用。传统 MonoBehaviour 方案一次性发射几十条射线就会开始担心 GC 和耗时DOTS 方案在 Job Burst 下可以一次发射上千条射线。1.3 DOTS Raycast 与经典 Physics.Raycast 的核心区别很多刚入门的人会下意识去找Physics.Raycast但要注意UnityEngine 命名空间下的Physics.Raycast与 DOTS Physics 中的射线检测完全是两套东西。对比项经典 Physics.RaycastDOTS Physics Raycast查询对象GameObject ColliderEntity PhysicsCollider数据来源物理场景自动管理CollisionWorld批量能力较弱逐条调用强可 Job 化批量查询过滤方式LayerMaskCollisionFilter / 图层与 ECS 配合需要 GameObject 桥接原生 Entity 查询Burst 兼容不兼容可兼容如果你的项目已经全面使用 ECS那么传统 Raycast 无法直接拿到 Entity 层面的命中结果需要额外维护 GameObject 与 Entity 的映射这非常不方便。DOTS Physics Raycast 可以直接命中 Entity并通过 IComponentData 做后续逻辑。2. 环境准备与版本说明2.1 开发环境要求本文示例基于以下环境编写Unity 版本2022.3 LTS 或更高版本包管理通过 Package Manager 安装 Entities 和 Unity Physics编程语言C#使用 Entities 1.x 的 API 写法开发工具Visual Studio 或 RiderDOTS 包版本迭代较快不同小版本的 API 命名和命名空间可能有微调。例如旧版本常用Unity.Physics.Systems.BuildPhysicsWorld新版本更推荐直接用PhysicsWorldSingleton。本文示例以 Entities 1.x Unity Physics 1.x 系列 API 为核心如果你用的版本较新请以实际 API 为准。2.2 安装 Entities 与 Unity Physics打开 Unity进入 Windows - Package Manager在左上角包来源中选择 Unity Registry搜索并安装com.unity.entitiescom.unity.physics如果你使用的是 Unity 6 或最新长期支持版本部分项目可能需要通过com.unity.entities的依赖自动引入 Unity Physics。安装完成后Unity 会自动编译相关程序集如果出现报错优先检查安装的包版本之间是否匹配。2.3 示例项目结构为了方便演示建议创建一个空项目然后按下面的结构添加脚本Assets/ Scripts/ RaycastTargetAuthoring.cs RaycastTargetBaker.cs RaySourceAuthoring.cs RaySourceBaker.cs RaySourceComponent.cs RaycastTestSystem.cs Scenes/ DotsRaycastDemo.unity3. DOTS Physics Raycast 核心原理拆解3.1 CollisionWorld 是什么DOTS Physics 中场景里所有带PhysicsCollider的 Entity 会把碰撞数据写入CollisionWorld。CollisionWorld可以理解为物理世界的“只读数据层”内部包含Bodies动态和静态刚体的数组Broadphase用于快速剔除不可能发生碰撞的对象的宽相结构Colliders每个刚体对应的碰撞体数据当你的 ECS 系统需要做物理查询时不应该直接去EntityManager遍历所有 Entity而应该从当前物理世界拿到CollisionWorld再对CollisionWorld做查询。获取方式在新版 DOTS Physics 中通常是通过PhysicsWorldSingletonPhysicsWorldSingleton worldSingleton SystemAPI.GetSingletonPhysicsWorldSingleton(); CollisionWorld collisionWorld worldSingleton.CollisionWorld;在主线程中拿到CollisionWorld后就可以直接调用CastRay方法。3.2 RaycastInput 与 RaycastHitDOTS Physics 的射线输入使用RaycastInput结构体public struct RaycastInput { public float3 Start; public float3 End; public CollisionFilter Filter; }Start射线起点世界坐标End射线终点世界坐标Filter碰撞过滤条件。这里和传统Physics.Raycast(origin, direction, maxDistance)不同DOTS 使用“起点 终点”定义射线不需要单独指定方向和距离。如果你习惯用方向和最大距离可以通过公式转换float3 end start direction * maxDistance;RaycastHit则用于承载命中结果public struct RaycastHit { public float3 Position; public float3 SurfaceNormal; public float Fraction; public Entity Entity; public int RigidBodyIndex; }Position命中点的世界坐标SurfaceNormal命中点表面的法线方向Fraction射线起点到命中点的归一化距离范围 0~1Entity命中的碰撞体所属 Entity这是 DOTS 物理非常重要的信息。3.3 CollisionFilter 过滤机制DOTS Physics 中控制射线能命中哪些物体不能靠传统 LayerMask 直接传递给RaycastInput而是通过CollisionFilter结构体。public struct CollisionFilter { public uint BelongsTo; public uint CollidesWith; public int GroupIndex; }BelongsTo当前碰撞体属于哪个碰撞组一般用二进制位表示CollidesWith当前碰撞体会与哪些组发生碰撞GroupIndex组索引如果两个碰撞体的GroupIndex互为相反数则它们一定不会碰撞如果相同或为 0按BelongsTo和CollidesWith判断。射线检测同样遵循这套规则。射线输入中的Filter.CollidesWith决定了射线能打中哪些组的物体。比如new CollisionFilter { BelongsTo ~0u, CollidesWith (1u 0) | (1u 1), GroupIndex 0 };上面这个 Filter 表示射线属于所有组只检测第 0 组和第 1 组物体。3.4 动态碰撞体和静态碰撞体DOTS Physics 中有一个重要概念静态碰撞体和动态碰撞体。静态碰撞体只有PhysicsCollider没有PhysicsVelocity适合地面、墙壁等不移动的物体动态碰撞体同时拥有PhysicsCollider和PhysicsVelocity会参与模拟运动由物理系统驱动运动学碰撞体通过PhysicsBody配置为 Kinematic可以手动控制位置但不受重力影响。射线检测可以同时作用于这两类碰撞体。实际项目里如果需要高频检测一个移动的敌人更推荐把敌人作为动态碰撞体让物理系统自动更新它的位置射线检测也始终能得到正确结果。4. 实战一个可运行的 DOTS Physics Raycast 案例下面进入本次的核心实操。我会搭建一个非常简单的案例场景中有一个带PhysicsCollider的目标 Cube另外有一个射线发射源运行时不断向目标发射射线并把命中结果显示到 Console。4.1 创建基础场景在 Unity 中手动创建以下物件创建一个 Cube命名为 Target放在 (0, 0, 0)创建一个空物体命名为 RaySource放在 (-2, 0, 0)给 Target 添加RaycastTargetAuthoring脚本给 RaySource 添加RaySourceAuthoring脚本在 RaySource 下创建一个子物体命名为 RayEnd放在 (5, 0, 0)用于指定射线终点。注意RaySource 和 RayEnd 不要添加任何 Collider否则射线可能命中自己或射线终点路径上的其他物体干扰演示。4.2 编写目标物体的 Baker先写目标物体的 Authoring 组件和 Baker。Authoring 组件是挂载在 GameObject 上的 MonoBehaviourBaker 负责把它转换成 ECS 组件。// 文件路径Assets/Scripts/RaycastTargetAuthoring.cs using UnityEngine; public class RaycastTargetAuthoring : MonoBehaviour { public bool IsDynamic; }// 文件路径Assets/Scripts/RaycastTargetBaker.cs using Unity.Entities; using Unity.Mathematics; using Unity.Physics; using UnityEngine; public class RaycastTargetBaker : BakerRaycastTargetAuthoring { public override void Bake(RaycastTargetAuthoring authoring) { Entity entity GetEntity(TransformUsageFlags.Dynamic); var collider Unity.Physics.BoxCollider.Create( new BoxGeometry { Center float3.zero, Orientation quaternion.identity, Size authoring.transform.localScale, BevelRadius 0f }, new CollisionFilter { BelongsTo 1u 0, CollidesWith 1u 1, GroupIndex 0 } ); AddComponent(entity, new PhysicsCollider { Value collider }); } }这里有几点需要说明TransformUsageFlags.Dynamic告诉 ECS 这个 Entity 的 Transform 是动态的后续物理世界会同步它的位置BoxCollider.Create创建盒子碰撞体几何参数使用当前 GameObject 的 localScale 作为大小碰撞过滤器设置BelongsTo 1u 0表示归入第 0 组CollidesWith 1u 1表示只和第 1 组碰撞第 1 组可以留给射线这样物体之间不会互相碰撞干扰演示。如果你不需要区分碰撞组可以简化为全部使用默认组。上面的写法能更直观展示CollisionFilter的用法。4.3 编写射线发射源的 Baker射线发射源需要保存射线的起点和终点位置。起点就是 RaySource 自身的位置终点由子物体 RayEnd 控制。先定义一个 ECS 组件// 文件路径Assets/Scripts/RaySourceComponent.cs using Unity.Entities; using Unity.Mathematics; public struct RaySourceComponent : IComponentData { public float3 Start; public float3 End; }// 文件路径Assets/Scripts/RaySourceAuthoring.cs using UnityEngine; public class RaySourceAuthoring : MonoBehaviour { public Transform RayEndPoint; }// 文件路径Assets/Scripts/RaySourceBaker.cs using Unity.Entities; using Unity.Mathematics; public class RaySourceBaker : BakerRaySourceAuthoring { public override void Bake(RaySourceAuthoring authoring) { Entity entity GetEntity(TransformUsageFlags.Dynamic); AddComponent(entity, new RaySourceComponent { Start authoring.transform.position, End authoring.RayEndPoint ! null ? authoring.RayEndPoint.position : authoring.transform.position new Vector3(5f, 0f, 0f) }); } }注意Bake 阶段获取到的transform.position是 Authoring 组件在场景中的实际坐标。如果后续运行过程中射线源会移动你需要每帧更新RaySourceComponent的字段。为了演示最简单我这里直接使用固定坐标。4.4 编写 Raycast 系统核心系统代码如下// 文件路径Assets/Scripts/RaycastTestSystem.cs using Unity.Entities; using Unity.Mathematics; using Unity.Physics; using UnityEngine; public partial struct RaycastTestSystem : ISystem { public void OnUpdate(ref SystemState state) { // 获取当前物理世界碰撞数据 PhysicsWorldSingleton worldSingleton SystemAPI.GetSingletonPhysicsWorldSingleton(); CollisionWorld collisionWorld worldSingleton.CollisionWorld; // 遍历场景中的射线发射源 foreach (var raySource in SystemAPI.QueryRaySourceComponent()) { RaycastInput input new RaycastInput { Start raySource.Start, End raySource.End, Filter new CollisionFilter { BelongsTo ~0u, CollidesWith 1u 0, GroupIndex 0 } }; if (collisionWorld.CastRay(input, out Unity.Physics.RaycastHit hit)) { Debug.Log($命中目标Entity{hit.Entity}命中点{hit.Position}Fraction{hit.Fraction}); } else { Debug.Log(射线未命中任何物体。); } } } }上面代码中SystemAPI.GetSingletonPhysicsWorldSingleton()是获取物理世界的推荐方式CollisionFilter.CollidesWith 1u 0表示射线只检测第 0 组物体也就是 Target CubePhysicsWorldSingleton和CollisionWorld会在物理系统更新后保持最新数据不需要手动 Build Physics World这里放在ISystem.OnUpdate主线程中执行方便输出 Log。实际项目建议将大量检测逻辑迁移到 Job 中后续会展开说明。4.5 运行与验证把脚本挂好之后回到 Unity 编辑器点击 Play 运行。预期行为Console 输出射线未命中任何物体如果射线起点和终点之间没有目标当 Target 位于射线路径上时Console 输出命中目标Entity...移动 RayEnd 到不同位置观察命中状态变化。更直观的方法是把射线起点、终点、命中点都用Debug.DrawLine画出来。可以在系统中追加一行Debug.DrawLine(raySource.Start, raySource.End, Color.red);如果你的射线没有命中优先检查Target 是否真的通过 Baker 生成了PhysicsCollider射线 Filter 的CollidesWith是否包含了目标物体的BelongsTo组射线是否被其他物体挡住。4.6 性能优化批量 Raycast 的 Job 思路上面的例子为了演示清楚使用了主线程逐条射线检测。真实项目往往需要在短时间内处理大量射线。比如实现一个子弹系统一帧发射 500 条射线。DOTS 的优势在于可以 Job 化。大致思路是用一个 NativeArray 收集所有射线输入在 IJobEntity 中并行处理每一个射线输入把命中结果写入另一个 NativeArray 或 Buffer主线程在 Job 完成后读取结果。这一步并不复杂但需要留意CollisionWorld在 Job 中是一个只读容器射线检测不会修改物理世界状态因此可以安全并行。如果你刚开始学 DOTS建议先把主线程版本跑通再逐步改成 Job 版本不要一上来就把射线检测分散到多个系统里。5. DOTS Physics Raycast 常见问题与排查思路DOTS Physics Raycast 出错时往往不会像传统 API 那样报一个明确的参数错误更多是“检测不到”“命中错物体”“结果不稳定”等逻辑问题。下面是常见问题排查表。问题现象常见原因解决思路射线检测不到目标PhysicsCollider 没有被正确 Baker检查目标 Entity 是否包含 PhysicsCollider射线检测到自己射线起点和终点所在物体也带 Collider使用 CollisionFilter 排除特定组命中点不稳定动态刚体数据未同步确认目标使用 PhysicsCollider PhysicsVelocity系统代码直接调 Camera.main 报错主线程被 Burst 约束避免在 Burst 系统中调用非 Burst APICastRay 返回结果和显示位置不一致Baker 使用 Transform 数据时机不对使用 TransformUsageFlags 或手动同步Filter 设置了但没生效BelongsTo 和 CollidesWith 位运算配置错误统一使用二进制位维护碰撞组5.1 射线检测不到目标最常见的原因是目标物体没有成功生成PhysicsCollider。在 DOTS 中只有挂载了 Authoring 脚本且 Baker 正常运行的 GameObject 才会转换为 Entity并带有物理 Collider。排查方法打开 Unity 的 Window - Entities - Hierarchy在 ECS 模式下查看目标是否出现在 Entity 列表中并且选中后能在 Inspector 中看到PhysicsCollider组件。如果看不到说明 Baker 没有执行或BoxCollider.Create时传入了无效参数。把 Baker 中AddComponent的调用方式检查一遍确认GetEntity返回的 Entity 不是Entity.Null。5.2 射线命中了自己射线常常会命中发射者自身的 Collider尤其是把射线起点放在带 Collider 的 GameObject 内部时。解决办法是使用 CollisionFilter 分组。经典 Unity 用 LayerMask 排除发射者DOTS 用碰撞组目标物体BelongsTo 1u 0射线发射者BelongsTo 1u 1射线 FilterCollidesWith 1u 0这样射线只检测第 0 组发射者即使带 Collider也不会产生干扰。5.3 动态物体命中位置漂移如果目标物体是动态刚体并且通过 Transform 组件直接移动而不是通过PhysicsVelocity驱动那么物理世界中的位置和渲染位置可能不同步。这是因为 DOTS Physics 使用PhysicsVelocity、PhysicsMass等组件模拟运动物理数据需要同步到LocalToWorld。直接设置 GameObject 的 transform 不会自动更新物理世界。正确做法是动态物体交给物理系统驱动通过PhysicsVelocity设置线速度运动学物体通过PhysicsBody配置 Kinematic再设置 LocalToWorld 同步物理位置。5.4 系统不执行ISystem没有执行时往往是因为缺少Partial关键字或系统没有被自动创建。在 Entities 1.x 中ISystem结构体可以标记为partial structUnity 会自动生成相关包装代码。如果你的系统没有注册也可以手动使用World.CreateSystem或在 Bootstrap 中注册。新手最容易犯的问题是把系统类写成了非 partial 结构体或者不小心加上了[DisableAutoCreation]。5.5 Filter 位运算出错碰撞组使用二进制位表示组 0 对应1u 0组 1 对应1u 1。如果多个物体使用相同的位或射线 Filter 的CollidesWith只包含部分位就会出现“明明在场景里却检测不到”的情况。建议使用一个静态类统一管理碰撞组public static class CollisionGroups { public const uint Default 1u 0; public const uint Player 1u 1; public const uint Enemy 1u 2; public const uint Ray 1u 3; }这样在 Baker 和 Raycast System 里就不用反复写魔法数字。6. DOTS Physics Raycast 最佳实践与工程建议6.1 使用 PhysicsWorldSingleton不要缓存 CollisionWorld每次物理模拟结束后CollisionWorld都可能发生变化。不要把CollisionWorld缓存到字段中长时间使用否则查询结果可能过期。正确的做法是在系统OnUpdate中通过SystemAPI.GetSingletonPhysicsWorldSingleton()获取当前物理世界用完就丢。6.2 射线输入集中管理实际项目中玩家点击、子弹、视觉检测、寻路射线可能是不同系统发出来的。如果每个系统都直接构建RaycastInput碰撞组配置很容易散落各处。建议定义一个统一组件来保存一次批量射线检测所需的输入数据比如public struct RaycastBatchRequest : IComponentData { public int RayCount; }然后系统统一处理这批射线命中结果写入另一个 Buffer 组件。这样调试和排查都方便。6.3 大量射线优先 Job 化主线程逐条调用collisionWorld.CastRay在几千条射线时会成为瓶颈。DOTS 的优势在于并行建议将射线检测逻辑放到IJobEntity或无状态的(IJob)中执行。Job 化需要注意射线输入数据用NativeArray或ComponentLookup读取命中结果用NativeArrayRaycastHit保存处理完毕后再由系统写回 Entity 组件不要在 Job 中执行Debug.Log或调用Camera.main。6.4 用 CollisionFilter 代替传统 LayerMaskDOTS 中 LayerMask 依然存在但物理检测层面更推荐使用CollisionFilter。它直接在物理底层过滤不需要把 GameObject Layer 和物理碰撞矩阵映射到 Entity。如果你必须沿用自己的 Layer 体系也可以将一个 Authoring 组件转换为CollisionFilter在 Baker 阶段把 Layer 转成二进制位。6.5 保持安全的 API 使用边界射线检测读取物理世界时只读不会修改物理状态。但在 Job 中同时去GetComponent并修改命中 Entity 的组件就要格外小心竞争条件。推荐做法射线检测 Job 只写独立的NativeArrayRaycastHit完成所有 Raycast 后再用主线程或另一个 Job 修改实体组件不要在射线检测过程中直接销毁或移动被命中的 Entity。6.6 调试可视化射线检测失败时可视化是最好的排错手段。使用Debug.DrawLine把射线路径画出来Debug.DrawLine(raySource.Start, raySource.End, Color.yellow);如果要查看命中点也可以画一个小球标记Debug.DrawWireSphere(hit.Position, 0.1f, Color.green);在场景视图中可以直接看到射线是否穿过目标、命中点是否正确。6.7 避免每帧产生 GC 垃圾传统Physics.Raycast的某些重载版本会产生少量 GC 分配DOTS 的CastRay本身基于 struct 参数不会装箱。但如果你在系统里频繁使用new RaycastInput、NativeArray而不释放仍然会有问题。在ISystem中创建NativeArray时尽量使用Allocator.Temp并在同一个方法内 Dispose或者使用EntityCommandBuffer和其他持久容器管理生命周期。7. 总结与下一步学习建议本篇文章围绕 DOTS Physics Raycast 展开核心可以总结为几个关键点DOTS 的射线检测基于CollisionWorld和传统Physics.Raycast是两套体系射线使用RaycastInput定义起点、终点和碰撞过滤条件命中结果RaycastHit可以直接返回 Entity方便在 ECS 中继续处理CollisionFilter的三个字段BelongsTo、CollidesWith、GroupIndex是控制检测范围的主要手段生产环境尽量使用PhysicsWorldSingleton Job 化批量检测。如果你正在学习 DOTS建议下一步继续深入三个方向Collision Events 和 Trigger Events射线检测只能告诉你“是否命中”碰撞事件可以告诉你“进入触碰瞬间”和“离开触碰瞬间”适合实现伤害判断、机关触发等逻辑Physics Step 参数调优DOTS Physics 的固定步长、重力、碰撞容差都会影响射线检测的稳定性和性能Character Controller 与运动学刚体很多涉及角色移动的项目需要把射线检测和运动学移动模式结合起来。最后分享一条真实经验学习 DOTS Physics Raycast 最好的方式不是直接看文档而是先把一个最小案例跑通然后故意制造几个错误比如修改 Filter、去掉 PhysicsCollider、改变系统执行顺序亲眼看到出错现象后再修复。这样一遍操作下来对 API 的掌握会比刷十篇文章更牢固。如果这篇文章对你的 DOTS 学习有帮助建议收藏备用尤其是第 5 节的排查表和第 6 节的最佳实践在后续项目里大概率能帮你省下不少排查时间。
返回列表