Profiler で犯人を特定し、GC対策 も Update Manager もやった。それでもグラフの山が消えない——犯人が「全敵からプレイヤーへの距離計算」のような、 数千〜数万件の数値計算そのもの だった場合、呼び出し回数を整理する系の最適化は効きません。計算の総量が多いのだから、誰かがその計算をしなければならない。
ならば、 メインスレッド1人でやるのをやめましょう。今のCPUには複数のコアがあり、Unityには計算をワーカースレッドへ配る公式の仕組み—— Job System と、配った計算を高速なネイティブコードに変換する Burstコンパイラ ——があります。この記事は、ECSへ飛ぶ前の現実的な入口として、1万体の距離計算を題材にJobs+Burstへ入門します。
この記事でわかること
- Jobは万能高速化ではなく 「独立した大量計算」専用の道具 であること(向き・不向きの判断)
- Jobとデータをやり取りする NativeArray と、Disposeの責任
IJobParallelFor+Burst の最小実装と、Schedule→別作業→Completeの正しい順序- Job内から
transformなどのGameObject系APIに触れない理由と、その回避策- そして 「今は使わなくてよい」 と判断する引き際
動作確認環境: Unity 2022.3 LTS / Unity 6(Burst・Collectionsパッケージ使用。Package Managerで確認できます)
Jobが向く仕事、向かない仕事
最初にはっきりさせておきます。Jobは「付ければ何でも速くなる魔法」ではありません。仕事をワーカーへ配る往復にはコストがかかるので、 配る価値のある仕事 にしか効きません。

向いている仕事 の条件は3つそろっていることです。
- 大量: 数千〜数万件。数十件なら配るだけ損です
- 独立: 1件ごとの計算が他の件の結果に依存しない(1万体の距離計算は、どの1体も他の体と無関係に計算できます)
- 数値だけ: 入力も出力も数値データで完結する。ゲームでは、全敵の距離・視界判定、群れ(Boids)の移動ベクトル、プロシージャル地形の頂点計算などが典型です
逆に、 順序に意味がある処理(前の結果を次が使う)、 少量の処理 、そして GameObjectを触る処理(理由は後述)には向きません。この見極めができれば、この記事の半分は終わりです。
tips: 「うちのゲームに向く仕事はあるか?」の答えはProfilerが知っています。Profilerの記事 の手順で、Deep Profileで 1つのメソッドが大量の同じ計算を回している 山を探してください。それが候補です。
仕組み:メインスレッドからワーカーへ配る
Job Systemの絵はシンプルです。今まで全部を1人でやっていたメインスレッドが、 仕事の束をワーカースレッドたちへ配り、自分は別の仕事を続け、あとで結果を回収する ——それだけです。

ワーカースレッドはCPUのコア数に応じてUnityが自動で用意します。私たちが書くのは「1件ぶんの計算のやり方」(Jobの Execute)だけで、 何件をどのワーカーに割り振るかはUnityが面倒を見ます。スレッドを自分で作ったりロックを取ったりする、いわゆる怖いマルチスレッドプログラミングは登場しません。
そして Burst は、このJobのコードを、SIMD命令などを活用した高速なネイティブコードへ変換するコンパイラです。属性を1行付けるだけで、同じ計算が数倍〜数十倍速くなることも珍しくありません。JobとBurstは常にセットで使う、と覚えてしまって構いません。
NativeArray:Jobとやり取りする専用の入れ物
ワーカーへ仕事を配るには、データを渡す必要があります。ここで普通のC#の配列や List は使えません。代わりに使うのが NativeArray<T> ——スレッドをまたいで安全に読み書きできる、Job専用の入れ物です。
普通の配列と違う点は2つだけ、しかしどちらも重要です。
- 確保時に置き場所(Allocator)を指定する: そのフレーム内で使い捨てるなら
Allocator.TempJob、毎フレーム使い回すならAllocator.Persistent - 使い終わったら必ず
Dispose()で返却する: NativeArrayは ガベージコレクション の管理外にあるため、 確保した人が返す責任 を負います。忘れるとメモリリークの警告("A Native Collection has not been disposed")で叱られます
using Unity.Collections;
var distances = new NativeArray<float>(10000, Allocator.TempJob);
// ...Jobで使う...
distances.Dispose(); // 確保と返却は必ず対で
tips: 毎フレーム
TempJobで確保・返却を繰り返すと、その管理コスト自体が無視できなくなります。毎フレーム走らせるJobなら、Persistentで一度確保して使い回し、OnDestroyで返却するのが定石です。
最小のIJobParallelFor+Burst
道具がそろったので、Jobそのものを書きます。「配列の全要素に同じ計算をする」ためのJobが IJobParallelFor です。構造体として定義し、1件ぶんの計算を Execute(int index) に書きます。
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;
[BurstCompile] // この1行でネイティブコードに変換される
public struct DistanceJob : IJobParallelFor
{
[ReadOnly] public NativeArray<Vector3> positions; // 入力:全敵の座標
public Vector3 playerPosition; // 入力:プレイヤー位置
public NativeArray<float> distances; // 出力:距離
public void Execute(int index)
{
// 「index番目の1件」の計算だけを書く。何番がどのワーカーで走るかは気にしない
distances[index] = Vector3.Distance(positions[index], playerPosition);
}
}
見慣れない点は2つです。[BurstCompile] がBurst変換の 合図。[ReadOnly] は「このデータは読むだけ」という宣言で、付けておくと複数のワーカーが同じ配列を安全に同時に読めるようになります(出力側の distances は各ワーカーが自分の担当indexにしか書かないので、そのままで安全です)。
Schedule→別作業→Completeの順序
Jobを走らせる側のコードには、性能を分ける 順序の作法 があります。

// 1. Schedule:仕事を配る(まだ結果はない)
JobHandle handle = job.Schedule(positions.Length, 64); // 64は1束あたりの件数
// 2. メインスレッドは別の仕事を進める(ここが並列化の稼ぎどころ)
// 入力処理、UI更新、他のゲームロジック……
// 3. Complete:結果が必要になる直前に回収する
handle.Complete();
// 4. これ以降、distancesが読める
Schedule は「配って即座に戻る」だけで、計算はまだ終わっていません。結果を読む前には必ず Complete() で完了を待ちます。やってしまいがちなのが、Schedule の直後に Complete を呼ぶこと——それでは メインスレッドが腕組みして待つだけ で、並列化の意味が半減します。「配ってか ら回収までの間に、メインスレッドの別の仕事を挟む」が理想の形です(Updateで配ってLateUpdateで回収する、が実践しやすい配置です)。
実践:1万体の距離計算を並列化する
仕上げに通しで組みます。題材は「1万体の敵とプレイヤーの距離を毎フレーム計算する」——大群サバイバー系の索敵、RTSの範囲判定、タワーディフェンスのターゲット選びなど、ジャンルを問わず顔を出す計算です。

using Unity.Collections;
using Unity.Jobs;
using UnityEngine;
public class EnemyDistanceSystem : MonoBehaviour
{
[SerializeField] private Transform player;
private NativeArray<Vector3> positions;
private NativeArray<float> distances;
private JobHandle handle;
private void Start()
{
// 毎フレーム使い回すのでPersistentで一度だけ確保
positions = new NativeArray<Vector3>(10000, Allocator.Persistent);
distances = new NativeArray<float>(10000, Allocator.Persistent);
}
private void Update()
{
// 1. 入力を詰める(敵の座標を管理側からコピー)
for (int i = 0; i < positions.Length; i++)
positions[i] = EnemyRegistry.GetPosition(i);
// 2. 配る
var job = new DistanceJob
{
positions = positions,
playerPosition = player.position,
distances = distances,
};
handle = job.Schedule(positions.Length, 64);
// Updateの残りの仕事はここで続けてよい
}
private void LateUpdate()
{
// 3. 回収してから使う
handle.Complete();
// 例:一番近い敵を探して索敵に使う
int nearest = 0;
for (int i = 1; i < distances.Length; i++)
if (distances[i] < distances[nearest]) nearest = i;
EnemyRegistry.SetNearestToPlayer(nearest);
}
private void OnDestroy()
{
// 4. 返却の責任(Jobが走っている途中なら待ってから)
handle.Complete();
if (positions.IsCreated) positions.Dispose();
if (distances.IsCreated) distances.Dispose();
}
}
Playの前に、 必ずProfilerで「前」を録っておいてください。並列化の効果は環境と件数で大きく変わるので、体感ではなく Profilerの記事 の手順で前後を見比べるのが唯一の正しい評価です。CPU Usageのタイムライン表示にすると、今までメインスレッドに1本だけ立っていた計算の山が、 ワーカースレッドの段に分かれて同時に走っている のが見えるはずです——この光景が、並列化が効いている証拠です。ついでに [BurstCompile] の行をコメントアウトして再計測してみてください。Burstの貢献がどれほど大きいか、山の高さの違いで体感できます。
ポイントは2つ。 入れ物をPersistentで使い回し、確保・返却を起動時と破棄時の1回ずつに抑えた こと。そして UpdateでScheduleし、LateUpdateでCompleteすることで、間のメインスレッドの仕事と計算を重ねた ことです。
おまけ:先に知っておくと良いこと
- Job内からGameObjectに触れないのは仕様:
transform.positionやその他のGameObject系APIはメインスレッド専用として設計されており、ワーカーから触ると安全が壊れるため、Job内ではコンパイルエラーになります。 必要な値はSchedule前にNativeArrayへコピーして渡す のが基本です - Transformを大量に動かしたいなら: 読み取りではなく「1万体のTransformをJobで動かしたい」場合は、専用の
IJobParallelForTransformとTransformAccessArrayがあります。距離計算に慣れたら次に触ると良い仕組みです - 小規模なら普通のC#でよい: 数百件程度の計算なら、Jobの往復コストとコードの複雑さに見合いません。Profilerが騒いでいないなら、 今は使わない が正しい判断です。この道具は「本当に重くなった日」まで温存してください
- ECSはこの先にある: ゲーム全体をデータ指向で設計し直すECS(DOTS)は、JobsとBurstを土台にした大掛かりな選択肢です。まずJobs単独で「並列化の勘所」を掴んでおくと、将来ECSが必要になった時の学習が一段楽になります
まとめ
- Jobは 大量・独立・数値だけ の計算に効く道具。少量・順序依存・GameObject操作には向かない
- データの受け渡しは NativeArray。確保した人が Disposeで返却する責任 を持つ(毎フレームならPersistentで使い回す)
- 実装は
IJobParallelFor+[BurstCompile]。1件ぶんの計算だけを書けば、割り振りはUnityがやる - 順序は Schedule→別作業→Complete。配った直後に待たない
- 効果の判定は体感ではなく Profilerの前後比較 で。そして小規模なら、使わない勇気を
メインスレッド1人に背負わせていた計算を、今日はじめてチームに配りました。あなたのProfilerで一番高い山——それは「配れる仕事」ですか?