Unity DOTS Jobs 实战:TargetsAndSeekers 教程四步优化,从 330ms 到 0.5ms
2026/9/16 12:59:27 网站建设 项目流程

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 系统的核心概念:IJobIJobParallelFor的用法、NativeArray数据搬运、[BurstCompile]加速、SortJob排序与 Job 依赖链,以及空间数据组织(按 X 排序 + 二分搜索)带来的算法级收益。

教程概览与运行环境

该教程位于 Jobs101 项目的TargetsAndSeekers目录下,被拆分为四个彼此递进的步骤,每个步骤都在独立的子目录中(Step N 对应Assets/StepN目录):

步骤目录场景文件技术要点
Step 1Step 1Step1_NoJobs.unity纯 MonoBehaviour 暴力搜索基线
Step 2Step 2Step2_SingleThreadedJob.unity单线程IJob+ Burst
Step 3Step 3Step3_ParallelJob.unityIJobParallelFor多线程并行
Step 4Step 4Step4_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.JobsUnity.CollectionsUnity.MathematicsUnity.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,避免每帧重复查找,因为目标集合是固定的。

SeekerTarget(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 编译后的代码是严格禁止访问任何托管对象的。

另外,虽然这里仍然可以用Vector3Mathf,但教程改用Unity.Mathematics包中的float3math,因为它对 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()遵循标准五步:

  1. 将 Seeker 与 Target 的 Transform 位置逐帧拷贝进float3类型的NativeArrayVector3可隐式转换为float3);
  2. 创建FindNearestJob实例并初始化各字段;
  3. 调用 Job 实例上的Schedule()扩展方法;
  4. Schedule()返回的JobHandle调用Complete()
  5. 用 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

第三步把FindNearestJobIJob改为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 的不同批次可以在不同线程上并发执行。

IJobParallelForExecute()方法接收一个索引参数,会从 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 的最近目标分三步:

  1. 二分搜索X 坐标最接近 Seeker 的目标;
  2. 从该索引出发,在数组中向上、向下搜索距离更小的目标;
  3. 搜索过程中,一旦X 轴距离超过当前候选目标的二维距离,立即提前退出(early out)

背后的关键洞察是:

  • 任何目标,若其 X 轴距离大于当前候选的二维距离,就不可能是更近的目标;
  • 假设目标已按 X 排序,如果某个目标的 X 轴距离过大,那么它一侧所有目标的 X 轴距离必然也过大。

于是每个 Seeker 不再需要逐个检查所有目标。源码实现见 Step 4/FindNearestJob.cs,其中AxisXComparer定义了对float3x分量比较的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 { });

SortJobSchedule()方法会调度两个 Job

  1. SegmentSort:并行地对数组的各个分段分别排序;
  2. 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 的执行序列为:SegmentSortSegmentSortMergeFindNearestJob

原文档特别提醒:如果忘记把排序 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 1MonoBehaviour 暴力搜索1000×1000~330ms主线程串行,O(N²)
Step 2单线程IJob1000×1000~30ms(无 Burst)→ ~1.5ms(Burst)移出主线程 + Burst 编译
Step 3IJobParallelFor10000×10000~260ms CPU 总耗时 / <17ms 墙钟16 核并行,优化延迟
Step 4并行 Job + 按 X 排序 + 二分搜索10000×10000FindNearestJob 总 CPU ~7.5ms / ~0.5ms 墙钟算法级剪枝,进一步缩小搜索范围

从这条优化路径可以提炼出几条可复用的工程经验:

  1. 先量化基线:Step 1 的 Profiler 数据是一切优化的出发点,没有基线就无法判断收益;
  2. 数据先行:Job/Burst 无法访问托管对象,先用NativeArray等非托管集合组织好数据(生命周期用Allocator.Persistent+Dispose()管理);
  3. 并行化要选对接口IJob适合整体串行的任务,IJobParallelFor适合按索引切分的任务,并通过 batch size 控制切分粒度;
  4. 用依赖表达顺序Schedule的最后一个参数把前置 JobHandle 传给后续 Job,安全系统会保证执行顺序并检测数据竞争;
  5. 算法与并行不矛盾:排序 + 二分搜索 + 提前退出把 O(N²) 摊薄到可接受范围,空间数据结构(四叉树/k-d 树)是更进一步的扩展方向;
  6. 识别真正的瓶颈:当计算本身已足够快时,帧时间会被 GameObject 体系的其他开销占据,下一步的答案在 Entities 中。

如需深入了解后续内容,可继续阅读仓库中的 Jobs101 项目目录 以及仓库顶层 README.md 了解各 101 教程(Entities101、Jobs101、Netcode101、Physics101)的整体布局。原文档同时附有 17 分钟的教程讲解视频链接,建议配合本文对照学习。

【免费下载链接】EntityComponentSystemSamples项目地址: https://gitcode.com/GitHub_Trending/en/EntityComponentSystemSamples

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询