sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

Unity 変数の値が変化したとき“1度だけ”コードを実行したい「ObservedValue」パターン

f:id:shikaku_sh:20200910152454p:plain

変数の値が変化したとき“だけ”コードを実行したいというような、ゲーム中ではよくあるイベントをどのように実装するのか、という話についてです。

この内容は「Unity 開発に関する 50 の Tips 〜ベストプラクティス〜(2016 Edition)」の Tips 40 の内容に挙がっていた「記事」を個人的に訳をした内容です。

ObservedValue クラスは、著者の作成したクラスですが、とてもシンプルなクラスだったので、この記事の最後にクラスのコードを加筆しています。


「ObservedValue」はどういうものか(The new class in Extensions, ObservedValue: what is it for and how to use it)

もし、あなたがたくさんのゲームのコードを書いたことがあるなら、こんなコードを書いていたことがあるのではないか:

class MyBehaviour : MonoBehaviour
{
   int count;
   
   public void Start()
   {
       count = CalculateCurrentCount();
   }

   public void Update()
   {
       var currentCount = CalculateCurrentCount();
       
       if(currentCount != count)
       {
           count = currentCount;
           DoSeomtingExpensive();
       }
   }
}

これは、ある値を(たとえば、フレームごとに)チェック or 計算したりしているが、値が変化しときに“だけ”なんらかのアクションを実行する、という考えです。

上のコードのサンプルでは、それほど悪いものではないですが、ファイルの内にこんなコードをいくつか作っているなら、それは面倒を起こす可能性があります。

また、特に値を複数の場所で更新する場合や、Update() メソッドの代わりに値の変更後にすぐチェックを実行したいなら、エラーを作りやすいです。

ObservedValue (Assets Store) は、このコードをきれいにパッケージ化することを目的としています。これは、ひな形 (boiler plate) の部分を担うと共に、値が変更されたとき、アクションを問題なく実行し、同じコード中で使い分けることができます。

class MyBehaviour : MonoBehaviour
{
   ObservedValue<int> count;
 
   public void Start()
   {
      count = new ObservedValue(CalculateCurrentCount());
      count.OnValueChanged += DoSeomtingExpensive;
   }
 
   public void Update()
   {
      count.Value = CalculateCurrentCount();
   }
}

これは、最初のバージョン(おそらく Assets のバージョンの話)と同じ機能のことをしていますが、よりクリーンなコードになりました。

重要な使用例のひとつとしては、ユーザーが値を変更したとき、あなたがなにか(別の値など)を変更したい場合、すぐに GUI (エディター)を使用することがあります。実際、私たちがクライアントのために開発したレベルエディターには、画面に描画されるものに影響をあたえる変数が20程度あって、ツールを改良するうちに多くのバグに遭遇しはじめたため、この ObservedValue クラスを書きました。

もうひとつ役に立つ戦略は、フレームごとに複数の計算をしないようにするため、state machine パターンと組み合わせることです。(これを普通にやると、見苦しいフラグ管理になる)以下は、state machine を使わない例:

class MyBehaviour : MonoBehaviour
{
   bool isDirty;
   ObservedValue<int> count1;
   ObservedValue<int> count2;
 
   public void Start()
   {
      isDirty = false;
      count1 = new ObservedValue(CalculateCurrentCount1());
      count1 += () { isDirty = true; };
 
      count2 = new ObservedValue(CalculateCurrentCount2());
      count2 += () { isDirty = true; };
   }
   
   public void Update()
   {
       count1.Value = CalculateCurrentCount1();
       count2.Value = CalculateCurrentCount2();
 
       if(isDirty)
       {
           DoSomethingExpensive();
           isDirty = false;
       }
   }
}

例によって、この単純なケースではそれほど悪いコードになっていません。複数のクラスにまたがっていたり、見苦しくなるコードの箇所がいくつもあったり、見苦しさのレベルがひどかったりすると、コードはより複雑になってしまいます。

つぎのコードは、state machine パターンを使ってどのように見えるのかを示す:

class MyBehaviour : MonoBehaviour
{
   public enum DirtyState {Dirty, Clean};
 
   StateMachine dirtyManager;
   ObservedValue<int> count1;
   ObservedValue<int> count2;
 
   public void Start()
   {
      dirtyManager = new StateMachine();
 
      dirtyManager.AddState(DirtyState.Clean);
      dirtyManager.AddState(
          DirtyState.Dirty, 
          null, 
          () => 
          {
             DoSomethingExpensive();
             dirtyManager.SetState(DirtyState.Clean);
          }); 
 
      count1 = new ObservedValue(CalculateCurrentCount1());
      count1 += () { dirtyManager.SetState(DirtyState.Dirty); };
 
      count2 = new ObservedValue(CalculateCurrentCount2());
      count2 += () { isDirty = true; };
 
   }
   
   public void Update()
   {
       count1.Value = CalculateCurrentCount1();
       count2.Value = CalculateCurrentCount2();
       
       dirtyManager.Update();
   }
}

上くらいシンプルなサンプルだと、state machine パターンを使用してもメリットはありません。恩恵を得ることができるのは、状況がもっと複雑になってからです。もちろん、上のコードにはひな形 (biler plate) になる部分があるので、あなたが DirtyManager で処理してもよいでしょう。(DirtyManager は、将来の拡張ライブラリとしてよい手です)

つぎに、そういったクラスのドラフトを示します:

public class DirtyManager
{
   private enum DirtyState {Dirty, Clean};
 
   private StateMachine dirtyManager;
   public event OnShouldCleanDirt; //needs a better name!
 
   public DirtyManager()
   {
      dirtyManager = new StateMachine();
 
      dirtyManager.AddState(DirtyState.Clean);
      dirtyManager.AddState(
          DirtyState.Dirty, 
          null, 
          () => 
          {
             if(OnShouldCleanDirt != null) 
             {
                 OnShouldCleanDirt();
             }
 
             dirtyManager.SetState(DirtyState.Clean);
          }); 
   }
 
   public void Update()
   {
       dirtyManager.Update();
   }
 
   public void Observe(ObservedValue observedValue)
   {
       observedValue.OnValueChanged += SetDirty;
   }
   
   private void SetDirty()
   {
       dirtyManager.SetState(DirtyState.Dirty);
   }
}

サンプルはこうなります:

class MyBehaviour : MonoBehaviour
{
   DirtyManager dirtyManager;
   ObservedValue<int> count1;
   ObservedValue<int> count2;
 
   public void Start()
   {
      count1 = new ObservedValue(CalculateCurrentCount1());
      count2 = new ObservedValue(CalculateCurrentCount2());
 
      dirtyManager = new DirtyManager();
 
      dirtyManager.Observe(count1);
      dirtyManager.Observe(count2);
 
      dirtyManager.OnShouldCleanDirt += DoSomethingExpensive; 
   }
   
   public void Update()
   {
       count1.Value = CalculateCurrentCount1();
       count2.Value = CalculateCurrentCount2();
       
       dirtyManager.Update();
   }
}

コードはよりクリーン(わかりやすく)なり、追加する複雑さに対してより強かになりました。

最後に、テストコードは state machine パターンが本当に必要ですか? 答えは「必要ありません」。簡単なコードを保つように、bool を使います。

しかし、より複雑なケースでは、state machine パターンは価値を提供することができます。見苦しさのレベル、特別な状況に対処するためなどに使えます。(たとえば、なにか他のことが起こるまで、計算を遅らせたい場合など)


補足(ObservedValue のコード確認)

以下の内容は、新しく私のブログで補足として調べた内容です。この記事のメインコンテンツである ObservedValue は UniRx の一部分(ObserveEveryValueChanged など)のようなものですが、下記コードのように実装はとてもシンプルのようです。

Gamelogic Extensions
Version 2.5 Feb 21, 2019:
Assets>Gamelogic>Extensions>Plugins>Scripts>Patterns

public class ObservedValue<T>
    {
        private T currentValue;
        public event Action OnValueChange;

        public ObservedValue(T initialValue)
        {
            currentValue = initialValue;
        }

        public T Value
        {
            get { return currentValue; }

            set
            {
                if (!currentValue.Equals(value))
                {
                    currentValue = value;

                    if (OnValueChange != null)
                    {
                        OnValueChange();
                    }
                }
            }
        }

        public void SetSilently(T value)
        {
            currentValue = value;
        }
    }

基本的には、Value プロパティに対して値を設定するのがこのクラスの用途のようですね。このときに、値が一致しなければ !Equals(value)OnValueChange イベントを実行するという仕組みです。

値を設定するときに、このイベントを実行したくないときは、SetSilently() メソッド経由で現在値を更新しています。こんな感じで、趣味でプログラミングをしていて UniRx を無免許運転するくらいなら、車輪の再開発をして、こうしたコードテクニック自体をちょっとずつ覚え、地に足をつけたコーディング力を身につけるのも悪くないと私は思います。


パフォーマンスに関する意見

見てわかるとおり、もともとのコードは if 文をひとつ入れて処理していた内容が、クラスを定義して作ったプロパティは、if 文が2つにイベントを実行すると、パフォーマンスの上では間違いなく(僅かに)低くなった実装になっていることが伺えます。

このテクニックが良いことか悪いことかを考えてみます。この答えのひとつを挙げると、リファクタリングの善し悪しを、審美的なものにして、個人の好みとするのは微妙です。

単なる好みではない面からいくと、一般的な意味での良いコートとは、どれだけ変更が容易なのかを示す柔軟性・拡張性で決まっています。なので、ほんの少し低速になる代わりに、(この頻出するだろう問題に対して)健全なコードになるようリファクタリングし、(元のコードよりも)生産性をよりよい状態にした ObservedValue はよいコードといえるかもしれません。

僅かなパフォーマンスとリファクタリングの関係についてもっと知りたい場合は、リファクタリング関係の本を読むといいです。

最初の説明にもあったように、シンプルなコードなら、こうしたリファクタリングは不要だと思います。しかし、見た目に冗長であること+コード設計として劣化を早めやすい傾向のコードなら、すくなくとも注意が必要です。

コンピューターがプログラムを実行する際に、CPU サイクルを余分に少し必要にするという事態よりも、コーダーの都合を大切にするようなコーディングを選択するような選択は年々増えてきています。

パフォーマンスを要求するゲームでも、細かい部分は作業性・メンテナンス性をとりますよ、と。(そもそも Unity + C# だし、そりゃそうだよね)


参考

増補改訂版Java言語で学ぶデザインパターン入門

増補改訂版Java言語で学ぶデザインパターン入門

  • 作者:結城 浩
  • 発売日: 2004/06/19
  • メディア: 大型本