
1. 为什么 DOTS 里的 Raycast 值得单独搞懂很多 Unity 开发者第一次接触 DOTS 时最先发出的疑问往往是同一个物理检测怎么写在传统 MonoBehaviour 开发里一个Physics.Raycast就搞定了API 简单直接文档满天飞。但到了 DOTS / ECS 体系里你发现找不到一个叫Physics.Raycast的静态方法取而代之的是一套名为PhysicsWorld、RaycastCommand、CollisionFilter的新概念。于是不少人第一反应是这不是把简单事情复杂化吗这个判断只对了一半。DOTS 物理之所以设计成一套完全不同的 API根本原因在于传统Physics.Raycast是单线程、即时调用、直接返回结果的模型。如果在一个for循环里跑 1000 次 Raycast主线程会被活活卡住。而 DOTS 的目标是把大量物理检测打包成 Job 批量执行利用多核 CPU 并行计算同时配合 ECS 的数据结构让物理查询与游戏逻辑解耦。理解了这一点你就明白为什么 Raycast 在 DOTS 体系里值得单独学。如果你正在做大规模弹幕、群体 AI 索敌、子弹系统、寻路避障、地形采样这类需要高频物理检测的项目DOTS 里的RaycastCommand可以带来一个数量级的性能提升。而如果你只是做一个 2D 小游戏每帧最多发射几次射线那确实完全可以继续用传统Physics.Raycast不必强上 DOTS。这篇文章我打算用一个贴近实战的示例来拆解 DOTS Physics Raycast 的完整用法从环境准备、核心概念到代码实现、运行验证再到常见坑点与工程建议。目标只有一个让你读完后能够在自己的项目中独立写出基于RaycastCommand的物理查询逻辑并且知道在什么场景下该用它、什么场景下别硬用。2. 先搞清楚 DOTS 物理的核心概念2.1 ECS 与 Physics 的关系DOTS 的全称是 Data-Oriented Technology Stack它包含三个核心部分ECSEntity Component System、Job System、Burst Compiler。ECS 不等于 DOTS但 DOTS 的运行时架构是围绕 ECS 组织的。在 ECS 体系里物理模块是这样的场景中所有的碰撞体Collider、刚体RigidBody、物理配置都会进入一个叫PhysicsWorld的容器。PhysicsWorld是物理模拟和物理查询的底层数据源。我们可以把它理解为一张庞大的物理数据库表数值以组件的形式存在 Entity 上每帧由物理系统同步更新。与传统的 GameObject 物理不同DOTS 物理世界没有依赖Transform组件它依赖的是 ECS 自己的LocalTransform/PhysicsTransform组件。这也是新手最容易忽略的转换点——如果你的物体既有原生GameObject的 Transform又想参与 DOTS 物理就必须走GameObject Conversion流程把实体转换成 ECS Entity 并生成相应的物理组件。2.2 RaycastCommand 与传统 Raycast 的差异传统写法是RaycastHit hit; if (Physics.Raycast(origin, direction, out hit, maxDistance)) { Debug.Log(hit.collider.name); }DOTS 里的核心类型是RaycastCommand。但注意RaycastCommand不是方法而是一个结构体struct它描述一次射线查询的输入。执行它有两种主要方式作为IJobEntity/IJobParallelFor的一部分在 Job 中批量执行。直接调用PhysicsWorld.CastRay同步查询。RaycastCommand之所以设计成 struct是因为它天然适合 Job System 的数据布局。CPU 在遍历连续内存时最快ECS 和 Job System 都遵循这个原则。你一次性创建 1000 个RaycastCommand放入一个NativeArray交给一个ParallelForJob 执行Burst 编译器会将这些数据组织成适合 SIMD 处理的布局彻底榨干 CPU 的并行能力。可以用一个比喻来理解差异传统方式像一个服务员一个个上菜每上一道菜都要等顾客反馈DOTS 方式像一个厨房批量出餐所有订单一次性送入后厨各位厨师并行烹饪最后统一收盘。对于小餐馆一对一服务没问题对于上千张桌子的宴会后厨流水线才能扛住。2.3 CollationFilter 与碰撞过滤在 DOTS 物理中碰撞检测不是靠LayerMask至少不完全一样而是靠CollisionFilter。public struct CollisionFilter { public uint BelongsTo; public uint CollidesWith; public int GroupIndex; }BelongsTo和CollidesWith是 Bit Mask用位运算判断是否碰撞类似于传统 Layer 的 mask。GroupIndex是特殊规则如果两个碰撞体的GroupIndex相同且不为 0则必定碰撞如果相反则必定不碰撞。这个设计在时序射击、子弹穿透、队友误伤等需求里非常实用。新手容易把CollisionFilter和 ECS 的PhysicsCollider组件混为一谈。事实上CollisionFilter通常嵌套在PhysicsCollider内部是其中的一个字段。过滤的是物理世界的查询和碰撞结果而不是决定物理模拟本身是否生效。2.4 PhysicsWorld 与 SimulationTypePhysicsWorld是物理数据的容器组件挂在场景的某个 Entity 上。在 DOTS 的运行时中每个物理场景默认有一个PhysicsWorld。当我们使用BuildPhysicsWorld系统时它会收集所有PhysicsCollider、PhysicsVelocity、PhysicsMass等组件构建出可供查询的物理世界数据。另一个常用概念是SimulationType决定物理世界是No Simulation只做查询不做模拟、Unity Physics完整物理模拟还是Dynamics Processing限制动态体的位置更新。如果只做射线检测、不关心刚体运动可以把物理世界配置为No Simulation省掉每帧的大量计算。这个概念对开发的意义是在 DOTS 里查询物理世界 和 模拟物理世界 是两件可拆分的事。你可以构建物理世界只用于查询而完全关闭模拟。这在大量射线检测、AI 感知、网络同步校验等场景里非常实用。3. 环境准备与前置条件3.1 Unity 版本与包依赖DOTS 物理目前与 Unity 版本绑定较深建议按官方发布版本索引来配置。以 Unity 6 为例你需要通过 Package Manager 确认安装了以下包{ dependencies: { com.unity.entities: 1.2.0, com.unity.physics: 1.2.0, com.unity.burst: 1.8.0, com.unity.collections: 2.4.0, com.unity.jobs: 0.70.0 } }注意这里的版本号只是示例。DOTS 的包版本迭代非常快我强烈建议你在实际操作时以 Package Manager 里官方推荐的版本为准而不是照抄网上任意一个编号。版本不一致是 DOTS 项目最常见的问题来源之一。Unity 6 中 DOTS 的安装已经简化了许多直接在 Package Manager 搜索 Entities 和 Physics 就能看到官方发布版本。3.2 创建 ECS 项目DOTS 项目与普通 Unity 项目没有本质区别建议从 Create 一个新项目开始之后按顺序做以下几件事打开 Window Package Manager。在包列表里搜索并安装 Entities、Physics、Burst、Collections。在 Project Settings Player Scripting Define Symbols 里确认不需要额外添加宏一般装上包后自动引入。写代码时确保脚本文件放在 Assets 目录下的任意文件夹编译目标为 .NET Standard 2.1。如果是从老版本迁移还需要注意旧版包名com.unity.entities下可能包含 Physics 相关功能但在新版中 Physics 已经被独立为com.unity.physics。安装后命名空间变成了Unity.Physics不再从Unity.Entities.Physics引用。如果这一步没做对你会遇到大量 cannot find namespace Unity.Physics 的编译错误。排查时首先去 Project Settings 检查 Package Manager 的包引用其次用 IDE 的go to definition确认命名空间是否存在。3.3 场景配置在编辑器中DOTS 物理世界不是自动创建的。你需要给场景添加PhysicsWorld通常使用脚本方式var world new PhysicsWorld(0); var physicsWorldElement new PhysicsWorldElement { World world };更常见的推荐做法是在入口 Entity 上添加PhysicsWorldIndex和PhysicsCustomTags等组件或者在初始化系统里调用BuildPhysicsWorld系统。教材里的最常见演示方式是让系统继承SystemBase然后在系统的OnCreate里获取BuildPhysicsWorld的 JobHandle。不过这里我想给你一个更简单的判断标准如果你只是做射线检测不涉及刚体运动模拟那么你并不需要Unity.Physics.Systems下的完整物理系统库。你可以在自己的系统里构建一个轻量物理世界仅填充查询所需的数据。这样既降低复杂度也避免了自动物理系统对性能的占用。4. 核心流程拆解从创建命令到获取结果DOTS Raycast 的执行链路可以拆成四步准备好 Raycast 的输入数据起点、方向、距离、过滤条件。将输入打包成RaycastCommand放入NativeArray。通过 Job 并行执行批量射线查询。从NativeArrayRaycastHit中读取结果并做后续逻辑。下面逐步展开。4.1 构建 RaycastCommandRaycastCommand的结构体定义大致如下以实际 API 为准public struct RaycastCommand { public float3 From; public float3 Direction; public float MaxDistance; public CollisionFilter Filter; }From是射线的起点Direction是方向向量通常需要 normalizedMaxDistance是最大检测距离Filter是碰撞过滤配置。创建命令的代码如下var commands new NativeArrayRaycastCommand(count, Allocator.TempJob); var origin new float3(0f, 1f, 0f); var direction math.normalize(new float3(1f, 0f, 0f)); for (int i 0; i count; i) { commands[i] new RaycastCommand { From origin, Direction direction, MaxDistance 50f, Filter new CollisionFilter { BelongsTo 1u, CollidesWith 1u, GroupIndex 0 } }; }这里有一个容易踩坑的细节Direction没有 normalize 会怎样从物理层看MaxDistance的实际判断距离会被方向向量的长度放大。比如方向是(2,0,0)且MaxDistance50实际检测距离大约是 100。这在传统 Physics.Raycast 中也有类似问题但 DOTS 中更容易被忽略因为方向向量常来自未归一化的向量运算。4.2 执行批量查询批量执行的最简单方式是用RaycastCommand的静态调度方法。在 DOTS 新版本中你可以直接写var raycastJob RaycastCommand.ScheduleParallel(commands, results, 128, default(PhysicsWorld));但更通用且适合学习的做法是自己写一个IJobParallelFor[BurstCompile] public struct RaycastJob : IJobParallelFor { [ReadOnly] public NativeArrayRaycastCommand Commands; [WriteOnly] public NativeArrayRaycastHit Results; public void Execute(int index) { // 注意这里需要在 PhysicsWorld 中进行不能直接用 Command 数组执行 } }因为RaycastCommand本身不持有物理世界数据你需要配合PhysicsWorld才能完成真正的检测。在 Job 中执行检测更推荐的方式是拿到PhysicsWorld内部的CollisionWorld然后调用CollisionWorld.CastRay。教材里常见的完整链路是通过BuildPhysicsWorld获取PhysicsWorld的引用然后在 Job 中读取。我们可以这样组织代码public partial struct PhysicsRaycastSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { UnityEngine.Physics.PhysicsWorld physicsWorld SystemAPI.GetSingletonUnity.Physics.PhysicsWorld(); var collisionWorld physicsWorld.CollisionWorld; var commands new NativeArrayRaycastCommand(1, Allocator.TempJob); commands[0] new RaycastCommand { From new float3(0, 1, 0), Direction math.normalize(new float3(1, 0, 0)), MaxDistance 100, Filter CollisionFilter.Default }; var results new NativeArrayRaycastHit(1, Allocator.TempJob); new SingleRaycastJob { CollisionWorld collisionWorld, Commands commands, Results results }.Run(); if (results[0].Entity ! Entity.Null) { UnityEngine.Debug.Log($Hit Entity: {results[0].Entity}); } commands.Dispose(); results.Dispose(); } }这里有个关键点PhysicsWorld本身是ISystem中的单例组件你不能在 Job 里直接持有它的引用直接使用因为 Job 结构体内只能使用 Blittable 类型。但CollisionWorld内部包含NativeArray和NativeList这些是可以安全引用在 Job 里的。所以我们要把它取出来作为 Job 的字段。4.3 RaycastHit 的结构RaycastHit是查询结果的载体主要字段包括Entity被击中的 ECS 实体。Position/Normal撞击点位置与法线。Fraction射线前进比例0 到 1可用来换算实际距离。RigidBodyIndex命中的刚体序号。ColliderKey命中碰撞体的内部 key。我建议你至少检查Entity和Fraction两个字段。Entity.Null表示没有命中任何物体Fraction可以用来做多段命中排序或者判断哪个物体离起点最近。4.4 多段命中与最短路程处理传统 Raycast 只返回第一个命中DOTS 的CastRay也可以通过RaycastInput中的参数来控制返回的命中数。DOTS 物理除了射线还支持ColliderCastInput做碰撞体扫描以及SphereCastCommand等球体/胶囊扫描命令。它们的调用模式与RaycastCommand一致。一个常见需求是发射一束射线找出所有命中物体取最近的。你可以用NativeListRaycastHit接收多段命中然后遍历取Fraction最小的那个。由于 DOTS 支持永久性物理数据Persistent Physics Data你甚至可以复用上一次查询的碰撞体信息来做连续检测。如果你要拿CollisionWorld.CastRay获取多段命中需要注意传入一个NativeListRaycastHit作为allHits数组。设置RaycastInput.QueryContext的IsTrigger相关字段。至少预留足够大的 List 容量来容纳结果如果容量太小会丢弃超出部分。讲到这里你已经掌握了 DOTS Raycast 的核心执行流程。接下来用一个完整例子对上述内容做验证。5. 完整示例子弹发射与命中检测5.1 场景结构我们构造一个最简单的场景一个带PhysicsCollider的实体方块一个能够响应鼠标点击或固定频率发射射线的系统。射线从主摄像机出发如果命中目标就在控制台打印命中的 Entity。先做一个承载物理世界的初始化系统using Unity.Entities; using Unity.Physics; using Unity.Physics.Systems; public partial class PhysicsWorldInitSystem : SystemBase { protected override void OnCreate() { base.OnCreate(); // 通常默认物理系统已存在BuildPhysicsWorld 系统会自动创世。此处只为演示存在入口。 } protected override void OnUpdate() { // 空系统占位标识 } }实际上你不需要自己创建物理世界BuildPhysicsWorldSystem会自动处理。在 Unity 6 中打开一个有 DOTS 物理组件的场景物理世界就能被自动构建。我们更关心的是如何从外部触发射线检测。5.2 创建 Entity 和碰撞体我们不需要走复杂的 Baker直接在系统里生成一个测试用的物理方块using Unity.Entities; using Unity.Mathematics; using Unity.Physics; using Unity.Physics.Authoring; using Unity.Rendering; using Unity.Transforms; public partial class SpawnTestBoxSystem : SystemBase { protected override void OnCreate() { var entityManager World.DefaultGameObjectInjectionWorld.EntityManager; var arch entityManager.CreateArchetype( typeof(LocalTransform), typeof(PhysicsCollider), typeof(PhysicsVelocity) ); var entity entityManager.CreateEntity(arch); entityManager.SetComponentData(entity, new LocalTransform { Position new float3(5f, 0.5f, 0f), Rotation quaternion.identity, Scale 1f }); entityManager.SetComponentData(entity, new PhysicsCollider { // 这里需要用 Unity.Physics 的 BoxCollider 创建碰撞体见下文 }); entityManager.SetComponentData(entity, new PhysicsVelocity { Linear float3.zero, Angular float3.zero }); } protected override void OnUpdate() { } }注意PhysicsCollider不是直接 new 一个 struct 就能用的因为碰撞体内部数据是安全句柄BlobAsset。正确创建方式是通过 Baker 或使用BoxCollider.Create(BoxGeometry)方法。保险起见推荐用 Baker 的路径创建一个PhysicsShapeAuthoring组件并附加到 GameObject 上然后通过 GameObject Conversion 转换为 Entity。这样生成碰撞体的逻辑由编辑器完成代码不会出错。Baker 示例using Unity.Entities; using Unity.Physics.Authoring; using Unity.Physics; public class BoxAuthoringBaker : BakerPhysicsShapeAuthoring { public override void Bake(PhysicsShapeAuthoring authoring) { var entity GetEntity(TransformUsageFlags.Dynamic); AddComponent(entity, new PhysicsCollider { Value authoring.Shape.Value, Filter authoring.Filter }); } }5.3 射线检测系统实现核心的射线系统如下。我们使用一个SystemBase每 0.5 秒发射一次射线方向向右检测距离 100。using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Jobs; using Unity.Mathematics; using Unity.Physics; using Unity.Physics.Systems; using UnityEngine; [UpdateAfter(typeof(Unity.Physics.Systems.PhysicsSimulationGroup))] public partial struct RaycastTestSystem : ISystem { private float m_Timer; [BurstCompile] private struct RaycastJob : IJobParallelFor { [ReadOnly] public CollisionWorld CollisionWorld; [ReadOnly] public NativeArrayRaycastInput Inputs; public NativeArrayRaycastHit Results; public void Execute(int index) { RaycastHit hit; if (CollisionWorld.CastRay(Inputs[index], out hit)) { Results[index] hit; } else { Results[index] RaycastHit.Null; } } } [BurstCompile] public void OnUpdate(ref SystemState state) { m_Timer SystemAPI.Time.DeltaTime; if (m_Timer 0.5f) return; m_Timer 0f; var physicsWorld SystemAPI.GetSingletonUnity.Physics.PhysicsWorld(); var collisionWorld physicsWorld.CollisionWorld; var origin new float3(0f, 1f, 0f); var direction math.normalize(new float3(1f, 0, 0)); var inputs new NativeArrayRaycastInput(1, Allocator.TempJob); inputs[0] new RaycastInput { Start origin, End origin direction * 100f, Filter CollisionFilter.Default, // QueryContext 可以留默认 }; var results new NativeArrayRaycastHit(1, Allocator.TempJob); var job new RaycastJob { CollisionWorld collisionWorld, Inputs inputs, Results results }; job.Schedule(1, 1).Complete(); if (results[0].Entity ! Entity.Null) { Debug.Log($DOTS Raycast Hit! Entity: {results[0].Entity}, Fraction: {results[0].Fraction}); } else { Debug.Log(No Hit); } inputs.Dispose(); results.Dispose(); } }这段代码有几个值得说明的设计决策第一我把CollisionWorld通过SystemAPI.GetSingletonUnity.Physics.PhysicsWorld()取出来然后直接赋值给 Job 字段。CollisionWorld是 struct内部含 NativeContainer符合 Job 字段要求。第二使用的是RaycastInput而不是RaycastCommand。“命令”风格和“输入”风格在底层执行上有差异RaycastCommand更适合大数据量批量并行而RaycastInput更接近底层查询。建议理解优先用RaycastInput大数据量性能优先用RaycastCommand。第三job.Schedule(1, 1).Complete()是同步做法会阻塞主线程方便验证。真正生产环境中你应该用ScheduleParallel并结合Dependency做 Job 链。5.4 Burst 优化注意事项上面的RaycastJob标注了[BurstCompile]。但如果你在 Editor 里运行却发现性能没变化原因通常有两个Burst 编译器只在 Player 构建后启用编译优化Editor 里默认使用 Managed 版本。Job 未真正异步执行还是同步跑。你可以通过 Job Debugger 窗口Jobs Burst Open Inspector确认 Job 是否被 Burst 编译。从工程落地角度一个更推荐的实践是射线检测 Job 不应在OnUpdate里直接.Complete()而应使用state.Dependency job.ScheduleParallel(...)把依赖链交给 ECS 框架让后续系统可以等待这个依赖。当多个系统都需要访问同一个物理世界时正确的依赖顺序能避免数据竞争造成的错误结果。6. 运行结果与效果验证把上述脚本挂在场景中后你需要在场景里创建一个带PhysicsShapeAuthoring的 3D 物体例如 Cube。将 Cube 移到(5, 1, 0)位置。点击 Play观察 Console 窗口。预期输出是在每 0.5 秒一次的检测中如果射线从(0,1,0)出发沿 X 轴正方向前进碰到 Cube会打印DOTS Raycast Hit! Entity: Entity(1:1), Fraction: 0.75Fraction0.75意味着命中点在起点的 75% 距离处即 75 米左右。如果 Cube 位置在 5 米Fraction 应该是 0.05。你用自己的场景换算即可。如果输出一直是 No Hit优先检查以下三点Cube 是否转换为 ECS Entity如果 Cube 还是普通 GameObject 未转换就不会进入PhysicsWorld。射线起点和方向是否正确把origin打印出来肉眼验证是否被障碍物挡住。CollisionFilter是否匹配如果 Cube 的BelongsTo是 2你的CollidesWith是 1就不会碰撞。另外建议在场景视图里用 Gizmos 画出射线方便调试#if UNITY_EDITOR void OnDrawGizmos() { Gizmos.color Color.red; Gizmos.DrawRay(new Vector3(0, 1, 0), new Vector3(100, 0, 0)); } #endif如果你用的是 ECS 系统就不能挂 Gizmos 在 MonoBehaviour 上。更快捷的方式是临时用一个MonoBehaviour做辅助可视化验证通过后再删除这并不影响最终发布。再补充一个判断性能是否达标的参考方式。把射线数量从 1 改成 10000同步执行一次测量耗时。如果使用 Burst Job 并行消耗不会线性增长这是 DOTS Raycast 的价值所在。可以在代码里加入Stopwatch微基准测试var sw System.Diagnostics.Stopwatch.StartNew(); // 执行一万次 Raycast sw.Stop(); Debug.Log($10000 raycasts cost {sw.ElapsedMilliseconds} ms);需要提醒的是Stopwatch 测 ECS Job 并不完全准确因为 Job 调度可能延迟。它只能做粗略对比精确性能请用 Unity Profiler 的 Burst 区域采样。7. 常见问题与排查思路DOTS 物理的相关报错和数据竞争问题非常典型我把最常见的情况整理成表。问题现象可能原因排查方式解决方案系统找不到PhysicsWorld单例场景内没有开启 Physics 系统或场景中没有物理组件确认已安装 com.unity.physics 包确认场景存在带 PhysicsCollider 的 Entity添加BuildPhysicsWorldSystem或用 SystemAPI.GetSingleton 前确保物理初始化完成Raycast 永远返回 null Entity目标没有转换为 Entity检查目标物体是否挂 PhysicsShapeAuthoring 且已转换通过 Baker 或 GameObject Conversion 生成物理实体Raycast 每次命中但 hit 参数明显错误方向向量未归一化打印 Direction 向量的长度使用 math.normalize一万次 Raycast 性能没有任何提升Burst 未启用Job 被同步 Complete检查 Burst Inspector确认 Job 是否异步调度启用 Burst使用 ScheduleParallel多地形、可能命中多个物体但只拿到第一个未使用 allHits 版本检查 CollisionWorld.CastRay 的重载改用CastRay(input, ref NativeListRaycastHit)编译报错找不到Unity.Physics命名空间错误或包未安装检查包管理器安装情况安装 com.unity.physics 包确认命名空间为 Unity.PhysicsJob 中无法访问PhysicsWorldPhysicsWorld结构体含非 Nash 类型在 Job 里只引用CollisionWorld在 Job 外取出 CollisionWorld 字段赋给 Job其中RaycastCommand.ScheduleParallel未正确使用可能造成性能没有提升这是新手最常搞不懂的一点。你写一个 IJobParallelFor 独自调用CollisionWorld.CastRay它本身是在 Job 里并行执行确实可以提升性能。但如果你用job.Schedule(1,1).Complete()其实相当于串行执行一个 Batch 内的 1 个任务性能必然差。改成job.ScheduleParallel(inputs.Length, 64)才能发挥多核能力。另外数据竞争是另一个高频坑。多个 Job 同时读写同一个PhysicsWorld的CollisionWorld虽然只读不会崩但你如果有一个 Job 在做物理模拟例如写PhysicsVelocity另一个 Job 在读碰撞数据两个 Job 没有建立依赖关系就可能出现一帧内读到半更新的物理状态。推荐做法是在读写物理数据的系统上标注[UpdateAfter(typeof(PhysicsSimulationGroup))]并把上一个 Job 的Dependency传递下去。8. 最佳实践与工程建议8.1 何时用 DOTS Raycast何时继续用传统 API一个清晰的决策边界单次或低频射线用传统Physics.Raycast。简单直接可维护性高。每帧上百次以上射线或射线结果需要和 ECS 逻辑联动用 DOTS Raycast。需要 Batched 物理检测如子弹群、光线阵列、群体 AI 索敌必须用 DOTS Raycast。很多团队把所有物理检测都改成 DOTS这其实是过度设计。传统 API 在性能不是瓶颈时代码更直观Stack Overflow 和官方文档资料也更多。保持两套 API 并存是合理的ECS 项目中依然允许 MonoBehaviour 调用Physics.Raycast。8.2 数据流设计查询、回写、表现分离在实际项目里射线检测的结果通常要驱动多种逻辑。一次点击可能既要做伤害计算、又要做特效播放、还要更新 UI。在 ECS 中不要在一个系统里全部完成而是物理检测系统只产出RaycastHit或HitEvent组件。游戏逻辑系统消费HitEvent修改血量等组件。表现系统根据 Entity 上的特效组件播放动画/粒子。这样做的好处是你可以在没有物理场景的纯逻辑测试中模拟 HitEvent也不必因为表现逻辑复杂度拖慢物理检测的 Job 执行。HitEvent可以使用EntityCommandBuffer或自定义的NativeStream从 Job 传递到逻辑层。8.3 批次大小和分组策略ScheduleParallel(inputs.Length, batchSize)中的 batchSize 默认值经验是 32 到 128。Batch 太大会导致线程间负载不均太小则调度开销过大。并不是越大越好。一组一万条射线batchSize 设置为 64 到 128 即可。此外物理世界的数据分量也影响性能大世界场景下CollisionWorld包含的碰撞体越多单次查询越慢。可以考虑将场景按区块分多个PhysicsWorld或者配置BroadPhase策略减少无效碰撞检测。8.4 使用 CollisionFilter 做分组管理建议在项目启动时用一个PhysicsCategory常量类来统一管理 BelongsTo 和 CollidesWith 的位分配避免在代码里到处写魔法数字。public static class PhysicsCategory { public const uint Default 1u 0; public const uint Player 1u 1; public const uint Enemy 1u 2; public const uint Terrain 1u 3; public const uint Trigger 1u 4; }编制碰撞矩阵时用一张二维表维护每对类别是否碰撞。这样做的好处是你可以只改这里的常量而不用在每处 Raycast 命令里手动计算。DOTS 的 filter 直接在结构体内设置分层清晰。8.5 安全性与边界条件物理检测在生产环境中涉及数据安全和逻辑安全应遵循几个原则不要在生产环境执行无授权的高并发物理检测。如果用于网络同步或多人对战必须经过服务器授权校验。所有 Job 中访问的 NativeContainer 都要声明[ReadOnly]或[WriteOnly]。如果漏写Burst 编译器可能报错或者在越界时产生材料未定义的行为。对RaycastHit.Entity有效性的判断不要只依赖 Entity ! Null。在实体被销毁后Entity 可能失效但非 Null。更稳妥的检查是调用EntityManager.Exists(entity)或在哈希表中比对生成版本。NativeArray和NativeList务必在OnDestroy或 Job 完成后 Dispose避免内存泄漏。可以用Entities的EntityCommandBuffer延迟也可以用using模式确保释放。8.6 性能优化清单在调优 DOTS Raycast 性能时按顺序检查Burst 是否启用。Job 是否并行调度而不是同步 Complete。物理世界是否每帧重建BuildPhysicsWorld 的开销可能超过查询开销。查询命令的载荷大小使用 RaycastCommand 而不是自定义 struct。CollisionWorld 中的碰撞体数量必要时拆分 PhysicsWorld。是否使用了多段命中却只取第一个命中用单段命中更省。是否访问了PhysicsWorld.AllColliders这种大字段一次 Job 里只访问需要的部分。做完这 7 步绝大多数项目中 Raycast 性能问题都能找到原因。如果还是慢用 Unity Profiler 的 Burst 区域采样看清楚时间消耗在哪个系统上而非盲目换算法。9. 总结与后续学习方向这篇文章围绕 DOTS 物理中的 Raycast 查询从概念到代码逐一拆解关键点可以总结为DOTS Raycast 不是传统 Physics.Raycast 的语法替换而是一套面向并行计算、数据驱动的完整查询链路。核心类型有PhysicsWorld、CollisionWorld、RaycastCommand、RaycastInput和RaycastHit理解它们的职责边界比背 API 更重要。执行 Raycast 要分四步构建输入、调度 Job、执行查询、处理结果。每一步都有对应的常见错误。性能提升靠的是 Burst 编译、Job 并行调度和合理的数据组织不是单纯换 API。工程落地要关注 CollisionFilter、Job 依赖、批次大小、内存释放和安全边界。如果你想继续深入学习我建议按这个顺序往下走先把文中示例跑通改成 10000 条射线压测观察 Profiler 变化。学透CollisionFilter.GroupIndex的用法理解GroupIndex与BelongsTo/CollidesWith的优先级关系。研究PhysicsSimulationGroup的 Job 依赖链把ScheduleParallel从同步改成异步。再看SphereCastCommand、BoxCastCommand、ColliderCastInput等其它形状的物理查询你会发现它们和 Raycast 只是输入参数不同。这套知识学完后你再回头看传统Physics.Raycast会自然地意识到不是 DOTS 把简单的东西复杂化了而是 DOTS 把可以被并行化的查询从只能串行的逻辑中解放了出来。当你面对未来移动端大规模战斗场景、服务端物理校验、或编辑器工具批量生成碰撞数据时这套能力会成为你绕不开的底层基础设施。建议收藏这篇文章实际动手跑一遍示例坑踩过一次印象会比读十遍清晰得多。