news 2026/10/7 9:57:41

DOTS物理Raycast实战:从ECS到Job System的并行检测

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DOTS物理Raycast实战:从ECS到Job System的并行检测

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,它包含三个核心部分:ECS(Entity 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 与 SimulationType

PhysicsWorld是物理数据的容器组件,挂在场景的某个 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 一个新项目开始,之后按顺序做以下几件事:

  1. 打开 Window > Package Manager。
  2. 在包列表里搜索并安装 Entities、Physics、Burst、Collections。
  3. 在 Project Settings > Player > Scripting Define Symbols 里,确认不需要额外添加宏(一般装上包后自动引入)。
  4. 写代码时,确保脚本文件放在 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 的执行链路可以拆成四步:

  1. 准备好 Raycast 的输入数据(起点、方向、距离、过滤条件)。
  2. 将输入打包成RaycastCommand,放入NativeArray。
  3. 通过 Job 并行执行批量射线查询。
  4. 从NativeArray<RaycastHit>中读取结果并做后续逻辑。

下面逐步展开。

4.1 构建 RaycastCommand

RaycastCommand的结构体定义大致如下(以实际 API 为准):

public struct RaycastCommand { public float3 From; public float3 Direction; public float MaxDistance; public CollisionFilter Filter; }

From是射线的起点,Direction是方向向量(通常需要 normalized),MaxDistance是最大检测距离,Filter是碰撞过滤配置。创建命令的代码如下:

var commands = new NativeArray<RaycastCommand>(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)且MaxDistance=50,实际检测距离大约是 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 NativeArray<RaycastCommand> Commands; [WriteOnly] public NativeArray<RaycastHit> 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.GetSingleton<Unity.Physics.PhysicsWorld>(); var collisionWorld = physicsWorld.CollisionWorld; var commands = new NativeArray<RaycastCommand>(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 NativeArray<RaycastHit>(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一致。

一个常见需求是:发射一束射线,找出所有命中物体,取最近的。你可以用NativeList<RaycastHit>接收多段命中,然后遍历取Fraction最小的那个。由于 DOTS 支持永久性物理数据(Persistent Physics Data),你甚至可以复用上一次查询的碰撞体信息来做连续检测。

如果你要拿CollisionWorld.CastRay获取多段命中,需要注意:

  1. 传入一个NativeList<RaycastHit>作为allHits数组。
  2. 设置RaycastInput.QueryContext的IsTrigger相关字段。
  3. 至少预留足够大的 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 : Baker<PhysicsShapeAuthoring> { 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 NativeArray<RaycastInput> Inputs; public NativeArray<RaycastHit> 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.GetSingleton<Unity.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 NativeArray<RaycastInput>(1, Allocator.TempJob); inputs[0] = new RaycastInput { Start = origin, End = origin + direction * 100f, Filter = CollisionFilter.Default, // QueryContext 可以留默认 }; var results = new NativeArray<RaycastHit>(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.GetSingleton<Unity.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 里运行却发现性能没变化,原因通常有两个:

  1. Burst 编译器只在 Player 构建后启用编译优化,Editor 里默认使用 Managed 版本。
  2. Job 未真正异步执行,还是同步跑。

你可以通过 Job Debugger 窗口(Jobs > Burst > Open Inspector)确认 Job 是否被 Burst 编译。

从工程落地角度,一个更推荐的实践是:射线检测 Job 不应在OnUpdate里直接.Complete(),而应使用state.Dependency = job.ScheduleParallel(...)把依赖链交给 ECS 框架,让后续系统可以等待这个依赖。当多个系统都需要访问同一个物理世界时,正确的依赖顺序能避免数据竞争造成的错误结果。

6. 运行结果与效果验证

把上述脚本挂在场景中后,你需要:

  1. 在场景里创建一个带PhysicsShapeAuthoring的 3D 物体,例如 Cube。
  2. 将 Cube 移到(5, 1, 0)位置。
  3. 点击 Play,观察 Console 窗口。

预期输出是在每 0.5 秒一次的检测中,如果射线从(0,1,0)出发,沿 X 轴正方向前进,碰到 Cube,会打印:

DOTS Raycast Hit! Entity: Entity(1:1), Fraction: 0.75

Fraction=0.75意味着命中点在起点的 75% 距离处,即 75 米左右。如果 Cube 位置在 5 米,Fraction 应该是 0.05。你用自己的场景换算即可。

如果输出一直是 "No Hit",优先检查以下三点:

  1. Cube 是否转换为 ECS Entity?如果 Cube 还是普通 GameObject 未转换,就不会进入PhysicsWorld。
  2. 射线起点和方向是否正确?把origin打印出来,肉眼验证是否被障碍物挡住。
  3. 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 NativeList<RaycastHit>)
编译报错找不到Unity.Physics命名空间错误或包未安装检查包管理器安装情况安装 com.unity.physics 包,确认命名空间为 Unity.Physics
Job 中无法访问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 中,不要在一个系统里全部完成,而是:

  1. 物理检测系统:只产出RaycastHit或HitEvent组件。
  2. 游戏逻辑系统:消费HitEvent,修改血量等组件。
  3. 表现系统:根据 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 性能时,按顺序检查:

  1. Burst 是否启用。
  2. Job 是否并行调度而不是同步 Complete。
  3. 物理世界是否每帧重建(BuildPhysicsWorld 的开销可能超过查询开销)。
  4. 查询命令的载荷大小(使用 RaycastCommand 而不是自定义 struct)。
  5. CollisionWorld 中的碰撞体数量(必要时拆分 PhysicsWorld)。
  6. 是否使用了多段命中却只取第一个命中(用单段命中更省)。
  7. 是否访问了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 依赖、批次大小、内存释放和安全边界。

如果你想继续深入学习,我建议按这个顺序往下走:

  1. 先把文中示例跑通,改成 10000 条射线压测,观察 Profiler 变化。
  2. 学透CollisionFilter.GroupIndex的用法,理解GroupIndex与BelongsTo/CollidesWith的优先级关系。
  3. 研究PhysicsSimulationGroup的 Job 依赖链,把ScheduleParallel从同步改成异步。
  4. 再看SphereCastCommand、BoxCastCommand、ColliderCastInput等其它形状的物理查询,你会发现它们和 Raycast 只是输入参数不同。

这套知识学完后,你再回头看传统Physics.Raycast,会自然地意识到:不是 DOTS 把简单的东西复杂化了,而是 DOTS 把"可以被并行化的查询"从"只能串行的逻辑"中解放了出来。当你面对未来移动端大规模战斗场景、服务端物理校验、或编辑器工具批量生成碰撞数据时,这套能力会成为你绕不开的底层基础设施。建议收藏这篇文章,实际动手跑一遍示例,坑踩过一次,印象会比读十遍清晰得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 9:56:19

如何用RPCS3把PS3游戏调到60帧

如何用RPCS3把PS3游戏调到60帧 【免费下载链接】rpcs3 PlayStation 3 emulator and debugger 项目地址: https://gitcode.com/GitHub_Trending/rp/rpcs3 游戏一启动就卡成幻灯片&#xff0c;跑几分钟还闪退&#xff1f;RPCS3&#xff08;PlayStation 3模拟器&#xff09…

作者头像 李华
网站建设 2026/10/7 9:55:54

Cadence Allegro 过孔替换:5种方法、参数核算与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 9:55:13

游戏引擎基础架构详解:核心模块、主循环与资源管理

在游戏行业摸爬滚打这些年&#xff0c;我始终保持着拆引擎的习惯。你可能也在用Unity、Unreal&#xff0c;或者公司内部的引擎做项目&#xff0c;但当你真正去关心"游戏引擎架构"这几个字时&#xff0c;会发现市面上大多数教程都在教你怎么调API、怎么写玩法逻辑&…

作者头像 李华