したかみ ぶろぐ

Unity成分多め

【Unity】JobSystemのサンプル作成と計測

始めに

並列処理が出来るJobSystemのサンプルを作成して、どれほどの性能なのかを計ってみようと思います。

何も考えずにサンプルを作って動作確認しているので、JobSystemについての詳細については一切ふれません。

もし、JobSystemについて詳しく知りたい方はUnity公式の動画がおすすめです。


www.youtube.com


www.youtube.com


作成するサンプル

指定された立方体内を反射し続けるCubeを大量に作って、以下の環境でどれだけの性能が出るかを計測しました。

  • 愚直な実装 + MeshRenderer
  • JobSystem + Burst + MeshRenderer
  • JobSystem + Burst + RenderMeshInstanced

また、負荷を高めるために O(n2) の計算を加えています。

ここで載せるコードは雑に書いたものになるので、参考程度にして下さい。

動作環境

  • Core i7 8700K
  • Unity 2021.3.8f1 URP


愚直な実装 + MeshRenderer

MonobehaviorのUpdateにそのままCubeの移動処理を書きます。

CubeMove.cs

using System.Collections.Generic;
using UnityEngine;

public class CubeMove : MonoBehaviour
{
    [SerializeField] private int _instanceCount;
    [SerializeField] private GameObject _prefab;
    [SerializeField] private float _range;

    [SerializeField] private float _velocity;
    
    private readonly List<ObjData> _objDatas = new();
    private readonly List<GameObject> _gameObjects = new();
    
    private class ObjData
    {
        public Vector3 Position;
        public Vector3 Velocity;
    }

    private void Start()
    {
        for (int i = 0; i < _instanceCount; ++i)
        {
            var position = RandomPositionInRange();
            var instance = Instantiate(_prefab, position, Quaternion.identity);
            _objDatas.Add(new ObjData { Position = position, Velocity = RandomDirectionVelocity() } );
            _gameObjects.Add(instance);
        }
    }

    private void Update()
    {
        UpdateObjects();
    }

    private void UpdateObjects()
    {
        for (int i = 0; i < _instanceCount; ++i)
        {
            SumVelocity(); // MEMO: わざと負荷を上げる
            
            var obj = _objDatas[i];

            var deltaTime = Time.deltaTime;
            var diff = obj.Velocity * deltaTime;
            
            if (Mathf.Abs(obj.Position.x + diff.x) >= _range) obj.Velocity.x *= -1;
            if (Mathf.Abs(obj.Position.y + diff.y) >= _range) obj.Velocity.y *= -1;
            if (Mathf.Abs(obj.Position.z + diff.z) >= _range) obj.Velocity.z *= -1;

            obj.Position += obj.Velocity * deltaTime;

            _gameObjects[i].transform.position = obj.Position;
        }
    }

    private void SumVelocity()
    {
        var sumVelocity = Vector3.zero;
        var sumPosition = Vector3.zero;

        for (int i = 0; i < _objDatas.Count; ++i)
        {
            sumVelocity += _objDatas[i].Velocity;
            sumPosition += _objDatas[i].Position;
        }
    }
    
    private Vector3 RandomPositionInRange()
    {
        return new Vector3(
            Random.Range(-_range, _range),
            Random.Range(-_range, _range),
            Random.Range(-_range, _range)
        );
    }

    private Vector3 RandomDirectionVelocity()
    {
        return Random.onUnitSphere * _velocity;
    }
}


こちらの実行結果が次のようになります。

cube数が100個の場合は約100fps、1000個の場合は約13fpsになります。

n = 100 n = 1000


JobSystem + Burst + MeshRenderer

次にJobSystemとBurstを使ってサンプルを作ってみます。

CubeMoveByJobSystem.cs

using System.Collections.Generic;
using System.Linq;
using Unity.Burst;
using Unity.Collections;
using UnityEngine;
using UnityEngine.Jobs;
using Random = UnityEngine.Random;

public class CubeMoveByJobSystem : MonoBehaviour
{
    [SerializeField] private int _instanceCount;
    [SerializeField] private GameObject _prefab;
    [SerializeField] private float _range;

    [SerializeField] private float _velocity;
    
    private ObjData[] _objDatas;
    private readonly List<GameObject> _gameObjects = new();
    private TransformAccessArray _transformAccessArray;

    private struct ObjData
    {
        public Vector3 Position;
        public Vector3 Velocity;
    }

    private void Start()
    {
        _objDatas = new ObjData[_instanceCount];

        var objDataList = new List<ObjData>();
        for (int i = 0; i < _instanceCount; ++i)
        {
            var position = RandomPositionInRange();
            var instance = Instantiate(_prefab, position, Quaternion.identity);
            objDataList.Add(new ObjData { Position = position, Velocity = RandomDirectionVelocity() } );
            _gameObjects.Add(instance);
        }

        _objDatas = objDataList.ToArray();
        _transformAccessArray = new TransformAccessArray(_gameObjects.Select(obj => obj.transform).ToArray());
    }

    private void Update()
    {
        UpdateByJobSystem();
    }

    private void UpdateByJobSystem()
    {
        var objDataArray = new NativeArray<ObjData>(_instanceCount, Allocator.TempJob);
        var objDataReadArray = new NativeArray<ObjData>(_instanceCount, Allocator.TempJob);
        
        objDataArray.CopyFrom(_objDatas);
        objDataReadArray.CopyFrom(_objDatas);
        
        var job = new MoveObjectJob
        {
            objDatas = objDataArray,
            objDatasRead = objDataReadArray,
            range = _range,
            deltaTime = Time.deltaTime
        };

        var handler = job.Schedule(_transformAccessArray);
        handler.Complete();
        
        objDataArray.CopyTo(_objDatas);

        objDataArray.Dispose();
        objDataReadArray.Dispose();
    }

    private Vector3 RandomPositionInRange()
    {
        return new Vector3(
            Random.Range(-_range, _range),
            Random.Range(-_range, _range),
            Random.Range(-_range, _range)
        );
    }

    private Vector3 RandomDirectionVelocity()
    {
        return Random.onUnitSphere * _velocity;
    }

    private void OnDestroy()
    {
        _transformAccessArray.Dispose();
    }

    [BurstCompile]
    private struct MoveObjectJob : IJobParallelForTransform
    {
        public NativeArray<ObjData> objDatas;
        [ReadOnly] public NativeArray<ObjData> objDatasRead;
        [ReadOnly] public float range;
        [ReadOnly] public float deltaTime;

        public void Execute(int index, TransformAccess transform)
        {
            SumVelocity(); // MEMO: わざと負荷を上げる
            
            var obj = objDatas[index];

            var diff = obj.Velocity * deltaTime;
            
            if (Mathf.Abs(obj.Position.x + diff.x) >= range) obj.Velocity.x *= -1;
            if (Mathf.Abs(obj.Position.y + diff.y) >= range) obj.Velocity.y *= -1;
            if (Mathf.Abs(obj.Position.z + diff.z) >= range) obj.Velocity.z *= -1;

            obj.Position += obj.Velocity * deltaTime;
            objDatas[index] = obj;

            transform.localPosition = obj.Position;
        }
        
        private void SumVelocity()
        {
            var sumVelocity = Vector3.zero;
            var sumPosition = Vector3.zero;

            for (int i = 0; i < objDatasRead.Length; ++i)
            {
                sumVelocity += objDatasRead[i].Velocity;
                sumPosition += objDatasRead[i].Position;
            }
        }
    }
}


こちらの実行結果は次のようになります。

n = 1000の場合は安定して80fps近く出せてますが、n = 5000で40fps行かないぐらい、n = 10000で約15fpsまで落ちました。

n = 1000 n = 5000 n = 10000


JobSystem + Burst + RenderMeshInstanced

先程のサンプルの処理速度を見るに、計算処理ではなく描画処理がボトルネックになっているとわかりました。(計算負荷を上げている個所をコメントアウトしてもフレームレートが変わらない)

なので、MeshRendererではなく、Graphics.RenderMeshInstancedを使って描画をしてみます。( Unity - Scripting API: Graphics.RenderMeshInstanced

CubeMoveByJobySystemAndGpuInstancing.cs

using System.Collections.Generic;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;
using UnityEngine.Rendering;
using Random = UnityEngine.Random;

public class CubeMoveByJobySystemAndGpuInstancing : MonoBehaviour
{
    [SerializeField] private int _instanceCount;
    [SerializeField] private float _range;
    [SerializeField] private Mesh _mesh;
    [SerializeField] private Material _material;

    [SerializeField] private float _velocity;
    
    private ObjData[] _objDatas;
    private RenderParams _renderParams;

    private const int InstanceCountPerDraw = 1023;

    private struct ObjData
    {
        public Vector3 Position;
        public Vector3 Velocity;
    }

    private void Start()
    {
        _objDatas = new ObjData[_instanceCount];

        var objDataList = new List<ObjData>();
        for (int i = 0; i < _instanceCount; ++i)
        {
            var position = RandomPositionInRange();
            objDataList.Add(new ObjData { Position = position, Velocity = RandomDirectionVelocity() } );
        }

        _objDatas = objDataList.ToArray();
        _renderParams = new RenderParams(_material) { receiveShadows = true, shadowCastingMode = ShadowCastingMode.On };
    }

    private void Update()
    {
        UpdateByJobSystem();
    }

    private void UpdateByJobSystem()
    {
        var objDataArray = new NativeArray<ObjData>(_instanceCount, Allocator.TempJob);
        var objDataReadArray = new NativeArray<ObjData>(_instanceCount, Allocator.TempJob);
        var objMatrix = new NativeArray<Matrix4x4>(_instanceCount, Allocator.TempJob);

        objDataArray.CopyFrom(_objDatas);
        objDataReadArray.CopyFrom(_objDatas);

        var job = new MoveObjectJob
        {
            objDatas = objDataArray,
            objDatasRead = objDataReadArray,
            objMatrix = objMatrix,
            range = _range,
            deltaTime = Time.deltaTime
        };

        var handler = job.Schedule(_instanceCount, 0);
        handler.Complete();
        
        objDataArray.CopyTo(_objDatas);

        DrawAll(objMatrix);
        
        objDataArray.Dispose();
        objDataReadArray.Dispose();
        objMatrix.Dispose();
    }

    private Vector3 RandomPositionInRange()
    {
        return new Vector3(
            Random.Range(-_range, _range),
            Random.Range(-_range, _range),
            Random.Range(-_range, _range)
        );
    }

    private Vector3 RandomDirectionVelocity()
    {
        return Random.onUnitSphere * _velocity;
    }

    private void DrawAll(NativeArray<Matrix4x4> matricesArray)
    {
        for (int i = 0; i < _instanceCount; i += InstanceCountPerDraw)
        {
            var length = Mathf.Min(InstanceCountPerDraw, _instanceCount - i);
            Graphics.RenderMeshInstanced(_renderParams, _mesh, 0, matricesArray, length, i);
        }
    }

    [BurstCompile]
    private struct MoveObjectJob : IJobParallelFor
    {
        public NativeArray<ObjData> objDatas;
        [ReadOnly] public NativeArray<ObjData> objDatasRead;
        [ReadOnly] public float range;
        [ReadOnly] public float deltaTime;
        [WriteOnly] public NativeArray<Matrix4x4> objMatrix;

        public void Execute(int index)
        {
            SumVelocity(); // MEMO: わざと負荷を上げる
            
            var obj = objDatas[index];

            var diff = obj.Velocity * deltaTime;
            
            if (Mathf.Abs(obj.Position.x + diff.x) >= range) obj.Velocity.x *= -1;
            if (Mathf.Abs(obj.Position.y + diff.y) >= range) obj.Velocity.y *= -1;
            if (Mathf.Abs(obj.Position.z + diff.z) >= range) obj.Velocity.z *= -1;

            obj.Position += obj.Velocity * deltaTime;
            objDatas[index] = obj;

            objMatrix[index] = Matrix4x4.TRS(obj.Position, Quaternion.identity, Vector3.one);
        }
        
        private void SumVelocity()
        {
            var sumVelocity = Vector3.zero;
            var sumPosition = Vector3.zero;

            for (int i = 0; i < objDatasRead.Length; ++i)
            {
                sumVelocity += objDatasRead[i].Velocity;
                sumPosition += objDatasRead[i].Position;
            }
        }
    }
}


実行結果は次のとおりです。(マテリアルを設定する必要があるので、Cubeの色が変わっています)

n = 5000 n = 10000 n = 20000

n = 5000では安定して約90fps、n = 10000でも安定して50fps以上でました。

ただし、n = 20000まで来ると約20fpsになります。


計測結果 まとめ

n = 1000 n = 5000 n = 10000
愚直な実装 + MeshRenderer 13fps - -
JobSystem + Burst + MeshRenderer 80fps 40fps 15fps
JobSystem + Burst + RenderMeshInstanced 140fps 90fps 50fps



まとめ・感想

JobSystemで簡単なサンプルを作ってみましたが、とても取っつきやすい印象でした。実行速度もなかなか良い結果が出たので実用レベルと感じてみます。

また、Graphics.RenderMeshInstancedはNativeArrayをそのまま渡せるので、JobSystemと相性が良いんじゃないかなと思います。

 Graphics.RenderMeshInstanced(_renderParams, _mesh, 0, matricesArray);


計測結果から、極限のパフォーマンスを目指すとき以外はJobSystemでも事足りると感じています。(JobSystemを使うときは極限のパフォーマンスを目指すときかもしれませんが....)

ComputeShaderと比べて、学習コストの低さやC#でコードが書けること、Profilerで実行速度を観測できる点でJobSystemの方が圧倒的に扱いやすいです。


参考

shibuya24.info

shibuya24.info


www.youtube.com