ARTICLE DETAIL

资讯详情

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

DOTS物理射线检测实战:ECS+Job+Burst实现批量Raycast

DOTS物理射线检测实战:ECS+Job+Burst实现批量Raycast Unity 的 DOTS 物理模块里射线检测是最值得先跑通的功能之一。传统的Physics.Raycast虽然在原型阶段很好用但只要同屏射线数量一多主线程的计算压力就会直接反映到帧率上。DOTS 的解法是把射线检测放到 ECS 数据流里用 Job 并行调度再用 Burst 编译成高密度机器码配合物理世界自带的 Broadphase 加速结构几千条射线在同一帧完成并不夸张。这次我们跟着码猴Code Monkey的 DOTS 完整版系列第 020 期把 DOTS Physics Raycast 完整过一遍。这一期的核心是四个点DOTS 里怎么发射线、怎么用 CollisionFilter 做分层过滤、怎么把批量射线写进 Job、以及跑完以后怎么验证结果和排查问题。内容对应的是 Unity DOTS 1.x 和 Unity Physics 1.x 的 API 体系如果你之前接触过 0.x 版本的 ECS会发现接口变化非常大后面单独说迁移注意点。这期内容适合两类人第一类是已经在学 DOTS卡在 Physics 模块不知道从哪里入手的 Unity 开发者第二类是正在用传统Physics.Raycast做射击判定、车辆传感器、AI 视野检测但因为性能瓶颈考虑迁移到 ECS 的工程开发者。读完这篇文章你会拿到一套可以直接放进项目的射线查询代码以及一套验证性能的测试方法。1. DOTS Physics Raycast 核心能力速览能力项说明技术栈Unity DOTSECS Job System Burst Compiler由 Unity.Physics 包提供主要功能最近命中检测、全部命中收集、分层过滤、批量射线 Job、Broadphase 加速查询运行方式ISystem 中同步调用或通过 IJobEntity 批量调度到 Job 线程传统 API 区别不再直接调用Physics.Raycast数据入口是PhysicsWorldSingleton硬件要求无独立 GPU 门槛吃 CPU 多核性能Burst 编译后效率提升明显配套依赖Entities、Unity.Physics、Burst、Collections、Mathematics批量任务支持推荐用 IJobEntity 批量调度多条射线自动并行调试手段SubScene 烘焙、Physics Debug Display 可视化、命中点 Gizmos 输出适合读者学习 DOTS 的 Unity 开发者、需要大规模射线检测的游戏项目2. 适用场景与使用边界DOTS Physics Raycast 最典型的应用场景是射击游戏里的大规模弹道检测。传统做法是每发子弹在 Update 里调一次Physics.Raycast几百发子弹同时在场时主线程排队执行的开销会非常难看。DOTS 写法把每颗子弹的射线查询整理成并行 Job数据按实体排列在连续内存里命中结果也直接写回IComponentData不需要回调函数来回传。车辆传感器是另一个常见场景。自动驾驶仿真、赛车 AI、物流机器人模拟都需要大量短距离射线感知地面和障碍物。每条射线的起点、方向、长度都不是固定值用IJobEntity遍历传感器实体时可以灵活地从LocalTransform读取批量查询完全并行。AI 视野检测同样适用。敌人单位需要定期向玩家方向发射射线判断障碍物是否遮挡视线。这类查询往往是分散的每帧只发 20 到 50 条用同步CastRay也够用但一旦单位数量破千就必须考虑 Job 化。使用边界要清楚。DOTS Physics 是独立于 Built-in 3D 物理的系统Rigidbody、Collider、Physics.Raycast的数据不会自动进入 ECS 物理世界。DOTS 射线只能检测带PhysicsCollider的实体这些实体来自 SubScene 里的烘焙或者运行时在代码里手动添加组件。如果项目只是做简单 UI 点击检测、数量很少或者物理交互极其复杂强行上 DOTS 反而增加工程复杂度。合规方面提醒一句跟着教程学习时注意分辨课程代码和素材的授权范围公司项目使用 DOTS 相关包时以当前 Unity 版本的许可证和官方包管理器的版本约束为准。涉及商业发布时物理表现和质量参数需要按目标平台单独验证。3. 环境准备与前置条件要用 DOTS Physics Raycast开发环境需要满足以下条件Unity 版本推荐 Unity 2022.3 LTS 或 Unity 6 及以上版本。旧版本对 Entities 1.x 支持不稳定调试体验差很多。核心依赖包com.unity.entities、com.unity.physics、com.unity.burst、com.unity.collections、com.unity.mathematics。其中 Burst 在 Unity 6 项目里通常自带Entities 和 Physics 需要手动安装。硬件要求普通开发机即可做大规模压力测试时多核 CPU 收益更明显。DOTS 物理不依赖独立显卡重点看 CPU 并行能力。磁盘空间至少预留 10GB 以上给 Unity 项目、DOTS 包缓存和烘焙数据避免 SubScene 构建时空间不足。端口与进程无固定端口占用问题但建议关闭不必要的杀毒扫描避免构建时锁定 DLL 文件导致 Burst 编译失败。设置项目时建议新建一个空场景只保留一个 SubScene 用来放物理测试物体另一个纯系统场景用来跑射线逻辑。物理数据、逻辑代码、可视化调试三块互相隔离后面排查问题会快很多。4. 安装与工程配置4.1 安装依赖包打开Window Package Manager搜索并安装Entities和Unity Physics。如果公司网络环境访问 Package Manager 不稳定可以直接修改项目根目录的Packages/manifest.json加入以下依赖版本号是示例以你当前 Unity 版本匹配到的实际版本为准{ dependencies: { com.unity.burst: 1.8.13, com.unity.collections: 2.2.1, com.unity.entities: 1.2.4, com.unity.mathematics: 1.2.6, com.unity.physics: 1.2.4 } }保存后切回 Unity 窗口编辑器会自动解析依赖并下载对应包。如果版本冲突Package Manager 会给出报错信息优先以它提示的兼容版本为准。4.2 创建物理测试场景DOTS 物理世界不是自动从场景里的 GameObject 读取的需要先把带物理组件的 GameObject 烘焙成实体。具体操作在 Hierarchy 面板新建一个 SubScene把测试物体拖进去。给测试物体添加Physics Shape组件也就是PhysicsShapeAuthoring。它是 DOTS 物理的 Authoring 组件负责把碰撞形状烘焙成 ECS 里的PhysicsCollider。如果是动态物体再添加Physics Body组件也就是PhysicsBodyAuthoring烘焙后会生成PhysicsVelocity、PhysicsMass等运行时组件。打开 SubScene 的 Authoring 模式确认物体在 Entity 场景里生成了对应的PhysicsCollider实体。这里有一个和传统物理最大的不同射线查询的目标不是 GameObject 而是 Entity。命中的结果返回的是Entity如果你需要拿到 GameObject 引用只能做反向映射或者维护一个 Entity 到业务数据的关系表。4.3 物理世界的自动构建Unity Physics 在运行时每个物理帧都会自动构建物理世界对应的系统是BuildPhysicsWorld。构建完成后物理世界的数据以PhysicsWorldSingleton组件的形式存在ISystem 里可以通过SystemAPI.GetSingletonPhysicsWorldSingleton()拿到。这个 Singleton 是 DOTS 射线检测的唯一入口。它内部包含CollisionWorld碰撞世界包含 Broadphase 和所有碰撞体的窄相位数据。Bodies所有参与物理计算的刚体列表。DynamicBodies、StaticBodies区分动态体和静态体方便按类型筛选。理解这个结构很重要。你不需要关心物理世界内部怎么构建但要知道射线查询的性能瓶颈不在CastRay本身而在于物理世界是否每帧都在更新、更新了多少个动态体。5. 核心 API 与代码演示5.1 同步 Raycast最简单的方式是直接在 ISystem 的OnUpdate里同步执行射线查询。下面这个系统从坐标(0, 5, 0)向下发射射线using Unity.Entities; using Unity.Mathematics; using Unity.Physics; using UnityEngine; public partial struct BasicRaycastSystem : ISystem { public void OnUpdate(ref SystemState state) { PhysicsWorldSingleton physicsWorld SystemAPI.GetSingletonPhysicsWorldSingleton(); RaycastInput input new RaycastInput { Start new float3(0f, 5f, 0f), End new float3(0f, -5f, 0f), Filter CollisionFilter.Default }; if (physicsWorld.CastRay(input, out RaycastHit hit)) { Debug.Log($命中实体{hit.Entity}命中点{hit.Position}Fraction{hit.Fraction}); } } }RaycastInput的核心字段是三个Start、End、Filter。射线从Start指向EndFilter决定这条射线能和哪些层碰撞。CastRay返回bool表示是否找到最近命中RaycastHit里的Fraction表示命中点在从Start到End线段上的比例范围是 0 到 1Position是实际命中点的世界坐标。这里要注意上面的代码使用了Debug.LogOnUpdate不要加[BurstCompile]否则 Burst 编译器会报错。编辑器阶段用这个写法看结果很直观正式工程里建议换成 ECS 的Unity.Logging或者直接写回组件数据。5.2 CollisionFilter 分层过滤传统物理用 Layer 和 LayerMask 控制碰撞。DOTS 物理用CollisionFilter字段更明确BelongsTo这个碰撞体自己属于哪些层用位掩码表示。CollidesWith这个碰撞体希望和哪些层碰撞。GroupIndex分组索引。非零时优先于BelongsTo和CollidesWith相同组直接碰撞不同组直接忽略。用来处理角色与自身碰撞这类特殊需求。建议在项目里用一个静态类集中管理层位public static class PhysicsLayers { public const uint Ground 1u 0; public const uint Player 1u 1; public const uint Enemy 1u 2; public const uint Wall 1u 3; }射线侧指定过滤条件CollisionFilter playerAttackFilter new CollisionFilter { BelongsTo PhysicsLayers.Player, CollidesWith PhysicsLayers.Enemy | PhysicsLayers.Wall, GroupIndex 0 }; RaycastInput input new RaycastInput { Start playerPosition, End playerPosition forward * attackRange, Filter playerAttackFilter };这一段表达的是这条射线属于玩家层只会命中敌人层和墙壁层的碰撞体遇到地面、队友等一切其他层都会直接忽略。在 Authoring 侧PhysicsShapeAuthoring组件里也有一个 Filter 区域可以调整Belongs To和Collides With。烘焙后这些值会写进PhysicsCollider的ColliderPtr-Filter。射线查询时Unity Physics 会拿射线的CollisionFilter和每个碰撞体的CollisionFilter做配对判断双方的CollidesWith都要包含对方的BelongsTo才判定为可命中。5.3 IJobEntity 批量射线当射线数量上升比如 1000 个敌方单位同时索敌同步CastRay就不合适了。正确做法是把射线查询写进IJobEntity让 ECS 自动遍历每个带查询组件的实体并调度到多个线程并行执行。先定义一个命中结果组件using Unity.Entities; using Unity.Mathematics; public struct RaycastResult : IComponentData { public bool HasHit; public Entity HitEntity; public float3 HitPosition; public float HitFraction; }再写并行 Job。每个实体的LocalTransform提供起点RaycastResult接收结果using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.Physics; using Unity.Transforms; [BurstCompile] public partial struct EntityRaycastJob : IJobEntity { [ReadOnly] public PhysicsWorldSingleton PhysicsWorld; public float3 RayEnd; [BurstCompile] public void Execute(in LocalTransform transform, ref RaycastResult result) { RaycastInput input new RaycastInput { Start transform.Position, End RayEnd, Filter CollisionFilter.Default }; if (PhysicsWorld.CastRay(input, out RaycastHit hit)) { result.HasHit true; result.HitEntity hit.Entity; result.HitPosition hit.Position; result.HitFraction hit.Fraction; } else { result.HasHit false; } } }调度这个 Job 的 System 也非常简单using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.Physics; public partial struct EntityRaycastSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { float3 rayEnd new float3(0f, 0f, 20f); state.Dependency new EntityRaycastJob { PhysicsWorld SystemAPI.GetSingletonPhysicsWorldSingleton(), RayEnd rayEnd }.Schedule(state.Dependency); } }这里的关键点是Schedule传入的是state.Dependency。这样新的 Job 会排在前面所有已经记录的依赖后面而后续系统如果想读取RaycastResult又必须在我们的 Job 完成之后才能执行。ECS 会用这个依赖链自动保证正确性。不要在主线程上直接GetSingleton后立刻调用CastRay也不要在一帧里反复CompleteDependency否则 Job 并行性会被打断。5.4 全部命中收集与 CastRayAll部分玩法需要一条射线穿过多个敌人比如穿透弹、激光武器、群体嘲讽判定。最近命中不够用要收集射线路径上的所有命中。这时用CastRayAllusing Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.Physics; public partial struct CollectAllHitsSystem : ISystem { public void OnUpdate(ref SystemState state) { PhysicsWorldSingleton physicsWorld SystemAPI.GetSingletonPhysicsWorldSingleton(); RaycastInput input new RaycastInput { Start new float3(0f, 0f, 0f), End new float3(0f, 0f, 100f), Filter CollisionFilter.Default }; NativeListRaycastHit allHits new NativeListRaycastHit(Allocator.TempJob); if (physicsWorld.CastRayAll(input, ref allHits)) { for (int i 0; i allHits.Length; i) { // allHits[i] 就是射线路径上的一个命中点 Entity hitEntity allHits[i].Entity; float3 hitPosition allHits[i].Position; } } allHits.Dispose(); } }NativeList使用Allocator.TempJob时记得在同一帧内手动Dispose避免产生内存泄漏。如果批量 Job 里使用更稳妥的做法是把NativeList作为 Job 的目标字段传入并延迟释放或者复用预先分配的容器但这会增加依赖管理的复杂度初学者先按上面的写法保证正确性。6. 功能测试与效果验证代码写完重点不是看能不能编译而是看结果是否正确。下面三组测试是从第 020 期内容里拆出来的核心验证流程照着顺序做能最快暴露问题。6.1 最近命中验证测试目的确认CastRay返回的是射线路径上最近的命中实体而不是随机命中。测试环境SubScene 里沿 Z 轴摆 3 个带PhysicsShapeAuthoring的球体离射线原点分别 5、10、15 米。射线从原点向 Z 轴正方向发射 100 米。操作步骤创建BasicRaycastSystem把射线方向改为(0, 0, 1)。在 Console 里观察日志输出的hit.Entity。记录命中的Fraction理论上第一个球体在 5 米处Fraction应约为 0.05。判断成功的标准hit.Entity是离原点最近的那个球体hit.Position的 Z 坐标约为 5而不是 10 或 15。常见失败原因如果射线穿过了所有球体优先检查球体是否烘焙成功。在 Hierarchy 里选中 SubScene 下的球体打开 Entities 预览窗口确认生成了PhysicsCollider。如果显示没有实体说明 SubScene 没有进入烘焙状态需要勾选 Authoring 模式重新触发烘焙。6.2 分层过滤验证测试目的确认CollisionFilter的位掩码逻辑生效。测试环境两个球体一个设置为Layer 0一个设置为Layer 1。射线侧Filter.CollidesWith只包含Layer 0。操作步骤在PhysicsShapeAuthoring的 Filter 区域给第一个球体设置Belongs To Layer 0第二个设置Belongs To Layer 1。射线CollidesWith设置为1u 0。分别在距离两个球体的位置发射射线观察结果。判断成功的标准只命中第一个球体第二个球体被完全忽略。常见失败原因忘记设置BelongsTo导致所有碰撞体默认全层过滤器失去意义或者层位定义混乱两个球体实际上属于同一层。建议在PhysicsLayers静态类里把层定义写成常量而不是在每段代码里手写1u 3。6.3 批量射线性能验证测试目的确认 Job 化后大量射线能否并行执行而不是被主线程串行拖住。测试环境用代码生成 1000 个带LocalTransform和RaycastResult的查询实体再生成 200 个随机位置的球体作为命中目标。统一使用固定随机种子保证每次测试场景一致。操作步骤打开 Profiler 的 CPU Usage 面板开启 Deep Profile。播放场景运行EntityRaycastJob记录该 Job 在 PlayerLoop 中的平均耗时。把 Job 里换成同步CastRay或者直接禁用 Burst对比两种写法的耗时差异。记录RaycastResult中HasHit为 true 的数量和自己手动计算的理论命中数量做对比。判断成功的标准Job 版本耗时明显低于主线程版本且每个 EnableableHasHit结果和预期一致。这里必须强调本文不给出固定帧率数字因为实际耗时取决于你的 CPU 核心数、碰撞体数量、碰撞体形状和动态体数量。正确做法是记录自己机器上的对比数据重点观察同场景下同步与 Job 的耗时差距。7. 性能观察与优化DOTS Physics 的性能来源有三层数据布局、并行调度、Burst 编译。射线查询的瓶颈通常不在CastRay本身而在物理世界重建。每一帧BuildPhysicsWorld都要把动态体的位置同步进 Broadphase动态体越多这个开销越大。所以静态场景里射线查询再贵也不怕动态物体多才是主要性能风险。以下优化点值得直接应用用[ReadOnly]标记PhysicsWorldSingleton让 Job 不持有写权限多个读取 Job 可以并行明不用互相等待。射线数量少几十条时主线程同步查询更简单数量多且每帧持续查询时再上 Job不要盲目 Job 化。层过滤要在射线查询前做好。物理查询会先走 Broadphase 粗筛再进窄相位精确检测层过滤能减少进入精确检测的候选数。NativeList使用后立即释放。Allocator.TempJob超过一帧不释放会产生安全错误堆内存分配也会拖累 GC。不要在一帧内同时做大量CompleteDependency()。频繁同步等于把并行 Job 拉回主线程排队性能打回原型。动态体数量尽量精简。假设同一帧有 500 个动态体同时在移动Broadphase 更新开销天然高于全部静态体的场景能静态化的碰撞体一律静态化。观察显存或内存不是 DOTS 物理的重点。内存占用主要来自NativeList、碰撞体烘焙数据以及物理世界内部缓冲。在 Profiler 的 Memory 面板里重点看Native分配器的增长曲线如果曲线持续上升多半是有容器没有释放。8. 常见问题与排查方法问题现象可能原因排查方式解决方案射线永远不命中目标没有PhysicsCollider或 SubScene 未烘焙检查 Entities 窗口里目标实体是否存在PhysicsCollider勾选 SubScene Authoring 强制烘焙射线命中了不该命中的对象CollisionFilter层位配置错误打印两个碰撞体的BelongsTo和CollidesWith统一在PhysicsLayers常量类里定义层位Burst 编译报错在 Burst 方法里使用了Debug.Log等不支持 API看编译错误堆栈定位到具体方法去掉Debug.Log改用 ECS Logging 或写回组件PhysicsWorldSingleton为空场景里没有物理相关实体或系统创建过早确认 SubScene 是否生成了带PhysicsCollider的实体场景内至少放一个烘焙物体即可触发物理世界构建Job 调度时安全错误或版本冲突主线程读取了 Job 正在写的数据缺少依赖处理查看 Safety System 报错信息对应组件写入state.Dependency依赖链或加CompleteDependency()老版本 DOTS 代码报错0.x 的BuildPhysicsWorld、CollisionWorld.CastRayAPI 已废弃检查 API 是否属于 1.x 命名空间迁移到PhysicsWorldSingleton和SystemAPI写法运行时 GC 压力大每帧 newNativeList或临时容器Profiler Memory 观察 Native 分配增长缓存容器、按时 Dispose或用Allocator.Temp控制生命周期性能提升不明显Job 前不断CompleteDependency()并行性被破坏Profile 里看 Job 间是否有同步等待依赖链串起来避免主线程反复读 Job 数据9. 最佳实践与工程建议DOTS Physics Raycast 用得好不好很多时候取决于工程习惯而不是 API 熟练度。下面几条是踩过坑之后总结下来的建议。第一固定随机种子做压力测试。批量射线性能测试如果没有固定随机数种子每次场景不同性能数据就没有可比性。测试场景、射线数量、目标分布全部固定才能在优化前后拿到可对比的数据。第二最小可运行配置要独立存档。给项目保留一个只有 SubScene、一个查询实体、少量目标球体的最小场景。出现问题先在这个场景里跑排除大规模场景对问题的干扰。第三目录管理要分清楚。Authoring 预制体、烘焙实体、射线 Job、结果组件分别放独立文件夹。项目后期一定会出现几十个 System 混在一起的情况目录清晰能省掉大量排查时间。第四日志要可开关。调试阶段打印每一条射线命中没问题但正式运行时不建议每帧Debug.Log。用条件编译或者专门的调试组件控制日志输出线上版本直接关闭。第五接口服务化意识提前建立。虽然 DOTS 是引擎内 API不是网络服务但在工程结构上可以把射线查询抽象成“输入起点 方向 长度 过滤规则输出命中实体”团队其他成员不需要理解 Job 细节也能调用。这能降低 DOTS 的上手门槛。第六数据回归测试。批量射线的结果是确定性数据同一固定种子、同一场景输出版本也必须完全一致。如果更新代码后结果变了说明逻辑有回归需要立刻定位。第七涉及的素材、美术资源和动画数据只在合法授权的范围内使用到项目里。教程代码可以作为学习参考商用前务必替换成自有资源并复核物理表现。10. 总结与下一步这一期 DOTS Physics Raycast 的关键点可以浓缩成四个问题来回顾射线入口是什么答案是PhysicsWorldSingleton过滤规则怎么配答案是CollisionFilter的BelongsTo和CollidesWith大批量查询怎么写答案是IJobEntity加 Burst性能怎么验证答案是固定场景、固定种子、Profiler 对比。在自己项目里建议按顺序做两件事。第一件事是用BasicRaycastSystem把一条简单的射线跑通确认 SubScene 烘焙、物理世界构建、命中实体这条链路是完整的。第二件事是生成 1000 个查询实体跑一次EntityRaycastJob在 Profiler 里看同步与 Job 的耗时差。这一步做完你对 DOTS 物理的性能边界会有一个直观认识后面再学碰撞事件、距离查询、CCD 连续碰撞检测都会轻松不少。最容易踩的坑也再说一遍目标碰撞体没有烘焙成实体、Filter 层位没有配好、Job 依赖没有处理完整。这三个坑占据了 DOTS 物理初期 80% 的报错时间先避开它们学习曲线会顺畅得多。下一步可以先往三个方向延伸用CastRayAll做穿透型攻击检测、用CalculateDistance代替射线做近距离感知、把射线查询结果接入行为树驱动 AI 决策。DOTS 物理并不是只能用来做射线它是一整套数据驱动的碰撞查询体系射线只是第一块敲门砖。
返回列表