十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Unity DOTS Jobs 实战:TargetsAndSeekers 教程四步优化,从 330ms 到 0.5ms

Unity DOTS Jobs 实战:TargetsAndSeekers 教程四步优化,从 330ms 到 0.5ms 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 1Step 1Step1_NoJobs.unity纯 MonoBehaviour 暴力搜索基线Step 2Step 2Step2_SingleThreadedJob.unity单线程IJob BurstStep 3Step 3Step3_ParallelJob.unityIJobParallelFor多线程并行Step 4Step 4Step4_ParallelJob_Sorting.unity并行 Job 排序 二分搜索从 ProjectVersion.txt 可以看到Jobs101 是一个Unity 6000.2.10f1项目其 manifest.json 中声明了 URPcom.unity.render-pipelines.universal、Input Systemcom.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与TargetMonoBehaviour负责移动逻辑完全一致见 Seeker.cs 与 Target.cspublic void Update() { transform.localPosition Direction * Time.deltaTime; }FindNearest挂载在 Seeker 预制体上每帧遍历目标数组找出最近目标并画线见 Step 1/FindNearest.cspublic 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新增SeekerTransformsFindNearest从 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.csusing Unity.Burst; using Unity.Collections; using Unity.Jobs; using Unity.Mathematics; [BurstCompile] public struct FindNearestJob : IJob { // Job 访问的所有数据都必须包含在其字段中。 // 只读的数组字段应标注 [ReadOnly]虽然并非严格必需 // 但标注后 Job 调度器可以更安全地让更多 Job 并发执行。 [ReadOnly] public NativeArrayfloat3 TargetPositions; [ReadOnly] public NativeArrayfloat3 SeekerPositions; // 对 SeekerPositions[i]将最近目标的位置写入 NearestTargetPositions[i]。 public NativeArrayfloat3 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类型的NativeArrayVector3可隐式转换为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.FindFirstObjectByTypeSpawner()拿到生成器实例以获取数量参数。第二步性能结果不启用 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 3IJobParallelFor 并行 Job第三步把FindNearestJob从IJob改为IJobParallelFor让「每个 Seeker 找最近目标」的任务真正并行化。改动只有两处FindNearestJob现在实现IJobParallelFor而非IJob见 Step 3/FindNearestJob.csFindNearest中的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 NativeArrayfloat3 TargetPositions; [ReadOnly] public NativeArrayfloat3 SeekerPositions; public NativeArrayfloat3 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分量比较的IComparerfloat3public struct AxisXComparer : IComparerfloat3 { 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.csSortJobfloat3, AxisXComparer sortJob TargetPositions.SortJob(new AxisXComparer { });SortJob的Schedule()方法会调度两个 JobSegmentSort并行地对数组的各个分段分别排序SegmentSortMerge把已排序的分段合并起来合并算法无法并行化因此必须用单独的单线程 Job完成。两个 Job 之间存在明确的先后关系SegmentSortMerge必须等SegmentSort执行完毕才能开始因此SegmentSort被设为SegmentSortMerge的依赖dependency。规则是工作线程只有在某个 Job 的全部依赖都执行完毕之后才会执行该 Job。Job 依赖本质上让我们可以在已调度的 Job 之间指定顺序执行关系。而FindNearestJob必须等排序完成才能开始因此它依赖排序 Job。完整调度代码SortJobfloat3, 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 同时读写同一个NativeArrayUnity 的依赖检测机制会立刻捕获这种数据竞争隐患。第四步性能结果在 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.5msBurst移出主线程 Burst 编译Step 3IJobParallelFor10000×10000~260ms CPU 总耗时 / 17ms 墙钟16 核并行优化延迟Step 4并行 Job 按 X 排序 二分搜索10000×10000FindNearestJob 总 CPU ~7.5ms / ~0.5ms 墙钟算法级剪枝进一步缩小搜索范围从这条优化路径可以提炼出几条可复用的工程经验先量化基线Step 1 的 Profiler 数据是一切优化的出发点没有基线就无法判断收益数据先行Job/Burst 无法访问托管对象先用NativeArray等非托管集合组织好数据生命周期用Allocator.PersistentDispose()管理并行化要选对接口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),仅供参考
返回列表