Unity DOTS Jobs 实战:TargetsAndSeekers 教程四步优化,从 330ms 到 0.5ms
【免费下载链接】EntityComponentSystemSamples项目地址: https://gitcode.com/GitHub_Trending/en/EntityComponentSystemSamples
本指南基于 EntityComponentSystemSamples 仓库中 Dots101/Jobs101/Assets/TargetsAndSeekers/README.md 展开,完整讲解一个「寻找最近目标」示例如何历经无 Job → 单线程 Job → 并行 Job → 并行 Job + 更优算法四个阶段,将逐帧耗时从约 330ms 优化到 0.5ms 量级。读完本文,你将掌握 Unity Jobs 系统的核心概念:IJob与IJobParallelFor的用法、NativeArray数据搬运、[BurstCompile]加速、SortJob排序与 Job 依赖链,以及空间数据组织(按 X 排序 + 二分搜索)带来的算法级收益。
教程概览与运行环境
该教程位于 Jobs101 项目的TargetsAndSeekers目录下,被拆分为四个彼此递进的步骤,每个步骤都在独立的子目录中(Step N 对应Assets/StepN目录):
| 步骤 | 目录 | 场景文件 | 技术要点 |
|---|---|---|---|
| Step 1 | Step 1 | Step1_NoJobs.unity | 纯 MonoBehaviour 暴力搜索基线 |
| Step 2 | Step 2 | Step2_SingleThreadedJob.unity | 单线程IJob+ Burst |
| Step 3 | Step 3 | Step3_ParallelJob.unity | IJobParallelFor多线程并行 |
| Step 4 | Step 4 | Step4_ParallelJob_Sorting.unity | 并行 Job + 排序 + 二分搜索 |
从 ProjectVersion.txt 可以看到,Jobs101 是一个Unity 6000.2.10f1项目;其 manifest.json 中声明了 URP(com.unity.render-pipelines.universal)、Input System(com.unity.inputsystem)等依赖。教程代码使用的Unity.Jobs、Unity.Collections、Unity.Mathematics、Unity.Burst属于 Unity 随编辑器交付的 DOTS 核心包,无需额外配置即可引用。
我们要解决的问题
- Seeker(蓝色立方体)与Target(红色立方体)各自在二维平面上沿随机方向缓慢移动;
- 从每个 Seeker 到它最近的 Target 之间绘制一条白色调试线;
- 移动缓慢意味着每帧世界状态变化很小,但寻找最近目标的计算量却可能非常庞大——这正是测试并行化与算法优化的理想场景。
Step 1:无 Job 的暴力搜索基线
第一步不引入任何 Job,完全用 MonoBehaviour 实现,用于建立性能基线。核心组件有三个:
Spawner(单例 MonoBehaviour)在Start()中完成全部初始化,对应 Step 1/Spawner.cs:
- 在 XZ 平面上的 500×500 区域内实例化 1000 个 Seeker 与 1000 个 Target;
- 每个物体用
Random.insideUnitCircle生成随机移动方向,并通过Random.Range(0, Bounds.x/y)随机落点; - 调用
Random.InitState(123)固定随机种子,保证每次运行结果可复现; - 将 Target 的 Transform 缓存在静态数组
TargetTransforms中,避免每帧重复查找,因为目标集合是固定的。
Seeker与Target(MonoBehaviour)负责移动,逻辑完全一致,见 Seeker.cs 与 Target.cs:
public void Update() { transform.localPosition += Direction * Time.deltaTime; }FindNearest(挂载在 Seeker 预制体上)每帧遍历目标数组,找出最近目标并画线,见 Step 1/FindNearest.cs:
public void Update() { // 比较距离的平方比比较距离本身更便宜, // 因为可以避免开平方根运算。 Vector3 nearestTargetPosition = default; float nearestDistSq = float.MaxValue; foreach (var targetTransform in Spawner.TargetTransforms) { Vector3 offset = targetTransform.localPosition - transform.localPosition; float distSq = offset.sqrMagnitude; if (distSq < nearestDistSq) { nearestDistSq = distSq; nearestTargetPosition = targetTransform.localPosition; } } Debug.DrawLine(transform.localPosition, nearestTargetPosition); }这里的查找算法是简单暴力穷举:对每一个 Seeker 都遍历所有 Target,复杂度为 O(N²)。源码注释中特别强调了用sqrMagnitude(距离平方)代替真实距离比较的优化技巧——纯比较场景下平方根是纯浪费,这个思路在后续所有 Job 版本中也被沿用(对应math.distancesq)。
第一步性能结果
在 1000 个 Seeker、1000 个 Target 规模下,Profiler 显示:每个 Seeker 更新约需 0.3ms,全部 1000 个 Seeker 合计超过 330ms——这意味着单帧就远超 60fps 的 16.6ms 预算,画面基本不可用。性能瓶颈显然在于每帧在 1000 个 GameObject 上串行执行 1000 次「遍历 1000 个目标」的暴力搜索,而且这一切都发生在主线程上。
Step 2:单线程 Job 版本
第二步把「硬计算」搬进 Job。与第一步相比,结构调整如下:
Spawner现在把Seeker 与 Target 的 Transform 都缓存进静态数组(见 Step 2/Spawner.cs,新增SeekerTransforms);FindNearest从 Seeker 预制体移到了Spawner GameObject上;FindNearest把 Seeker 与 Target 的localPosition拷贝进float3类型的NativeArray,然后调度并完成新的FindNearestJob。
为什么必须先拷贝数据?
把重活放进 Job 后,工作会从主线程转移到工作线程,并且可以 Burst 编译。而 Job 与 Burst 编译代码不能访问任何托管对象(包括 GameObject 及其组件),因此必须先将要处理的数据拷贝进非托管集合(如NativeArray)。
原文档附带的说明非常值得注意:
严格来说,Job 其实可以访问托管对象,但这样做需要格外小心,而且通常不是好主意。更何况我们明确希望对这个 Job 进行 Burst 编译,而 Burst 编译后的代码是严格禁止访问任何托管对象的。
另外,虽然这里仍然可以用Vector3和Mathf,但教程改用Unity.Mathematics包中的float3与math,因为它对 Burst 有专门的优化钩子,能获得更好的编译结果。
FindNearestJob 的定义
对应 Step 2/FindNearestJob.cs:
using Unity.Burst; using Unity.Collections; using Unity.Jobs; using Unity.Mathematics; [BurstCompile] public struct FindNearestJob : IJob { // Job 访问的所有数据都必须包含在其字段中。 // 只读的数组字段应标注 [ReadOnly]:虽然并非严格必需, // 但标注后 Job 调度器可以更安全地让更多 Job 并发执行。 [ReadOnly] public NativeArray<float3> TargetPositions; [ReadOnly] public NativeArray<float3> SeekerPositions; // 对 SeekerPositions[i],将最近目标的位置写入 NearestTargetPositions[i]。 public NativeArray<float3> NearestTargetPositions; // Execute 是 IJob 接口唯一的成员方法, // 工作线程执行该 Job 时会调用它。 public void Execute() { for (int i = 0; i < SeekerPositions.Length; i++) { float3 seekerPos = SeekerPositions[i]; float nearestDistSq = float.MaxValue; for (int j = 0; j < TargetPositions.Length; j++) { float3 targetPos = TargetPositions[j]; float distSq = math.distancesq(seekerPos, targetPos); if (distSq < nearestDistSq) { nearestDistSq = distSq; NearestTargetPositions[i] = targetPos; } } } } }可以看到,Job 是一个纯数据结构的 struct:所有依赖数据都以字段形式注入,Execute()内不触碰任何 UnityEngine 对象。[ReadOnly]标注虽非必须,但能帮助调度器判定数组访问语义、提升并发安全性。
FindNearest 的每帧流程
对应 Step 2/FindNearest.cs,其Update()遵循标准五步:
- 将 Seeker 与 Target 的 Transform 位置逐帧拷贝进
float3类型的NativeArray(Vector3可隐式转换为float3); - 创建
FindNearestJob实例并初始化各字段; - 调用 Job 实例上的
Schedule()扩展方法; - 对
Schedule()返回的JobHandle调用Complete(); - 用 Job 填充好的
NearestTargetPositions数组,从每个 Seeker 到最近目标绘制调试线。
其中调度与完成的代码段如下(与源码一致):
// 要调度一个 Job,先创建实例并填充其字段。 FindNearestJob findJob = new FindNearestJob { TargetPositions = TargetPositions, SeekerPositions = SeekerPositions, NearestTargetPositions = NearestTargetPositions, }; // Schedule() 把 Job 实例放入任务队列。 JobHandle findHandle = findJob.Schedule(); // Complete() 会一直阻塞直到该 JobHandle 代表的 Job 执行完毕。 // 某些情况下 Job 可能在调用 Complete() 之前就已执行完毕, // 无论哪种情况,Complete() 都只在 Job 完成后返回。 findHandle.Complete();NativeArray 的生命周期管理
Step 2/FindNearest.cs 中展示了NativeArray的标准生命周期:
- 数组大小在运行期不变,因此在
Start()中一次性创建并缓存为字段,使用Allocator.Persistent分配器——因为它需要在整个程序运行期间存在; - 在
OnDestroy()中调用Dispose()手动释放三个数组,非托管内存必须由使用者负责归还; - 通过
Object.FindFirstObjectByType<Spawner>()拿到生成器实例以获取数量参数。
第二步性能结果
- 不启用 Burst 编译时,1000×1000 规模下每帧约30ms——比第一步的 330ms 已有数量级提升(工作线程避免了主线程阻塞与托管层开销);
- 启用 Burst后骤降至约1.5ms,已远低于 60fps 的 16.6ms 预算。
启用 Burst 需要两步:给 Job struct 加上[BurstCompile]特性,同时确保菜单栏中 Burst 编译处于开启状态:
既然预算富余了,把规模提升到10000 个 Seeker、10000 个 Target:运行时间较 1000 规模增长了约 70 倍。这与 O(N²) 复杂度吻合——规模扩大 10 倍,暴力搜索的理论计算量扩大 100 倍,实际测量 70 倍属于合理波动。
📝注意:Profiler 中可以看到 Job 有时跑在主线程上。当对尚未从任务队列取出的 Job 调用
Complete()时,主线程反正也要空等 Job 完成,于是主线程本身可能直接执行该 Job。这解释了为何单线程 Job 在无 Burst 时仍比 Step 1 快得多。
Step 3:IJobParallelFor 并行 Job
第三步把FindNearestJob从IJob改为IJobParallelFor,让「每个 Seeker 找最近目标」的任务真正并行化。改动只有两处:
FindNearestJob现在实现IJobParallelFor而非IJob(见 Step 3/FindNearestJob.cs);FindNearest中的Schedule()调用新增两个 int 参数:索引数量(index count)与批大小(batch size)。
IJobParallelFor 的工作方式
对于处理数组或列表的 Job,通常可以按索引将工作切分为子区间并行执行——例如数组的前半段在一个线程上处理,后半段同时在另一个线程上处理。IJobParallelFor正是为这种场景设计的,其Schedule()接受两个 int 参数:
- index count:被处理数组(或列表)的长度;
- batch size:子区间(即批次)的大小。
举例:若 index count 为 100、batch size 为 40,则该 Job 被拆分为三个批次:第一批覆盖索引 0~39,第二批覆盖 40~79,第三批覆盖 80~99。工作线程逐个从队列中领取这些批次,因此同一个 Job 的不同批次可以在不同线程上并发执行。
IJobParallelFor的Execute()方法接收一个索引参数,会从 0 到 index count 为每个索引调用一次:
[BurstCompile] public struct FindNearestJob : IJobParallelFor { [ReadOnly] public NativeArray<float3> TargetPositions; [ReadOnly] public NativeArray<float3> SeekerPositions; public NativeArray<float3> NearestTargetPositions; // 每次 Execute 只处理一个单独的索引。 public void Execute(int index) { float3 seekerPos = SeekerPositions[index]; float nearestDistSq = float.MaxValue; for (int i = 0; i < TargetPositions.Length; i++) { float3 targetPos = TargetPositions[i]; float distSq = math.distancesq(seekerPos, targetPos); if (distSq < nearestDistSq) { nearestDistSq = distSq; NearestTargetPositions[index] = targetPos; } } } }调度端(见 Step 3/FindNearest.cs)用 Seeker 数组长度作为索引数量,批大小取 100:
// 该 Job 处理每个 Seeker,因此用 Seeker 数组长度作为索引数量。 // 批大小 100 是半随意的选择——不算太大也不算太小。 JobHandle findHandle = findJob.Schedule(SeekerPositions.Length, 100);注意:
IJobParallelFor要求Execute每次调用之间互不依赖(写各自的输出索引),这正是本问题天然具备的性质——每个 Seeker 的最近目标查找彼此独立,因此可以放心并行。
第三步性能结果
在 10000 个 Seeker、10000 个 Target 规模下,Profiler 显示:总 CPU 时间约 260ms,但从开始到结束的墙钟时间不足 17ms——工作被拆分到 16 个核心上并行执行,虽然总计算量没变(甚至因调度略有增加),但用户感知的单帧时间被压到了 60fps 预算线以内。
这里也揭示了 Job 系统的重要特性:Job 并行化优化的是延迟(latency),不是总吞吐量(throughput)。
Step 4:并行 Job + 更聪明的算法
第四步在并行化的基础上叠加了算法级优化:通过组织数据来减少必须检查的目标数量。核心改动有两处:
FindNearest额外调度一个 Job,把 Target 位置数组按 X 坐标排序(见 Step 4/FindNearest.cs);- 由于数组已排序,
FindNearestJob不再需要穷举每一个 Target。
排序 + 二分搜索的查找思路
将目标按 X 坐标排序(也可以按 Z,选择是任意的),找单个 Seeker 的最近目标分三步:
- 二分搜索X 坐标最接近 Seeker 的目标;
- 从该索引出发,在数组中向上、向下搜索距离更小的目标;
- 搜索过程中,一旦X 轴距离超过当前候选目标的二维距离,立即提前退出(early out)。
背后的关键洞察是:
- 任何目标,若其 X 轴距离大于当前候选的二维距离,就不可能是更近的目标;
- 假设目标已按 X 排序,如果某个目标的 X 轴距离过大,那么它一侧所有目标的 X 轴距离必然也过大。
于是每个 Seeker 不再需要逐个检查所有目标。源码实现见 Step 4/FindNearestJob.cs,其中AxisXComparer定义了对float3按x分量比较的IComparer<float3>:
public struct AxisXComparer : IComparer<float3> { public int Compare(float3 a, float3 b) { return a.x.CompareTo(b.x); } }Execute(int index)中先二分定位、再双向扩散搜索:
public void Execute(int index) { float3 seekerPos = SeekerPositions[index]; // 找到 X 坐标最接近 Seeker 的目标。 int startIdx = TargetPositions.BinarySearch(seekerPos, new AxisXComparer { }); // 没有精确匹配时,BinarySearch 返回最后一次搜索偏移量的按位取反。 // 所以当 startIdx 为负时,再次取反得到插入位置,但要确保索引在边界内。 if (startIdx < 0) startIdx = ~startIdx; if (startIdx >= TargetPositions.Length) startIdx = TargetPositions.Length - 1; // X 坐标最接近的目标位置。 float3 nearestTargetPos = TargetPositions[startIdx]; float nearestDistSq = math.distancesq(seekerPos, nearestTargetPos); // 向上搜索数组寻找更近的目标。 Search(seekerPos, startIdx + 1, TargetPositions.Length, +1, ref nearestTargetPos, ref nearestDistSq); // 向下搜索数组寻找更近的目标。 Search(seekerPos, startIdx - 1, -1, -1, ref nearestTargetPos, ref nearestDistSq); NearestTargetPositions[index] = nearestTargetPos; } void Search(float3 seekerPos, int startIdx, int endIdx, int step, ref float3 nearestTargetPos, ref float nearestDistSq) { for (int i = startIdx; i != endIdx; i += step) { float3 targetPos = TargetPositions[i]; float xdiff = seekerPos.x - targetPos.x; // 若 X 距离的平方大于当前最近距离,即可停止搜索。 if ((xdiff * xdiff) > nearestDistSq) break; float distSq = math.distancesq(targetPos, seekerPos); if (distSq < nearestDistSq) { nearestDistSq = distSq; nearestTargetPos = targetPos; } } }这里有一个值得注意的实现细节:BinarySearch在找不到精确匹配时会返回按位取反(~)的插入点,源码用if (startIdx < 0) startIdx = ~startIdx;还原插入索引,再做上下边界钳制,避免越界访问。提前退出条件用xdiff * xdiff > nearestDistSq比较平方值,延续了 Step 1 中「避免开平方」的优化思路。
原文档还提到,理论上用**四叉树(quadtree)**或k-d 树组织目标数据可以获得更大收益,本教程为了保持简单只做了按单轴排序——这是理解空间数据结构价值的良好起点。
用 SortJob 排序与 Job 依赖
教程没有手写排序 Job,而是直接调用NativeArray的扩展方法SortJob()(Step 4/FindNearest.cs):
SortJob<float3, AxisXComparer> sortJob = TargetPositions.SortJob(new AxisXComparer { });SortJob的Schedule()方法会调度两个 Job:
SegmentSort:并行地对数组的各个分段分别排序;SegmentSortMerge:把已排序的分段合并起来(合并算法无法并行化,因此必须用单独的单线程 Job完成)。
两个 Job 之间存在明确的先后关系:SegmentSortMerge必须等SegmentSort执行完毕才能开始,因此SegmentSort被设为SegmentSortMerge的依赖(dependency)。规则是:工作线程只有在某个 Job 的全部依赖都执行完毕之后,才会执行该 Job。Job 依赖本质上让我们可以在已调度的 Job 之间指定顺序执行关系。
而FindNearestJob必须等排序完成才能开始,因此它依赖排序 Job。完整调度代码:
SortJob<float3, AxisXComparer> sortJob = TargetPositions.SortJob( new AxisXComparer { }); FindNearestJob findJob = new FindNearestJob { TargetPositions = TargetPositions, SeekerPositions = SeekerPositions, NearestTargetPositions = NearestTargetPositions, }; JobHandle sortHandle = sortJob.Schedule(); // 让 find job 依赖排序 Job:把 sort job 的 handle 传给 find job 的 Schedule。 JobHandle findHandle = findJob.Schedule( SeekerPositions.Length, 100, sortHandle); // Complete 一个 Job 也会 Complete 它的全部依赖, // 因此完成 find job 也就完成了排序 Job。 findHandle.Complete();于是 Job 的执行序列为:SegmentSort→SegmentSortMerge→FindNearestJob。
原文档特别提醒:如果忘记把排序 Job 设为 find job 的依赖,Job 安全系统(job safety checks)会在尝试调度 find job 时抛出异常——因为两个 Job 同时读写同一个NativeArray,Unity 的依赖检测机制会立刻捕获这种数据竞争隐患。
第四步性能结果
在 10000 个 Seeker、10000 个 Target 规模下:
FindNearestJob总 CPU 时间约 7.5ms,从开始到结束仅约 0.5ms;- 放大看,
SegmentSort端到端耗时不足 0.1ms,单线程的SegmentSortMerge约 0.5ms; - 相比
FindNearestJob的巨大收益,额外付出的排序代价完全值得。
至此,帧耗时大头已经不再是「找最近目标」,而是 GameObject 体系本身的低效(每帧 Transform 同步、托管组件访问等)。原文档明确指出:剩余瓶颈可以用实体(Entities)替代 GameObject 来进一步消除——这也正是本仓库 Dots101 系列教程下一步的方向。
总结:四步优化的完整脉络
| 步骤 | 技术手段 | 规模 | 端到端耗时 | 说明 |
|---|---|---|---|---|
| Step 1 | MonoBehaviour 暴力搜索 | 1000×1000 | ~330ms | 主线程串行,O(N²) |
| Step 2 | 单线程IJob | 1000×1000 | ~30ms(无 Burst)→ ~1.5ms(Burst) | 移出主线程 + Burst 编译 |
| Step 3 | IJobParallelFor | 10000×10000 | ~260ms CPU 总耗时 / <17ms 墙钟 | 16 核并行,优化延迟 |
| Step 4 | 并行 Job + 按 X 排序 + 二分搜索 | 10000×10000 | FindNearestJob 总 CPU ~7.5ms / ~0.5ms 墙钟 | 算法级剪枝,进一步缩小搜索范围 |
从这条优化路径可以提炼出几条可复用的工程经验:
- 先量化基线:Step 1 的 Profiler 数据是一切优化的出发点,没有基线就无法判断收益;
- 数据先行:Job/Burst 无法访问托管对象,先用
NativeArray等非托管集合组织好数据(生命周期用Allocator.Persistent+Dispose()管理); - 并行化要选对接口:
IJob适合整体串行的任务,IJobParallelFor适合按索引切分的任务,并通过 batch size 控制切分粒度; - 用依赖表达顺序:
Schedule的最后一个参数把前置 JobHandle 传给后续 Job,安全系统会保证执行顺序并检测数据竞争; - 算法与并行不矛盾:排序 + 二分搜索 + 提前退出把 O(N²) 摊薄到可接受范围,空间数据结构(四叉树/k-d 树)是更进一步的扩展方向;
- 识别真正的瓶颈:当计算本身已足够快时,帧时间会被 GameObject 体系的其他开销占据,下一步的答案在 Entities 中。
如需深入了解后续内容,可继续阅读仓库中的 Jobs101 项目目录 以及仓库顶层 README.md 了解各 101 教程(Entities101、Jobs101、Netcode101、Physics101)的整体布局。原文档同时附有 17 分钟的教程讲解视频链接,建议配合本文对照学习。
【免费下载链接】EntityComponentSystemSamples项目地址: https://gitcode.com/GitHub_Trending/en/EntityComponentSystemSamples
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考