logo
Published on

【VRChat】Udonでマルチスレッド処理を行う

Authors

今回は Udon でのマルチスレッド処理について。
UdonSharp をそこそこ触ることのできる方向けの記事になります。

はじめに

VRChat のワールド開発では、Udon スクリプトによって様々な処理を行うことができます。

UdonSharp を使用すると Unity の C# スクリプトのようにコードを書くことができますが、
コードを書いていくにあたって意識しなければならない点が存在します。

それは、Udon は C# と比較して実行速度がとてもとても遅い1 ということ。
非常に大雑把でかつ正確性のない例えですが、4 GHz の CPU が Udon を実行している時だけ 25 MHz で動作するような感じでしょうか。

C# スクリプトではパフォーマンスに大きな影響のない処理も、Udon で実行すると激しく処理落ちしてしまうこととなります。

ヘッドマウントディスプレイを被り VR でプレイすることのできる VRChat において、
処理落ちやスタッタリングの発生は不快に感じるだけでなく酔い (Motion Sickness) の原因にもなりやすいため、
非 VR のゲームよりパフォーマンスには注意を払う必要があります。

ユーザーの快適性のためには積極的なパフォーマンス改善を行う必要がありますが、
どういった方法によってパフォーマンスの改善を行っていくのかを下記にいくつか書いてみます。
なお、これらの対応によってコードが複雑になり、保守性の低下やバグの発生に繋がることもあります。
パフォーマンス改善のためなら致し方ない、と思える範囲で対応するのが良いかもしれません。

  1. コードを最適化する (実行速度が速いコードを書く)
  2. 直ちに実行される必要のない処理は複数フレームに分ける
  3. GPU を活用する
  4. マルチスレッドを使用する

1. コードを最適化する

Udon は遅いため、少しでも高速に動作するコードを書く必要があります。
(例えば、配列のサイズや同じ値となる計算結果をキャッシュするなど)

このページ なども参考になります。

2. 複数フレームに処理を分割する

そのフレームで直ちに実行される必要のない処理については、
複数のフレームに分けて処理を行うことでスタッタリングを軽減させることが可能です。

ただそれでも、Udon の処理は Main Thread で実行される関係上、
CPU バウンドのときにはフレームレートが低下してしまうこととなります。

フレームレートの低下量を抑えるには少し(数ミリ秒)ずつ処理をさせるとよいですが、
少しづつの処理にさせるほど処理完了までの時間は伸びてしまう点に注意が必要です。

(UdonProfilerのような方法で CPU のヘッドルームを推測することで、より効率的に Main Thread を動かすこともできそうです。)

また、Udon ではイベントの実行開始から10秒以内に処理を終了しないと例外が出て停止してしまうため、
動作環境とするコンピューター2 において10秒以上かかる処理を行いたい場合は、スタッタリングの発生を気にしない場合においてもこの手法を使わざるを得ません。

3. GPU を活用する

グラフィックの描画に使われている GPU ですが、これを計算に活用することもできます。

ただし、GPU が得意なことは並列処理です。
そうでない処理は苦手なため、一部の処理でしか活用することができませんが、
GPU で実行されるコードは Udon のパフォーマンス低下を受けないことも相まって、
GPU での実行に適している並列処理に関しては大きなパフォーマンス向上が見込めるかもしれません。
(私はこの手法を活用できておりません…)

Unity では Compute Shader を使用して GPGPU を行うことができますが、
VRChat では Compute Shader を使用することはできず、RenderTexture を用いた複雑な実装が必要です。

また、GPU で計算した結果を Udon で扱うといった場合には、結局 Udon 側がボトルネックとなってしまうかもしれません。
(計算結果が格納されている Color32[]byte[] に変換して読み出したい場合、64 KB を得るのに 50 ミリ秒ほど必要となります。)

4. マルチスレッドを使用する

Unity の C# スクリプトで使えるような様々なマルチスレッドの手法は使用することができないのですが、
少々無理やりな方法を用いるとマルチスレッド処理を実現することができます。

この方法では Main Thread から処理をオフロードすることができるため、処理中のフレームレート低下を抑える (無くす) ことができます。

詳細については以下に記載します。

Udon でマルチスレッドをするには

Udon にてマルチスレッド処理を行う場合、Audio Thread で実行されるイベントである OnAudioFilterRead() を活用します。

ここで、簡単な実装の例を記載してみます。

この実装例では、Audio Thread で実行する側と、それを呼び出す側の2つのクラスが必要です。

Audio Thread にて実行するスクリプト (ここでは、Worker とします) は下記のように書きます。

[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class SomeWorker : UdonSharpBehaviour
{
    [NonSerialized]
    public bool[] IsRunning = new bool[1];

    private System.Diagnostics.Stopwatch _stopwatch = new System.Diagnostics.Stopwatch();

    public void _Initialize()
    {
        // 実行前の処理をここに書く

        IsRunning[0] = true;
    }

    // Audio Thread
    public void _onAudioFilterRead()
    {
        OnAudioFilterRead(null, 0);
    }

    private void OnAudioFilterRead(float[] _, int __)
    {
        if (IsRunning[0])
        {
            _stopwatch.Restart();

            bool condition = true;
            while (condition)
            {
                // ここに時間のかかるループ処理を書く
                // ループ処理の全体ではなく一部だけを実行し、定期的に (数ミリ秒ごとが望ましい) 経過時間の確認をすること

                // 一定時間経過したら中断
                if (_stopwatch.ElapsedMilliseconds >= 17)
                {
                    condition = false;
                }
            }
            // ループ処理が完了したとき
            if (isCompleted)
            {
                // ループ内で使用した配列の開放など

                IsRunning[0] = false;
            }
        }
    }
}
この Worker スクリプトを付けた GameObject は、初期状態で無効にしてください。
また、下記のような設定の Audio Source Component を追加してください。 Audio Source

そして、Worker を呼び出す側は下記のように書きます。

[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class SomeScript : UdonSharpBehaviour
{
    [SerializeField]
    private SomeWorker _worker;

    private bool[] _isWorkerRunning = new bool[1];

    private void WorkerExecute()
    {
        if (_worker.enabled)
        {
            // Worker へ必要なデータを渡したりする

            _worker._Initialize();
            _isWorkerRunning = _worker.IsRunning;

            // GameObject (または AudioSource) を有効化して OnAudioFilterRead() が実行されるようにする
            // 実行中は Worker に触らないこと
            _worker.gameObject.SetActive(true);
            SendCustomEventDelayedFrames(nameof(_CheckWorkerState), 4);
        }
    }

    // Worker の実行終了を監視する
    public void _CheckWorkerState()
    {
        if (_isWorkerRunning[0])
        {
            SendCustomEventDelayedFrames(nameof(_CheckWorkerState), 4);
        }
        else
        {
            // 処理終了後 は OnAudioFilterRead() が実行されないようにする
            _worker.gameObject.SetActive(false);
            _OnWorkerComplete();
        }
    }

    public void _OnWorkerComplete()
    {
        // Worker からデータを読み出したりする
    }
}

⚠️注意

Audio Thread を使用するにあたっての注意点を記載します。

Worker には他イベントを書かない

この Worker には Update() や、Start() 等の イベント を書かないようにしましょう。
Audio Thread で OnAudioFilterRead() が実行されている時、 Main Thread で何らかのイベントが実行されると UdonVM が壊れます。

下記のような空のイベントも書いてはいけません。
(Audio Thread でなくとも空のイベントを残すと無意味な処理負荷が発生するため書かないように…)

    private void Update()
    {
    }

実行中は Worker に触らない

Worker の GameObject か AudioSource Component を有効化して OnAudioFilterRead() が呼ばれる状態のときは、 Worker のメソッドを呼んではいけません。
上記と同じように壊れます。

実行中に Worker とデータのやり取りを行いたい場合は、配列の参照経由で読み書きするとよいと思います。

Editor で再生中は Inspector タブで UdonSharpBehaviour Component が付いた GameObject を選択しない

同じく、壊れます。

Audio Thread を長時間専有しない

Audio Thread は本来オーディオ処理のためのスレッドで、音声出力のために一定の更新頻度でオーディオ波形の合成などを行っています。

ここで理解が必要になる、一定の更新頻度 というものを解説していきます。

分かりやすいよう、まずはディスプレイを例に出してみます。
一般的なディスプレイは1秒間に 60 回、16.67 ミリ秒ごとに画面の更新が行われています。
垂直同期が有効のとき、画面の更新に合わせてゲームロジックを動作させたりフレームバッファを更新するように、
オーディオも出力される音声波形を処理をしやすいよう一定の間隔に区切った上で、それに合わせた更新が行われています。
その更新間隔はアプリケーションや環境によって異なりますが、VRChat での更新間隔がどうなっているかを調べていきます。

VRChat の PC (StandaloneWindows) 版では、下記設定でオーディオ処理が行われます。

  • System Sample Rate: 48000 Hz
  • チャンネル数: 2
  • DSP Buffer Size (一度に処理するオーディオのサンプル数): 2048

PC 版では、音声再生デバイスの形式が 1ch 8000Hz であろうと、2ch 192000Hz であろうと、VRChat が出力するオーディオは 2ch 48000Hz 固定となります。
(Android 版は未検証で、これと異なる可能性が大いにありますのでご注意ください。また、PC 版の値も将来的に変更される可能性があることにご注意ください。)

これらの値から計算をすると、Audio Thread が 1秒間に何回処理されるか および、その 処理の間隔 を求めることができます。

1÷(48000×2÷2048)=1÷46.875=0.0213˙\begin{align*} 1 \div (48000 \times 2 \div 2048) &= 1 \div 46.875 \\ &= 0.021\dot{3} \end{align*}

上記より、1秒間に 46.875 回、21.33 ミリ秒ごとに処理されることが分かります。

OnAudioFilterRead() を使うことによって Audio Thread で任意の処理を実行できますが、
この処理の最中は他のオーディオ処理が行われない状態となります。

他のオーディオ処理が行われないまま、先ほど求めた 21.33 ミリ秒ごとの次の更新タイミングを迎えてしまうと、
ワールドで再生している BGM やボイスチャットを含む VRChat のすべての音声出力が途切れてしまいます。

もし、ユーザーにロード画面を見せており、BGM を再生しておらず、ボイスチャットも聞こえない状態にしているのであれば音声が途切れたところで実害はないかもしれませんが3、そうでない場合に音声の途切れが発生することは望ましくないものです。

この実装例では、Audio Thread の全処理のうち一定の時間 (21.33 ミリ秒のうち 17~ミリ秒、約 80%) をマルチスレッド処理に割り当てています。
この場合、オーディオ処理が低負荷な時は問題なく音声が再生されますが、再生されている音声が多い時や CPU の性能によっては 残りの 20% の時間を使い切ってしまい音声の途切れが発生することがありますのでご注意ください。

(上手くやれば、Audio Thread の処理タイミングを監視してマルチスレッド処理の割当時間を動的に設定することができるかもしれません。)

Main Thread 以外では行えない処理に注意

UnityEngine のクラスの中などには、Main Thread でのみ実行可能な処理が存在します。 Audio Thread にて該当する処理を実行しようとすると、下記の例外が発生してしまいます。

<メソッド名> can only be called from the main thread.

これらの処理については実行に必要になるデータを保持しておき、
Audio Thread の処理の終了後に Main Thread にて行うようにしましょう。

Audio Thread の動作に注意

たとえば、デバイスの状態に起因して Audio Thread の動作が停止してしまったり、
AudioSource からの距離が離れる、他には同時発音数の超過などによって OnAudioFilterRead() が呼ばれなくなったりするかもしれません。
そういった場合にも処理が続行できるように考慮をしておく必要がありそうです。

また、将来のアップデートでこの方式が壊れる可能性も考えられます。
そのような場合には、OnAudioFilterRead() で実行していたループ処理の内容を Main Thread から代わりにメソッドを呼び出して実行する形に変更することでパフォーマンスは低下するものの動作を維持することができます。

その他の実装例

  • UdonTask (1.1.1)
    • 長所
      • 引数を渡せたりと使いやすく、コードが書きやすい
    • 短所
      • 非同期処理開始時に Main Thread で GameObject の Clone を作成するオーバーヘッドがある
      • 音声の途切れを発生させない使い方には向いていない (処理を小分けにする場合、「Main Thread で非同期処理作成」 ⇒ 「Audio Thread から一定時間で帰還」 を繰り返すが、スレッド間のメソッド呼び出しの遅延により Audio Thread を使用していない時間が発生する)
      • 数百回に一回、処理が停止する? (Editor で Inspector タブを表示していることが原因の可能性もあり)

さいごに

この Audio Thread を用いたマルチスレッド方法で、Main Thread と合わせて2スレッド分の CPU パワーを扱うことができます。

2つのスレッドの実行タイミングは同期していない関係上、スレッド間のデータのやり取りには遅延が発生してしまうため低遅延が求められる処理には向きませんが、 フレームレート低下の抑制や並列処理による処理速度の倍速化に役立つこの方法を活かしていきたいところです。

Footnotes

  1. Random Tips & Performance Pointers には、200倍から1000倍遅いと記載。
    Udon Benchmarking and performance tests の2つのテストでは、C# と比較して 604倍, 3629倍遅い結果。

  2. もしお使いの PC では実行時間が2秒だとしても、他の PC では実行に10秒以上かかり実行が停止してしまうこともあり得ます。

  3. ただし、Audio Thread でも Udon の 10秒制限は健在のため、経過時間の確認をせずに処理を続けるのは危険です。