すぎしーのXRと3DCG

主にXR, Unity, 3DCG系の記事を投稿していきます。

【UniRx】UniRxのDelayFrameが初回だけ1フレーム余計に遅延する問題と解決方法

概要

UniRxでストリームを遅延させるDelayFrameを使用した場合に初回だけOnNextが1フレーム余計に遅延する現象がありました。
今回はその現象と解決方法を紹介したいと思います。

UniRxについて

github.com

Unityで使用可能なReactive Extensionsライブラリ。

Reactive Extensionsについては今回は詳しく触れませんが、
独特のクセがあり慣れない人には最初戸惑うと思いますが、使えるといろいろと捗ります!!!

DelayFrameの問題について

現象を詳しく説明すると

  • Subject.DelayFrame(N)によりNフレーム遅延するSubject(Observable)を生成
  • 上記SubjectをSubscribeする
  • Subject.OnNext(...)により値を流す
  • 初回のOnNextのみ「N+1」フレーム遅延して値が流れてくる
    • 2回目以降のOnNextは指定通り「N」フレーム遅延

現象の確認方法

以下のコードを使用してSubscribe, OnNextを意図的に発生させて確認

DelayFrame(1)で1フレーム遅延を指定しています。

f:id:tsgcpp:20200806215443j:plain

上記コードをA -> Z -> Z と入力した時の結果です。
Z入力が1021、OnNextがコールされたのが1023 と2フレームの差がついています。

問題の原因

簡単に言うと

  • UniRxは裏側でUniRx専用のDispatcher(スレッドの処理を管理するオブジェクト)を使用
  • そのDispatcher生成は遅延処理などが必要になったときに実施
  • Dispatcherの仕事は生成したフレームの次フレームから行われる

簡単に言えばこのような動きになるため、初回のみ1フレームずれます

詳細に言うと

  • 初めてDelayFrameなどの遅延処理が発生した時にMainThreadDispatcherというオブジェクトが生成される
    • Hierarchyで確認可能(DontDestroyOnLoad以下に存在)
    • アプリケーション終了まで存続
  • DelayFrameなどの遅延処理が初めて発生した時にMainThreadDispatcher.StartCoroutineがコールされる
    • UniRxのDispatcherはMonoBehaviourのコルーチンを使用して実現している
  • コルーチンはwhile(true)yield return nullRun()のループ処理を行う
    • Run()内で遅延処理、および遅延後のOnNextをコールする
    • yield return null -> Run() の順番でループする(コードは こちら)

StartCoroutineした直後にyield return nullがあり、
次のフレームから遅延処理が始まるため、
初回のみ1フレーム遅れる現象が起きます。

バグではないの?

バグではあると思います。

ですが、個人的には
「制限事項 」としてとらえたほうが良いと思います。

制限事項とする理由

これはUniRxなりの気遣いの結果、生まれてしまった問題だと思ったからです。
コードを読むと「遅延処理を必要としないならDispatcherは生成しない」という設計が見て取れます。

また、「初回だけなら気にしない」もしくは「1フレームぐらいずれてもいい」という場合にも問題にはなりません。

そして、後述しますが問題の回避が簡単です。

余談:修正できないの?

難しいと思います。

というのもUniRxという使われる側からすると初回のDelayFrameなどが「どこからコールされるかわからない」からです。

コルーチンを使っている関係で、FixedUpdate, Update, LateUPdateなど、 どこから呼ばれるかによって初回の処理のみ差異が生まれてしまいます。

だったら初回のみ特別扱いせず、1フレーム後からRun()を開始したほうがよほど合理的だと思います。

問題の回避

この問題、結構簡単に解決できます。

Dispatcherの生成を真っ先に行えば良いだけです。
以下に紹介するMonoBehaviourをオブジェクトにアタッチして初期シーンに配置すれば問題を回避できます。

方法1. MainThreadDispatcher.Initialize()をコール

見た目の通りMainThreadのDispatcherを初期化します。
コール時にStartCoroutineも実施されるため、DelayFrameする前に初期化を完了できます。

using UnityEngine;
using UniRx;

public class UniRxThreadStarter : MonoBehaviour
{
    void Start()
    {
        MainThreadDispatcher.Initialize();
    }
}

方法2. Scheduler.SetDefaultForUnity()をコール

※個人的には非推奨

これも結果としてMainThreadのDispatcherを初期化することになります。
こちらはもっと広い範囲でスレッド周りの初期化を行います。
主にUnitTestで使われているようです。

ただ、注意点としてWebGLだとおかしくなるかもしれません。
こちらのような記述があるのですが、上記メソッドだとそれに反するスレッドが使用されてしまいます。

using UnityEngine;
using UniRx;

public class UniRxThreadStarter : MonoBehaviour
{
    void Start()
    {
        Scheduler.SetDefaultForUnity();
    }
}

方法3. Scheduler, Dispatcherが初期化されるPropertyを呼び出し

以下のPropertyは初回呼び出し時にScheduler, Dispatcherが初期化されるようになっています。
予めいろいろな初期化を済ませておきたい人にはオススメのやり方かなと思います。

using UnityEngine;
using UniRx;

public class UniRxThreadStarter : MonoBehaviour
{
    void Start()
    {
        // Propertyの初期呼び出しで初期化が実施される
        var immediate = Scheduler.Immediate;
        var currentThread = Scheduler.CurrentThread;
        var mainThread = Scheduler.MainThread;
        var threadPool = Scheduler.ThreadPool;
    }
}

回避策の結果

f:id:tsgcpp:20200807210550j:plain

初回のOnNextも2回目以降と同様に指定通りの遅延になりました。

余談2:起動したコルーチンはどうなるの?

少なくともUniRx v7.1.0 時点ではアプリケーションが終了するまで残り続けます。
StopCoroutineなどコルーチンを止める処理も見当たりませんでした。

再起動がないなら割り切ってさっさと起動させてしまうのも手かなと思います。

雑感

今回はUniRxを取り上げてみました。

個人的には厳密なフレーム単位の遅延を考えるのが好きだったりするので、今回の現象は興味深くもありすごく気になる部分でもありました。

初期化処理はなんだかんだ特殊な処理が生まれるので、
UniRxにも諸々の初期化を実施してくれるメソッドがあってもいいかなと思ったりしました。

何にしろこれからもお世話になるライブラリかなと思います!(最近更新されてないのが気になりますが)

それでは~