【Unity】Jobs+Burst入門:大量計算が本当に重い時の「次の一手」

作成: 2026-07-16

Profilerで特定した犯人が「数千〜数万件の距離計算」のような大量の数値計算だった時、GCやUpdate Managerの最適化だけでは足りなくなります。UnityのJob SystemとBurstコンパイラは、独立した大量計算をワーカースレッドへ配って並列化する公式の仕組み。NativeArrayの受け渡しとDisposeの責任、IJobParallelFor+Burstの最小例、Schedule→別作業→Completeの正しい順序、そして「小規模なら普通のC#でよい」という引き際まで、1万体の距離計算を題材に入門します。

Profiler で犯人を特定し、GC対策Update Manager もやった。それでもグラフの山が消えない——犯人が「全敵からプレイヤーへの距離計算」のような、 数千〜数万件の数値計算そのもの だった場合、呼び出し回数を整理する系の最適化は効きません。計算の総量が多いのだから、誰かがその計算をしなければならない。

ならば、 メインスレッド1人でやるのをやめましょう。今のCPUには複数のコアがあり、Unityには計算をワーカースレッドへ配る公式の仕組み—— Job System と、配った計算を高速なネイティブコードに変換する Burstコンパイラ ——があります。この記事は、ECSへ飛ぶ前の現実的な入口として、1万体の距離計算を題材にJobs+Burstへ入門します。

1人で計算の山に埋もれる人形と、仕事を分け合って並列で計算する人形たちのイメージ

この記事でわかること

  • Jobは万能高速化ではなく 「独立した大量計算」専用の道具 であること(向き・不向きの判断)
  • Jobとデータをやり取りする NativeArray と、Disposeの責任
  • IJobParallelFor+Burst の最小実装と、Schedule→別作業→Completeの正しい順序
  • Job内から transform などのGameObject系APIに触れない理由と、その回避策
  • そして 「今は使わなくてよい」 と判断する引き際

動作確認環境: Unity 2022.3 LTS / Unity 6(Burst・Collectionsパッケージ使用。Package Managerで確認できます)

Sponsored

Jobが向く仕事、向かない仕事

最初にはっきりさせておきます。Jobは「付ければ何でも速くなる魔法」ではありません。仕事をワーカーへ配る往復にはコストがかかるので、 配る価値のある仕事 にしか効きません。

Jobの向き不向きの比較図。向くのは大量・独立・数値だけの計算で、1万体の距離計算や群れの移動計算など。向かないのは少量の計算・順序に依存する処理・GameObjectを触る処理

向いている仕事 の条件は3つそろっていることです。

  • 大量: 数千〜数万件。数十件なら配るだけ損です
  • 独立: 1件ごとの計算が他の件の結果に依存しない(1万体の距離計算は、どの1体も他の体と無関係に計算できます)
  • 数値だけ: 入力も出力も数値データで完結する。ゲームでは、全敵の距離・視界判定、群れ(Boids)の移動ベクトル、プロシージャル地形の頂点計算などが典型です

逆に、 順序に意味がある処理(前の結果を次が使う)、 少量の処理 、そして GameObjectを触る処理(理由は後述)には向きません。この見極めができれば、この記事の半分は終わりです。

tips: 「うちのゲームに向く仕事はあるか?」の答えはProfilerが知っています。Profilerの記事 の手順で、Deep Profileで 1つのメソッドが大量の同じ計算を回している 山を探してください。それが候補です。

仕組み:メインスレッドからワーカーへ配る

Job Systemの絵はシンプルです。今まで全部を1人でやっていたメインスレッドが、 仕事の束をワーカースレッドたちへ配り、自分は別の仕事を続け、あとで結果を回収する ——それだけです。

Job Systemの仕組みの図。メインスレッドが計算の束を複数のワーカースレッドへ配り、ワーカーが並列で計算する間にメインスレッドは別の仕事を続け、最後に結果を回収する

ワーカースレッドは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を走らせる側のコードには、性能を分ける 順序の作法 があります。

Scheduleのタイムライン比較図。悪い例はScheduleの直後にCompleteを呼んでメインスレッドが待ちぼうけになる。良い例はScheduleした後にメインスレッドが別の仕事を進め、結果が必要になる直前にCompleteで回収する
// 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で回収する、が実践しやすい配置です)。

Sponsored

実践:1万体の距離計算を並列化する

仕上げに通しで組みます。題材は「1万体の敵とプレイヤーの距離を毎フレーム計算する」——大群サバイバー系の索敵、RTSの範囲判定、タワーディフェンスのターゲット選びなど、ジャンルを問わず顔を出す計算です。

実践の図。フィールドに大量の敵がひしめき、プレイヤーからの距離計算が4本のワーカーレーンへ分かれて同時に進み、Profilerの山が低くなる様子
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で動かしたい」場合は、専用の IJobParallelForTransformTransformAccessArray があります。距離計算に慣れたら次に触ると良い仕組みです
  • 小規模なら普通のC#でよい: 数百件程度の計算なら、Jobの往復コストとコードの複雑さに見合いません。Profilerが騒いでいないなら、 今は使わない が正しい判断です。この道具は「本当に重くなった日」まで温存してください
  • ECSはこの先にある: ゲーム全体をデータ指向で設計し直すECS(DOTS)は、JobsとBurstを土台にした大掛かりな選択肢です。まずJobs単独で「並列化の勘所」を掴んでおくと、将来ECSが必要になった時の学習が一段楽になります

まとめ

  • Jobは 大量・独立・数値だけ の計算に効く道具。少量・順序依存・GameObject操作には向かない
  • データの受け渡しは NativeArray。確保した人が Disposeで返却する責任 を持つ(毎フレームならPersistentで使い回す)
  • 実装は IJobParallelFor[BurstCompile]。1件ぶんの計算だけを書けば、割り振りはUnityがやる
  • 順序は Schedule→別作業→Complete。配った直後に待たない
  • 効果の判定は体感ではなく Profilerの前後比較 で。そして小規模なら、使わない勇気を

メインスレッド1人に背負わせていた計算を、今日はじめてチームに配りました。あなたのProfilerで一番高い山——それは「配れる仕事」ですか?