三連休
海の日だからって皆が海に行くとは思うなよ!ということで三連休は部屋に閉じこもる平常運転な休日だったのですが、連休中にやろうと思っていたCutieCircuitのiOS版のリリースはTextureを小さくしてもipaサイズの問題解消とはいかず、いよいよAssetBundleに手をだすしかないことが確定したので心折れて、現実逃避の意味も含めてちょっと違うことにチャレンジしました。
C# JobSystem
Unity 2018.1の目玉機能のひとつに「C# JobSystem」(以下 JobSystem)というものがあります。
JobSystemとは、ざっくり言うと「Unityで並列処理を可能とする機能」です。
もともとC#自体にはThread処理機能がありますが、JobSystemではUnity側が内部で使用しているWorkerThreadをC#の(ユーザ側の)スクリプト処理にも開放することで並列処理を可能としている機能なので、Unity側が使いやすいようにAPIを用意してくれていて、C#のThread処理を使うよりはロジックの実装がかなり楽ちん、らしいです。
ここら辺の話は以下のリンク先で詳しい解説が記載されています。
せっかくだから使ってみよう
ロジックの実装がかなり楽、とはいえ、幾つか決まりがあります。
JobSystemを実装する場合は、この決まりに沿って処理を構築する必要があります。
- 並列処理で処理する関数(構造体)で使用する領域(メモリ)を事前に確保する
- 入力用の領域に並列処理用関数(構造体)へ渡すデータを設定する
- Sceduleを実行して、JobSystemにジョブを発行依頼
- Completeで処理が終了まで待つ
- 処理結果を出力用の領域から取得する
- 1.で確保した領域を解放する
手順が多くて難しそうな印象を受けるかもしれませんが、一つ一つの作業はそれほど難しくありません。むしろ一回覚えれば簡単に作れてしまいます。
ただ、メモリの確保にはNativeContainerと呼ばれるコンテナを使う必要があることや、処理が終ったら必ずメモリを解放しなければいけないこと、メモリの確保の仕方が何種類かあること、などは慣れないと戸惑うかもしれませんが、最初のうちは「おまじないみたいなもの」として捉えて、とりあえず見よう見まねで実装しておけばよいです。
詳しいことは全て先ほどのリンク先に書かれているのでそちらを参考してもらうとして、このJobSystemを使って実際私が作ったものがこちら
ドーン
3つの球体がそれぞれ違う速度で自転と公転をしています。
JobSystemを使って自転と公転それぞれの回転をフレーム毎に並列で計算しています。
公転の計算と移動
各球体の回転と移動はIJobParallelForTransformを継承したジョブ実行用の構造体にて行います。
ジョブを実行すると各球体毎にIJobParallelForTransformのExecute関数が呼ばれます。
このExecute関数にはTransformAccessという引数が渡ってくるので、このTransformAccessを経由して球体を移動させます。
なので並列処理側の動作としては
- 1フレーム分の角度回転した移動量を計算する
- TransformAccessのpositionから現在位置を取得し、移動量分を加算する
- 加算した位置をTransformAccessのpositionに設定する
という流れになります。
ちなみに、IJobParallelForTransformではTransform.RotateAroundと言ったAPIを使うことができないようなので、行列計算を使い自力で公転の移動量を計算しています。
これは以下のリンクのロジックを参考にしました。
実際のソースはこんな感じ
/// <summary> /// 公転運動 /// </summary> struct RevolutionMotionUpdate : IJobParallelForTransform { public NativeArray<RotCalStruct> Accessor; // JobSystem側で実行する処理 void IJobParallelForTransform.Execute(int index, TransformAccess transform) { RotCalStruct accessor = this.Accessor[index]; // 計算結果をTransformに反映 ApplyPosition(accessor.Matrix, transform, accessor); // 結果保持
this.Accessor[index] = retAccessor; } /// <summary> /// 公転位置の設定 /// </summary> /// <param name="matrix"></param> /// <param name="transform"></param> /// <param name="data"></param> void ApplyPosition (Matrix4x4 matrix, TransformAccess transform, RotCalStruct data) { Vector3 RevOriPos = new Vector3(data.RevOri_X,data.RevOri_Y,data.RevOri_Z); Vector3 TargetPos = transform.position; // 原点位置からの差分ベクトル Vector3 TempPos = TargetPos - new Vector3(data.RevOri_X, data.RevOri_Y, data.RevOri_Z); // 行列計算による移動量算出 Vector3 CalResPos = matrix.MultiplyPoint3x4(TempPos); // 元の位置に移動量を加算して設定 transform.position = RevOriPos + CalResPos; } }
ここで、最初のほうに記述されている
public NativeArray<RotCalStruct> Accessor;
この部分が入力・出力用の領域(メモリ)となります。
RotCalStructは自前の回転計算用データで、メイン側で1フレーム毎の回転角度や公転原点を設定し、並列処理側でそれを参照する形で使用しています。
自転の計算と移動
こちらも公転と同様にIJobParallelForTransformを継承したジョブ実行用の構造体にて行います。
/// <summary> /// 自転運動 /// </summary> struct RotationMotionUpdate : IJobParallelForTransform { public NativeArray<RotCalStruct> Accessor; // JobSystem側で実行する処理 public void Execute(int index, TransformAccess transform) { RotCalStruct accessor = this.Accessor[index]; // 角度を加算更新 accessor.CurrentRot += accessor.CurrentAngle; // オブジェクトの向きを更新 transform.rotation = Quaternion.AngleAxis(accessor.CurrentRot, Vector3.up); // 更新したデータをメイン側へ返却する this.Accessor[index] = accessor; } }
こちらの場合は、Executeが呼ばれる度にCurrentRotを加算更新しメイン側へ返却、メイン側はそれを保持して次回のコール時に使用して球体を回転させています。
メイン側の処理
メイン側では前述した回転角度や公転原点を設定のほかに、TransformAccessArrayの配列を定義しています。
そこに動作対象となる3つの球体のtransformを設定することで3つの球体それぞれの並列処理(IJobParallelForTransform)が呼ばれる仕組みとなっています。
/// <summary> /// 回転計算用データ /// </summary> struct RotCalStruct { // 1フレーム毎の回転角度 public float CurrentAngle; // 現在の回転角度 public float CurrentRot; // 算出した回転情報を保持 public Matrix4x4 Matrix; // 公転原点 public float RevOri_X; public float RevOri_Y; public float RevOri_Z; public RotCalStruct(float currentAngle) { this.CurrentAngle = currentAngle; this.CurrentRot = 0f; this.Matrix = new Matrix4x4(); this.RevOri_X = 0; this.RevOri_Y = 0; this.RevOri_Z = 0; } } // Jobの終了待ち等を行うHandle JobHandle _jobRevolutionHandle; // 公転運動 JobHandle _jobRotatioHandle; // 自転運動 // Job用の回転計算用データ NativeArray<RotCalStruct> _revolutionStructs; NativeArray<RotCalStruct> _rotationStructs; // JobSystem側で実行する際に用いるTransfromの配列 TransformAccessArray _planetTransformAccessArray; void Start() { // 回転計算用データのメモリ確保 this._revolutionStructs = new NativeArray<RotCalStruct>(planetArray.Length, Allocator.Persistent); this._rotationStructs = new NativeArray<RotCalStruct>(planetArray.Length, Allocator.Persistent); for (int i = 0; i < planetArray.Length; i++) { //-------------------// // 公転用情報設定 // //-------------------// // 公転用情報生成 RotCalStruct acsess = new RotCalStruct(jobSetting[i].revolAngle); // 公転Matrixの生成 acsess.Matrix = Rotate(acsess); // 公転原点 acsess.RevOri_X = originPlanet.transform.position.x; acsess.RevOri_Y = originPlanet.transform.position.y; acsess.RevOri_Z = originPlanet.transform.position.z; // 公転用Acesserに設定 this._revolutionStructs[i] = acsess; //-------------------// // 自転用情報設定 // //-------------------// // 自転用情報生成 RotCalStruct acsess2 = new RotCalStruct(jobSetting[i].rotatAngle); // 自転軸設定 acsess2.RodAxis = (int)jobSetting[i].RotAxis; // 軸の傾き設定 acsess2.AxisAngle = jobSetting[i].RotAxisAngle; this._rotationStructs[i] = acsess2; } // IJobParallelForTransform用データ this._planetTransformAccessArray = new TransformAccessArray(planetArray.Length); this._planetTrses = new Transform[planetArray.Length]; for (int i = 0; i < planetArray.Length; ++i) { var trs = planetArray[i].transform; this._planetTransformAccessArray.Add(trs); this._planetTrses[i] = trs; } } /// <summary> /// 回転マトリックスの取得 /// </summary> /// <param name="deltaTime"></param> /// <param name="data"></param> /// <returns></returns> static Matrix4x4 Rotate(RotCalStruct data) { // マトリックスの初期化 Matrix4x4 m = Matrix4x4.identity; float x = 0f, y = 0f, z = 0f; m.SetTRS(new Vector3(x, y, z), Quaternion.identity, Vector3.one); // 原点設定 float oriX = data.RevOri_X; float oriY = data.RevOri_Y; float oriZ = data.RevOri_Z; // 回転角度 float rot = data.CurrentAngle * Mathf.Deg2Rad; float sin = Mathf.Sin(rot); float cos = Mathf.Cos(rot); // 任意の原点周りにY軸回転を行う m.m00 = cos; m.m01 = -1 * (oriX * cos) + oriX - oriZ * sin; m.m02 = sin; m.m20 = -1 * sin; m.m21 = oriX * sin + oriZ - oriZ * cos; m.m22 = cos; return m; } void Update() { RevolutionMotionUpdate revolutionJob = new RevolutionMotionUpdate() { Accessor = this._revolutionStructs, }; RotationMotionUpdate rotationJob = new RotationMotionUpdate() { Accessor = this._rotationStructs, }; this._jobRevolutionHandle.Complete(); this._jobRotatioHandle.Complete(); this._jobRevolutionHandle = revolutionJob.Schedule(this._planetTransformAccessArray); this._jobRotatioHandle = rotationJob.Schedule(this._planetTransformAccessArray); JobHandle.ScheduleBatchedJobs(); } void OnDestroy() { // 解放/破棄など this._jobRevolutionHandle.Complete(); this._jobRotatioHandle.Complete(); this._revolutionStructs.Dispose(); this._rotationStructs.Dispose(); this._planetTransformAccessArray.Dispose(); }
Update()内にて公転のJob、自転のJobをそれぞれ発行し、Completeで待ち合わせています。
各JobはScheduleで発行しますが、実際の動作開始はScheduleBatchedJobsになります。
ちなみにUpdateの先頭でCompleteを行っているのは、JOB発行⇒完了待ち(同じフレーム)、よりも JOB発行⇒次フレーム⇒完了待ち、のほうがより高速化するからです。(・・・と資料に書いてあった)
実際に作ってみて
JobSystemの実装自体は難しいものではありませんでした、どちらかといえばY軸周りの公転用マトリックスを作るのに時間が掛かったぐらいです。(久しぶりに自力で行列計算した)
JobSystemを使えば弾幕系シューティングのように大量のオブジェクトを動かすときなどに効果を発揮すると思います。
ただ、JobSystemではUnityAPが殆ど使えないので、複雑な動きをさせたい場合などは今回のように自力でロジックを組む必要がありそうで、使いどころの見極めが重要になると感じました。
最後に動かす球体を70個ぐらいに増やしたバージョンも作ったので見てやってください。
ド、ドーン