simplestarの技術ブログ

目的を書いて、思想と試行、結果と考察、そして具体的な手段を記録します。

AIに身体性を与えるためのマイクロワールドの構築17.6:Unityでのマルチスレッド処理の話

前置き

AIに身体性をもたせるためのマイクロワールドの開発を進めていますが、マルチスレッドで処理しているため、期待と異なる結果になったり、パフォーマンスが極端に落ちたりすると、うまく原因を特定できません。
頼みのプロファイラーも抽象度の高い結果を返すため、結果的に開発者が処理を想像してデバッグすることになり、疲れるうえ、なかなか正確な原因にたどり着けません。
さらに、時間がたつと開発者自身、実装がどうなっているのか把握できなくなってくるので、このタイミングで簡単に実装内容をまとめてみようと思いました。

ゲームがフリーズしないようにするために

Unity はメインスレッドがロックされるとゲームがフリーズします。
最初、マイクロワールドで起こるすべてのブロックの処理やブロックのメッシュ作成処理などをメインスレッドが担当したのですが、もはやゲームではなくただのイラストになってしまいました。
そこで、マルチスレッド化です。

マイクロワールドで起こるすべての処理をメインスレッドとは異なる別スレッドに担当してもらうようにしました。
Unity でのマルチスレッドについての記事はこちら
simplestar-tech.hatenablog.com

ここで、Unity はメッシュの生成処理など、描画に関わる多くの処理は別スレッドから呼べない仕組みになっていることに気づかされます。そこで

WorldとUnityWorld
ChunkとUnityChunk

という、モデルとビューでそれぞれに世界とチャンク(世界の構成要素)の概念を与えました。
モデル側の World と Chunk にて UnityEngine 機能を呼ぶことを禁止することで、全てのモデル処理が別スレッドで処理できることが保証されるようになり、メインスレッドでしか処理できないような Unity 依存のコードを UnityWorld, UnityChunk に記述することで、いつかシミュレーションをUnity非依存のサーバで高速に行えるようになります。(あ、もう一つの設計方針であるモデルとビューの分離の話が混ざっちゃった、ちょっと理解するの難しいかも?)

さて、World で起こる出来事はすべてワーカースレッドが行うため、世界の更新がどんなに時間のかかる処理になったとしても、ゲームがフリーズすることは無いように思われますが、World をビジュアライズするためには、実行時に World から UnityWorld へ描画のためのメッシュ情報を渡す必要があります。さらにメッシュ生成処理もメインスレッドで行う必要があります。ここにフリーズの原因があると覚えてください。
スレッド間で同じものを参照する時に他のスレッドから更新されてはいけないオブジェクトもいくつかあります、たとえばメインスレッドで配列からメッシュを作成している最中に、参照している頂点配列とテクスチャ座標の配列のサイズが合わなくなるとクラッシュしますので、排他制御を行う必要があります。
この排他制御時のロックを非常に短くすることで、人間には観測できないロックになり、ストレスのないゲーム体験が実現できるわけですが、ここがエンジニアの腕の見せ所であり、このゲームの最も技術的に難しいテーマの一つに数えられます。
私がどうやってこの難題を解決したかを示します。

基本的な考え方は次の通り

メインスレッドにスレッドセーフキュー以外の排他制御コードを書くことを禁止する

普通はバックグラウンド処理中のロック時間を小さくすることを考えると思いますが、そうしない理由として、バックグラウンド処理が少しの間フリーズしても、ユーザーにとっては全然気にならないということが挙げられます。(ちょっとよくわからない文章になりましたが、要は裏で行われている処理はユーザーには認識できませんので、認識できないものがどんなに遅延しても、やはり遅延を認識できないということです。)
メインスレッドでロックを記述するということは、どんなに別スレッドでのロック時間を小さくしても、根本的にゲームのフリーズの可能性を取り払うことはできません。
将来にこのロックの仕組みを失念した私が、メインスレッドをフリーズさせる不具合を仕込む可能性が高いのです。
ロックを仕込まないようにする具体的な方法は、計算結果をインスタンス化して、キューに詰め、メインスレッドでキューから取り出したインスタンスを処理すれば、別スレッドで作成した計算結果が書き換わらないことが保証されますので、すべてうまくいきます。

では UnityWorld が保持しているスレッドセーフキューには、どのようなものがあるか見ていきましょう。

private ConcurrentQueue<Chunk> _encounteredChunkQueue = new ConcurrentQueue<Chunk>();
private ConcurrentQueue<Chunk> _updatedChunkQueue = new ConcurrentQueue<Chunk>();
private ConcurrentQueue<UnityChunk> _createMeshDataUnityChunkQueue = new ConcurrentQueue<UnityChunk>();
private ConcurrentQueue<UnityChunkLayer> _updateLayerMeshUnityChunkQueue = new ConcurrentQueue<UnityChunkLayer>();

まずカメラが配置されたチャンクを中心に一定距離内の視界に入る周囲チャンクをworldから _encounteredChunkQueue に詰めてもらいます。
World 側ではチャンクデータをディスクから読み出したり、WorldCreationプラグインから作ったりするため、処理に時間を要することがあり、そのまま全チャンクの準備が完了するまでロックするとやはりゲームがフリーズします。
そのため、メインスレッドを待たせないように、このスレッドセーフキューが用意されています。
チャンクデータの読み込みが完了し次第、_encounteredChunkQueue に Chunk が Enqueue されます。
その後メインスレッドで Dequeue して、チャンクオブジェクトが作成されます。
チャンクデータにアクセスする場合はロックが必要ですが、結果的にメインスレッドがロックされない仕組みになります。
その点については、チャンクデータからメッシュを作成する処理で詳しく述べます。

冒頭でも書きましたが GameObject に触る処理はメインスレッド外には書くことができません。
なのでこうして、ゲームオブジェクトを作成する処理だけをメインスレッドで処理します。
注意点として、worldはどのチャンクが表示されているかどうかのビューの情報は知ることができませんので、同じチャンクをキューに詰めてくる場合があります。
チャンクオブジェクトを重複して作成することを避けるため、作成後は辞書に登録して、辞書に載っていない場合にだけチャンクオブジェクトを作成する処理になっています。

while (_maxUpdateTime > updateProcessTime && _encounteredChunkQueue.TryDequeue(out chunk))
{
    if (!ChunkObjects.ContainsKey(chunk.ChunkKey))
    {
        GameObject chunkObject = new GameObject();
        chunkObject.name = "Chunk" + chunk.PositionX + "_" + chunk.PositionZ;
        UnityChunk unityChunk = chunkObject.AddComponent<UnityChunk>();
        unityChunk.SetChunk(chunk);
        unityChunk.SetNeedCollider(false);

        int offsetX = 0;
        int offsetZ = 0;
        Vector3 chunkObjectPosition = GetChunkObjectPosition(chunk, out offsetX, out offsetZ);
        chunkObject.transform.localPosition = chunkObjectPosition;
        chunkObject.transform.SetParent(transform, true);

        ChunkObjects.Add(chunk.ChunkKey, unityChunk);
        _createMeshDataUnityChunkQueue.Enqueue(unityChunk);
    }
    updateProcessTime = Time.realtimeSinceStartup - updateStartTime;
}

GetChunkObjectPosition で返ってくる位置は世界の循環を見越して計算されるもので、現在のカメラのチャンクから数えてどれくらいのオフセットが付くのか計算して返します。
オフセット計算は基本的には単なるチャンクインデックスの差の移動量を返しますが、あまりにも差が大きすぎる場合は、一周分のオフセットを足したり、引いたりした折り返しの移動量が返ってきます。
なので、世界の半分など、あまりにも遠くまで見渡せるようにしてしまうとこの循環配置システムは破綻します。つまりあまりにも狭すぎる世界は定義できないということです。
それなりに大きな世界ならば、現在のカメラのチャンクの位置を中心に計算するので、何度もループすることが可能であり、一見して果てのない世界を表現できます。

同じところに何度もループして戻ってくることを確認する動画
www.youtube.com

チャンクオブジェクトを作成し、そのチャンクオブジェクトに UnityChunk スクリプトコンポーネントを追加したら、今度はその UnityChunk コンポーネントをスレッドセーフキューの一つ _createMeshDataUnityChunkQueue へ詰めます。
このキューの中身の消化についてはメッシュ作成の段階で詳しく述べます。

次に _updatedChunkQueue について説明します。
ここにはワーカースレッドにて更新処理を終えたチャンクが詰め込まれます。
キューの中身の処理方法は基本的に変わらず、時間に余裕があればメッシュ作成のためのスレッドセーフキューに UnityChunk スクリプトコンポーネントを詰めますが、周囲の視界に入った場合の処理と違って、すでにチャンクオブジェクトが生成されている場合のみメッシュ更新処理をするようになっています。
ロジックによってブロック情報が書き換えられたのなら、ビューに反映されるべきですが、見えないところの更新については無視するというロジックです。
単にメッシュの更新が目的なので、チャンクオブジェクトが無い場合はスキップします。

while (_maxUpdateTime > updateProcessTime && _updatedChunkQueue.TryDequeue(out chunk))
{
    if (ChunkObjects.ContainsKey(chunk.ChunkKey))
    {
        UnityChunk unityChunk = ChunkObjects[chunk.ChunkKey];
        _createMeshDataUnityChunkQueue.Enqueue(unityChunk);
    }
    updateProcessTime = Time.realtimeSinceStartup - updateStartTime;
}

ここまで見てきた通り、_createMeshDataUnityChunkQueue にはチャンクオブジェクトに配置するメッシュ作成のためのソースとして UnitytChunk スクリプトコンポーネントが詰め込まれています。
それを次のようにバックグラウンド処理にて Dequeue して、メッシュデータの作成をメインスレッドに影響を与えないようにして作成します。
重要なことなので念を押しますが、ゲームオブジェクトを作成する処理をメインスレッドで行った後、再度メッシュデータの作成処理はメインスレッド以外で行うということです。
つまり、UnityChunk 側の実装でありながらも、この処理はどんなに時間を要してもゲームがフリーズしないということです。

private Action _CreateLayerMeshTask = () =>
{
    UnityChunk unityChunk = null;
    while (_meshCreaeteLoop)
    {
        if (Instance._createMeshDataUnityChunkQueue.TryDequeue(out unityChunk))
            unityChunk?.CreateLayerMeshData();
    }
    Debug.Log("_CreateLayerMeshTask finished");
};

メインスレッドとは別のワーカースレッドにて実行されますので、このCreateLayerMeshData関数を処理中にメインスレッドで UnityChunk がリムーブされたりアタッチ先のチャンクオブジェクトが削除された場合は、結果の反映先のオブジェクトが無いという困った状態になります。
幸い Unity はこの状況でクラッシュするにはならず、スクリプトコンポーネント自身が null となるだけですので、処理中に this == null が成立したらメッシュデータ作成処理をスキップする、という処理で対処します。
メッシュデータの作成が完了したら、成果物としての各種配列が詰まったインスタンスをスレッドセーフキュー _updateLayerMeshUnityChunkQueue に詰めます。

最後にメインスレッド側で、このキューから取り出したメッシュデータから描画用メッシュを作成して完了です。
計算結果のインスタンスはバックグラウンド処理から切り離されていますので、メインスレッドで安心してロックフリー参照してメッシュ生成を行えます。

private float _UpdateChunkLayerMesh(float updateProcessTime, float updateStartTime)
{
    UnityChunkLayer unityChunkLayer = null;
    while (_maxUpdateTime > updateProcessTime && _updateLayerMeshUnityChunkQueue.TryDequeue(out unityChunkLayer))
    {
        unityChunkLayer?.unityChunk.UpdateLayerMesh(unityChunkLayer.layerIndex, unityChunkLayer.meshSource);
        updateProcessTime = Time.realtimeSinceStartup - updateStartTime;
    }
    return updateProcessTime;
}
まとめ

スレッドセーフキューからDequeする処理以外で、メインスレッドがロックされる場面は存在しませんので、どんなに計算量の多い場面でもゲームのフレームレートが落ちないことが保証されます。
スレッドセーフキューに何も詰まっていなくても処理にそれなりの時間を要しますが、プロファイラーでの数値は約 0.63 ms ほどでした。
描画のために十分処理時間が余りますので、ひとまずはこの仕組みで作業していきたいと思います。

f:id:simplestar_tech:20180121134639j:plain