Edited at

UniRx入門 その4 -Updateをストリームに変換する方法とメリット-

UniRx入門シリーズ 目次はこちら



0.前回のおさらい

前回はストリームの構築方法をいくつか紹介しました。今回はもっと実用性の高い「Updateを変換する方法」に焦点を絞って説明したいと思います。

この記事で触れる内容は過去に書いた 【UniRx】Update()をObservableに変換する方法 について書き直した内容になります。


1.Update()をストリームに変換する方法

UnityのUpdate()コールをストリームに変換する方法は2つかあります。


  • UniRx.TriggersのUpdateAsObservableを利用する

  • Observable.EveryUpdateを利用する

上記2つは使い方や動作自体は似ていますが、内部実装が大きく異なっています。まずはそれぞれの使い方と仕組みを解説したいと思います。


UniRx.TriggersのUpdateAsObservableを利用する


使い方

呼び出し方



  1. using UniRx.Triggers; を追加


  2. this.UpdateAsObservable() を呼び出す

発行される型

Unit


UpdateAsObservable

using UnityEngine;

using UniRx;
using UniRx.Triggers; //このusingが必要

public class UpdateSample : MonoBehaviour
{
void Start()
{
// UpdateAsObservableはComponentに対する
// 拡張メソッドとして定義されているため、呼び出す際は
// "this."が必要
this.UpdateAsObservable()
.Subscribe(_ => Debug.Log("Update!"));
}
}


以上でUpdateイベントをUniRxのストリームに変換して利用することができます。

また、 UpdateAsObservable はGameObjectが破棄された際に自動的にOnCompletedが発行されるためストリームの寿命管理がしやすくなっています。


GameObjectが破棄されるとOnCompletedが発行される

using UnityEngine;

using UniRx;
using UniRx.Triggers;

public class UpdateSample : MonoBehaviour
{
void Start()
{
this.UpdateAsObservable()
.Subscribe(
_ => Debug.Log("Update!"), //OnNext
() => Debug.Log("OnCompleted") //OnCompleted
);

// OnDestoryを受けてログに出す
this.OnDestroyAsObservable()
.Subscribe(_ => Debug.Log("Destroy!"));

// 1秒後に破棄
Destroy(gameObject, 1.0f);
}
}




(Destory時にOnCompletedが発行されている)


仕組み

UpdateAsObservableObservableUpdateTriggerコンポーネントに実体を持つストリームです。

UpdateAsObservableを呼び出したタイミングで該当のGameObjectに対してObservableUpdateTriggerコンポーネントをUniRxが自動的にアタッチし、このObservableUpdateTriggerが発行するイベントを利用するという仕組みになっています。


UpdateAsbservableはObservableUpdateTriggerを初期化する拡張メソッド

public static IObservable<Unit> UpdateAsObservable(this Component component)

{
if (component == null || component.gameObject == null) return Observable.Empty<Unit>();
return GetOrAddComponent<ObservableUpdateTrigger>(component.gameObject).UpdateAsObservable();
}


UpdateAsObservableの本体

using System; // require keep for Windows Universal App

using UnityEngine;

namespace UniRx.Triggers
{
[DisallowMultipleComponent]
public class ObservableUpdateTrigger : ObservableTriggerBase
{
Subject<Unit> update;

/// <summary>Update is called every frame, if the MonoBehaviour is enabled.</summary>
void Update()
{
if (update != null) update.OnNext(Unit.Default);
}

/// <summary>Update is called every frame, if the MonoBehaviour is enabled.</summary>
public IObservable<Unit> UpdateAsObservable()
{
return update ?? (update = new Subject<Unit>());
}

protected override void RaiseOnCompletedOnDestroy()
{
if (update != null)
{
update.OnCompleted();
}
}
}
}


(コードはこちらからの引用)

このように、UpdateAsObservableは呼び出すことでObservableUpdateTriggerコンポーネントをGameObjectに貼り付け、ObservableUpdateTriggerで実行されるUpdate()を内部に持つSubjectを使ってただイベントとして発行しているだけの単純な構造になっています。

ここで注意して頂きたい点は次の2つです。


  • ObservableUpdateTriggerという謎のコンポーネントが突然増えていても、それが正常動作なので削除してはいけない

  • 1個のGameObjectごとに1個のObservableUpdateTriggerを共有して利用するため、UpdateAsObservable自体を大量にSubscribeしてもさほどコストになることになることはない

特にコンポーネントが突然増えていても、それが正常動作なので削除してはいけないという点だけ覚えておけばよいかと思います。


Observable.EveryUpdateを利用する


使い方

呼び出し方



  1. Observable.EveryUpdate() を直接Subscribeする

発行される型

long (Subscribeしてからの経過フレーム数)


Observable.EveryUpdate

using UnityEngine;

using UniRx;

public class UpdateSample : MonoBehaviour
{
void Start()
{
Observable.EveryUpdate()
.Subscribe(
_ => Debug.Log("Update!")
);
}
}


基本的に使い方は先程のUpdateAsObservableと同じです。ですが、一つだけ大きな違いがあり、Observable.EveryUpdate()は自らOnCompletedを発行しません。つまり、Observable.EveryUpdate()を利用する場合は必ず自分でストリームの寿命管理を行う必要があります。


仕組み

Observable.EveryUpdate()はUniRxの機能の1つである「マイクロコルーチン」を利用して動作しており、仕組みはUpdateAsObservableと比較して複雑になっています。すごく簡潔にまとめてしまえば、「Observable.EveryUpdate()は呼び出されるたびにシングルトン上でコルーチンを起動する」という動作になっています。このコルーチンは手動で止めない限りずっと動作し続けてしまうため、ストリームの寿命管理をしっかりしないとその2で触れたような問題を引き起こす可能性があります。

ただしその反面、Observable.EveryUpdate()には次のようなメリットも存在します。


  • シングルトン上で動作するため、ゲームが進行中ずっと存在するストリームを生成できる

  • 大量にSubscribeしてもパフォーマンスが低下しない(マイクロコルーチンの性質)

なお、UniRxが管理しているシングルトンは「MainThreadDispatcher」というGameObjectです。UniRxを使っているといつのまにか生成されていることがあるかと思います。こちらもUniRxの動作には絶対に必要なため、勝手に削除したりしないようにしましょう。



(MainThreadDispatcherはUniRxが管理・利用しているシングルトンオブジェクトのため勝手に削除してはいけない)


UpdateAsObservable()とObservable.EveryUpdate()の使い分け

これら2つは動作こそ似ているものの、内部実装は大きく異なっていました。それぞれの動作の仕組みをしっかりと把握し、シチュエーションに応じて適切な方を利用するとよいかと思います。




  • UpdateAsObservable() : GameObjectが破棄されたら勝手に止まる


  • Observable.EveryUpdate() : パフォーマンスは良いけどDisposeを手動で行う必要がある


UpdateAsObservableを使うとよさそうな場所



  • GameObjectに紐付いたストリームを利用する


    • OnDestory時にOnCompletedが発行されるため寿命管理が楽になるため



Observable.EveryUpdate()を使うとよさそうな場合



  • GameObjectを利用しないPureなclassでUpdateイベントを利用したいとき


    • シングルトン経由でUpdateイベントを取得できるため、MonoBehaviourを継承しなくてもUpdateイベントを利用できる




  • ゲーム中で常に存在して動作しているストリームを準備したいとき




  • 大量にUpdate()呼び出しが必要になったとき


    • 素のUpdate()呼び出しより圧倒的にパフォーマンスが出るため




正直なところ、どっちを使うべきかは好みの問題だったりすると思います。Observable.EveryUpdate()の方がパフォーマンスは出るのですが、Disposeし忘れたときの事故がやはり怖いというのがあります。エラーになってストリームが止まるならまだ良くて、一番怖いのはエラーも出さすに裏でずっと動き続けてしまうパターンです。気づいたらゴミストリームが裏で大量に走りっぱなしだったみたいな。

なので、いくらパフォーマンスに差があると言っても、そのパフォーマンス差がクリティカルにゲームの動作に影響するシチュエーションって滅多にない(膨大な量のGameObjectを同時に生成して動かしたときとか?)ので、個人的にはより安全なUpdateAsObservable()から使い始めることをおすすめします。


2.Updateをストリームに変換するメリット

UniRxを利用するべき理由の1つがこの「Updateをストリーム化できる」という点であると思います。

ストリーム化すると、以下のようなメリットを享受することができます。


  • UniRxのオペレータを利用してロジックを記述できるようになる

  • ロジックの処理単位が明確になる


オペレータを利用したロジックの記述

UniRxは時間に関係するオペレータが多数用意されているため、UniRxのストリームでロジックを記述してしまえば時間が関係するロジックを簡潔に記述することが可能になります。

例えば、ボタンを押している間一定間隔で攻撃するという処理を考えてみましょう。

ボタンを押している間一定間隔で攻撃する、というのは例えばシューティングゲームの弾の発射などで利用できるかと思います。「ボタンを押し続けている間n秒ごとに弾を発射する」といったシチュエーションです。

これをUniRxを利用せずに実装した場合、最後に実行した時刻を記録して毎フレーム比較するなど煩雑で面倒くさい実装が必要になります。ですが、UniRxを使うと次のように実装することができます。


一定間隔で攻撃する

using System;

using UniRx;
using UniRx.Triggers;
using UnityEngine;

public class UpdateSample : MonoBehaviour
{
//実行間隔
[SerializeField]
private float intervalSeconds = 0.25f;

void Start()
{
// ThrottleFirstは最後に実行してから
// 一定時間OnNextを遮断するオペレータ
this.UpdateAsObservable()
.Where(_ => Input.GetKey(KeyCode.Z))
.ThrottleFirst(TimeSpan.FromSeconds(intervalSeconds))
.Subscribe(_ => Attack());
}

void Attack()
{
Debug.Log("Attack");
}
}


このようにUniRxを使うと、コレクションへの煩雑な処理をLINQで簡潔に書くのと同じように、ゲームロジックを宣言的に簡潔に記述することが可能になります。


ロジックが明確になる

Unityで開発を進めてると、Update()内にはゲームロジックが詰め込まれ、ぐちゃぐちゃになっていくこと場合がほとんどであると思います。

それもUniRxを使うことで整理することができます。


移動・ジャンプ・着地効果音の再生をするロジックの例

移動・ジャンプ・着地時に効果音の再生を行うというロジックをUniRxを使った場合と使わなかった場合で記述してみました。


UniRxを使わずにロジックを書く

using System;

using UnityEngine;

public class Sample : MonoBehaviour
{
private CharacterController characterController;

//ジャンプ中フラグ
private bool isJumping;

void Start()
{
characterController = GetComponent<CharacterController>();
}

void Update()
{
if (!isJumping)
{
var inputVector = new Vector3(
Input.GetAxis("Horizontal"),
0,
Input.GetAxis("Vertical")
);

if (inputVector.magnitude > 0.1f)
{
var dir = inputVector.normalized;
Move(dir);
}
if (Input.GetKeyDown(KeyCode.Space) && characterController.isGrounded)
{
Jump();
isJumping = true;
}
}
else
{
if (characterController.isGrounded)
{
isJumping = false;
PlaySoundEffect();
}
}

}

void Jump()
{
//Jump処理
}

void PlaySoundEffect()
{
//効果音の再生
}

void Move(Vector3 direction)
{
//移動処理
}
}



UniRxを使ってロジックを書く

using System;

using UniRx;
using UniRx.Triggers;
using UnityEngine;

public class Sample : MonoBehaviour
{
private CharacterController characterController;

//ジャンプ中フラグ
private BoolReactiveProperty isJumping = new BoolReactiveProperty();

void Start()
{
characterController = GetComponent<CharacterController>();

//ジャンプ中でなければ移動する
this.UpdateAsObservable()
.Where(_ => !isJumping.Value)
.Select(_ => new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical")))
.Where(x => x.magnitude > 0.1f)
.Subscribe(x => Move(x.normalized));

//ジャンプ中でないならジャンプする
this.UpdateAsObservable()
.Where(_ => Input.GetKeyDown(KeyCode.Space) && !isJumping.Value && characterController.isGrounded)
.Subscribe(_ =>
{
Jump();
isJumping.Value = true;
});

//着地フラグが変化したときにジャンプ中フラグを戻す
characterController
.ObserveEveryValueChanged(x => x.isGrounded)
.Where(x => x && isJumping.Value)
.Subscribe(_ => isJumping.Value = false)
.AddTo(gameObject);

//ジャンプ中フラグがfalseになったら効果音を鳴らす
isJumping.Where(x => !x)
.Subscribe(_ => PlaySoundEffect());
}

void Jump()
{
//Jump処理
}

void PlaySoundEffect()
{
//効果音の再生
}

void Move(Vector3 direction)
{
//移動処理
}
}


上記2つを比較してどうでしょうか? 

UniRxを使わなかった場合、Update内に処理を複数の処理をまとめて記述する必要があるためif文によりネストが発生したり、変数のスコープが曖昧になるなどの問題点がありました。

ですが、UniRxを使ってUpdateをストリーム化した場合、ロジック単位で処理を分割し並べて記述することが可能になり、変数のスコープもストリーム内に閉じた実装になりました。

このように、Updateをストリーム化することで処理を適切な単位で区切って記述することが可能になり、変数のスコープも明確にすることができます。

なお、ObserveEveryValueChangedについては末尾の補足を御覧ください。


3.まとめ

Update()をストリームに変換する方法は2つある


  • 普通使うのはUpdateAsObservable()でよい

  • 特殊な用途の場合にObservable.EveryUpdate()を使うべき

Update()をストリームに変換するとロジックが記述しやすくなる


  • UniRxのオペレータがそのままゲームロジックに流用できる

  • 処理を宣言的に、簡潔に読みやすく記述できるようになる


4.補足


ObserveEveryValueChangedについて


ObserveEveryValueChangedについて

var charcterController = GetComponent<CharacterController>();

//CharacterControllerのIsGroundedを監視
//false → trueになったらログに出す
charcterController
.ObserveEveryValueChanged(c => c.isGrounded)
.Where(x => x)
.Subscribe(_ => Debug.Log("着地!"))
.AddTo(gameObject);

// ↑のコードは↓とほぼ同義
Observable.EveryUpdate()
.Select(_=>charcterController.isGrounded)
.DistinctUntilChanged()
.Where(x=>x)
.Subscribe(_ => Debug.Log("着地!"))
.AddTo(gameObject);


前回、 ObserveEveryValueChangedは、Observable.EveryUpdate + Select + DistinctUntilChangedの省略記法相当であると説明しました。実はこの説明は微妙に正しくありませんでした。

ObserveEveryValueChangedは監視対象のオブジェクトを弱参照(WeakReference)で参照します。つまり、 ObserveEveryValueChangedでの監視はGCの参照カウントにカウントされません。 また、ObserveEveryValueChangedは監視対象のオブジェクトがGCに回収されるとOnCompletedを自動的に発行します。

この点に注意してObserveEveryValueChangedを利用するといいでしょう。