PhotonWire - Photon Server + Unityによる型付き非同期RPCフレームワーク

というのを作りました。Unityでネットワークマルチプレイヤーゲーム作るためのフレームワーク。といっても、100%自作じゃなくて、基本的にPhoton Serverというミドルウェアの上に乗っかるちょっと高級なレイヤーぐらいの位置づけです。去年の9月ぐらいに作った/作ってるよ、というのは発表していたのですが、それからかれこれ半年以上もpublicにしてないベイパーウェアだったのですが(グラニ社内では使ってました)、重たい腰を上げてやっと、やっと……、公開。

謳い文句は型付き非同期RPCフレームワークで、サーバー側はC#でasync/awaitを使ったメソッド実装、Unity側はそこから生成されたUniRx利用のメソッドを呼ぶだけで相互に通信できます。それでなにができるかというと、Visual StudioでUnity -> Server -> Unityと完全に一体化してデバッグできます。全部C#で。もうこれだけで最強でしょう。他は比較にならない。勝った。終わった。以上第一部完。

真面目に特徴、強みを上げると、以下のような感じです。

  • 完全タイプセーフ。サーバー-サーバー間は動的プロキシ、クライアント-サーバー間は事前クライアント生成。
  • IDLレス。C#サーバーコードのバイナリを元にして、クライアントコードを生成するので、普通にサーバーコードを書くだけ。面倒なIDLはゴミ箱ぽい。
  • 高性能。サーバーはasync/awaitで、クライアントはUniRxにより完全非同期で駆動。特にサーバーのC#コードはIL直書きも厭わずギチギチに最適化済み。
  • 事前生成シリアライザによるMsgPackでのシリアライズ/デシリアライズ。デシリアライズは更にマルチスレッド上で処理してUniRxでメインスレッドにディスパッチするのでフレームレートに一切影響を与えない。
  • Visual Studioとの完全な統合。高いデバッガビリティと、Analyzer利用を前提にしたフレームワーク構成はVS2015時代の新しい地平線。
  • 外部ツール「PhotonWire.HubInvoker」により外からAPIを直接叩ける。

HubInvokerは私にしては珍しく、ちゃんと見た目にこだわりました。これの外観の作り方はMaterial Design In XAML Toolkitでお手軽にWPFアプリを美しくで記事にしてます。

Photon Serverを選ぶ理由

Unityでもネットワーク系は色々な選択肢があると思います。

  • UNET
  • PUN + Photon Cloud
  • Photon Server(SDK直叩き)
  • モノビットエンジン
  • WebSocketで自作
  • MQTTで自作

このあたりは見たことある気がします。そのうちUNETは標準大正義だしAPIもProfilerも充実してる感なのですが、uNet Weaver Errorがムカつくので(コンパイルができなくなるという絶望!特にUniRx使ってると遭遇率が飛躍的に上昇!)、それが直らないかぎりは一ミリも使う気になれない。というのと、サーバーロジックを入れ込みたいどうしてもとにかくむしろそれがマスト、な状況の時にというか割とすぐにそうなると思ってるんですが、Unity純正だと、逆にUnityから出れないのが辛いかな、というのはありますね(ロードマップ的にはその辺もやるとかやらないとかあった気がしますが、まぁ遠い未来ということで)。Unity外で弄れるというのは、サーバーロジックだけじゃなくHubInvokerのようなツールを作れるっていうのも良いところですね。大事。なので、標準大正義は正しくも選べないのです。

モノビットはよく知らないので。C++でサーバーロジックは書きたくないなあ、今はC#も行けるんですっけ?

自作系は、あんまりそのレイヤーの面倒は見たくないので極力避けたい。別に動くの作るのはすぐでも、まともにちゃんと動き続けるの作るのは大変なのは分かりきってる話で。トラブルシュートも泣いちゃう。そこに骨を埋める気はない。あと、自作にするにしてもプロトコルの根底の部分で安定してるライブラリがあるかないかも大事で(そこまで自作は本当に嫌!)、Unityだとただでさえそんなに選択肢のないものが更に狭まるので、結構厳しい気がするのよね。実際。

Photonといって、Photon Cloudの話をしているのかPUN(Photon Unity Network)の話をしているのか、Photon Serverの話をしているのか。どれも違く、はないけれど性質は違うのだから一緒くたに言われてもよくわからない。さて、PUN。PhotonのUnityクライアントは生SDKが低レイヤ、その上に構築されたPUNが高レイヤのような位置づけっぽい感じですが、PUNは個人的にはないですね。秒速でないと思った。PUNの問題点は、標準のUnity Networkに似せたAPIが恐ろしく使いづらいこと。標準のUnity Network自体が別に良いものでもなんでもないレガシー(ついでにUnity自体も新APIであるUNETに移行する)なので、それに似てて嬉しい事なんて、実際のとこ全くないじゃん!もうこの時点でやる気はないんですが、更にPhoton Serverで独自ロジック書いたらそこははみ出すので生SDK触るしかないのだ、なんだ、じゃあいらないじゃん?Client-Client RPCも別になくてもいいし、というかなくていいし。

Photon Server。C++のコアエンジンってのは言ってみればASP.NETにおけるIISみたいなもので、開発者は触るところじゃない、直接触るのはサーバーSDKとクライアントSDKだけで、つまり両方ピュアC#。その上では普通にC#でガリガリと書ける。いいじゃん。両方ピュアC#というのが最高に良い。サーバーはWindowsでホストされる。それも最高に良い。プロトコルとかはゲーム専用で割り切ってる分だけ軽量っぽい。うん、悪くないんじゃないか。

また、ホスティングは結構優秀です。まず、無停止デプロイができる(設定でShadowCopy周りを弄ればOK)。これ、すっごく嬉しい。この手のは常時接続なのでデプロイ時に切断するわけにもいかないし、これ出来ないとデプロイの難易度が跳ね上がっちゃいますからねぇ。また、1サーバーで擬似的に複数台のシミュレートなどが可能です。実際、グラニでは6台構成クラスタのシミュレートという形で常に動かしていて、どうしても分散系のバグを未然に防ぐには重要で、それがサクッと作れるのは嬉しい。脚周りに関しては、かなり優秀と思って良いのではないでしょうか。

PhotonWireの必要な理由

Photon Serverがまぁ悪くないとして、なんでその上のレイヤーが必要なのか。これは生SDKを使ったコードを見てもらえれば分かるかしらん。

// 1. クライアント送信
var peer = new CliendSidePeer(new MyListener());
peer.OpCustom(opCode:10, parameter:new Dictionary<byte, object>());
// 2. サーバー受信
protected override void OnOperationRequest(OperationRequest operationRequest, SendParameters sendParameters)
{
    switch (operationRequest.OperationCode)
    {
        case 10:
           // Dictionaryに詰まってる
            var parameter = operationRequest.Parameters;
            HogeMoge(); // なんか処理する
            // 3. 送り返す
            this.SendOperationResponse(new OperationResponse(opCode:5), sendParameters); // 
            break;
        // 以下ケース繰り返し
        default:
            break;
    }
}
public class MyListener : IPhotonPeerListener
{
    // 4. クライアント受信
    public void OnOperationResponse(OperationResponse operationResponse)
    {
        // 返ってきたレスポンス
        switch (operationResponse.OperationCode)
        {
            case 5:
                // なんかする
                break;
        }
    }
}

問題点は明白です。原始的すぎる。byteパラメータ、Dictionaryを送って受け取りそれをswitch、送り返してやっぱswitch。こうなると当然長大なswitchが出来上がってカオスへ。また、クライアント送信とクライアント受信がバラバラ。コネクションで送信した結果を受け取るのが、独立した別のListenerで受け取ることになる、となると、送信時にフラグONで受信側でフラグチェック祭り、Listener側のフラグ制御が大変。送信したメッセージと戻り受信メッセージだという判別する手段がないので、並列リクエストが発生するとバグってしまう。

これをPhotonWireはHubという仕掛け(とUniRx)で解決します。

image

ようするにMVCのControllerみたいな感じで実装できます、ということですね。また、逆に言えば、PhotonWireはそんなに大きな機能を提供しません。あくまで、このswitchやちょっとしたシリアライゼーションを自動化してあげるという、それだけの薄いレイヤーになっています。なので、PhotonWireによるコードが素のPhoton Serverによるものと少し異なるからといって、あまり警戒する必要はありません。実際、薄く作ることは物凄く意識しています。厚いフレームワークは物事の解決と同時に、別のトラブルを呼び込むものですから……。

ちなみにPhotonWireを通すことによる通信のオーバーヘッドは4バイトぐらいです。それだけで圧倒的に使いやすさが向上するので、この4バイトは全然あり、でしょう。

Hub

HubというのはASP.NET SignalRから取っています。というか、PhotonWireのAPIはSignalRからの影響がかなり濃いので、ドキュメントはSignalRのものを漁れば20%ぐらいは合ってます(全然合ってない)

// Unityクライアント側で受け取るメソッド名はインターフェイスで定義
public interface ITutorialClient
{
    [Operation(0)]
    void GroupBroadcastMessage(string message);
}

[Hub(100)]
public class Tutorial : PhotonWire.Server.Hub<ITutorialClient>
{
    // 足し算するだけのもの。
    [Operation(0)]
    public int Sum(int x, int y)
    {
        return x + y;
    }

    // 非同期も行けます、例えばHTTPアクセスして何か取ってくるとか。
    [Operation(1)]
    public async Task<string> GetHtml(string url)
    {
        var httpClient = new HttpClient();
        var result = await httpClient.GetStringAsync(url);

        // PhotonのStringはサイズ制限があるので注意(デカいの送るとクライアント側で落ちて原因追求が困難)
        // クラスでラップしたのを送るとPhotonの生シリアライズじゃなくてMsgPackを通るようになるので、サイズ制限を超えることは可能 
        var cut = result.Substring(0, Math.Min(result.Length, short.MaxValue - 5000));

        return cut;
    }
    
    [Operation(2)]
    public void BroadcastAll(string message)
    {
        // リクエスト-レスポンスじゃなく全部の接続に対してメッセージを投げる
        this.Clients.All.GroupBroadcastMessage(message);
    }

    [Operation(3)]
    public void RegisterGroup(string groupName)
    {
        // Groupで接続の文字列識別子でのグループ化
        this.Context.Peer.AddGroup(groupName);
    }

    [Operation(4)]
    public void BroadcastTo(string groupName, string message)
    {
        // 対象グループにのみメッセージを投げる
        this.Clients.Group(groupName).GroupBroadcastMessage(message);
    }
}

async/awaitに全面対応しているので、同期通信APIを混ぜてしまっていて接続が詰まって死亡、みたいなケースをしっかり回避できます。属性をペタペタ張らないといけないルールは、Visual Studio 2015で書くとAnalyzerがエラーにしてくるので、それに従うだけで良いので、かなり楽です。

プリミティブな型だけじゃなくて複雑な型を受け渡ししたい場合は、DLLを共有します。

// こんなクラスをShareプロジェクトに定義して、Server側ではプロジェクト参照、Unity側へはビルド済みDLLをコピーする
public class Person
{
    public int Age { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}
// サーバーがこんなふうに戻り値を返して
[Operation(1)]
public Person CreatePerson(int seed)
{
    var rand = new Random(seed);

    return new Person
    {
        FirstName = "Yoshifumi",
        LastName = "Kawai",
        Age = rand.Next(0, 100)
    };
}
// Unity側では普通に受け取れる
proxy.Invoke.CreatePersonAsync(Random.Range(0, 100))
    .Subscribe(x =>
    {
        UnityEngine.Debug.Log(x.FirstName + " " + x.LastName + " Age:" + x.Age);
    });

プロジェクトの構成はこんな感じ。シームレス。

image

また、オマケ的に、Unity側でのエディタウィンドウではコネクションの接続状況と送受信グラフがついてきます。UNETの立派なProfilerに比べるとショボすぎて話にならないんですが、ないよりはマシかな、と。

サーバー間通信

Photon Serverはサーバーとサーバーを接続してクラスタを作れるのですが、その通信もHubを使ったRPCで処理しています。

// ServerHub(呼ばれる方)
[Hub(54)]
public class MasterTutorial : PhotonWire.Server.ServerToServer.ServerHub
{
    [Operation(0)]
    public virtual async Task<int> Multiply(int x, int y)
    {
        return x * y;
    }
}

// ClientHub(呼ぶ方)
[Hub(99)]
public class Tutorial : Hub
{
    [Operation(0)]
    public async Task<int> ServerToServer(int x, int y)
    {
        var val = await GetServerHubProxy<MasterTutorial>().Single.Multiply(x, y);
        return val;
    }
}

この見た目、直接呼んでるかのように書けるサーバー間通信は、実行時には以下のように置き換わってネットワーク呼び出しに変換されています。

image

なので、ServerHubはかならず戻り値はTaskじゃないとダメです(Analyzerが警告してくれます)。昔はこの手の処理を、メソッド呼び出しのように隠蔽する場合って、同期的になっちゃって、でもネットワーク呼び出しなので時間かかってボトルネックに、みたいなパターンが多かったようですが、今はTask[T]があるので自然に表現できます。このへんも含めてTask[T]が標準であることの意味、async/awaitによる言語サポートは非常に大きい。

この辺りの詳しい話は以下のスライドに書いています。

Metaprogramming Universe in C# - 実例に見るILからRoslynまでの活用例 from Yoshifumi Kawai

ネットワーク構成

PhotonWireは特に何の既定もしません。Photonが自由に組める通り、どんな組み方もできるし、どんな組み方をしてもPhotonWireでの呼び出しに支障は出ません。

のはいいんですが、その時、ClientPeer, InboundS2SPeer, OutboundS2SPeerの3種類のPeerを持つように、PhotonWireもまたHub, ServerHub, ReceiveServerHubとそれぞれに対応する3種のHubを持っています。3つ、これは複雑で面倒。

しかしPhotonWireはネットワークの複雑さの隠蔽はしません。やろうと思えばできますが、やりません。というのも、これ、やりだすと泥沼だから。賢くやりたきゃあAkkaでもなんでも使ってみればよくて、自分で書いたら一生終わらない。Photonのネットワークは本当に全然賢くなくて、ただたんに直結で繋いでるという、それだけです。そんなんでいい、とまではいいませんが、そうなら、それに関しては受け入れるべきでしょうね。勘違いしちゃあいけなくて、フレームワークは複雑さを隠蔽するもの、ではないのです。

ともあれ、最低限の賢くなさなりに、スケールしそうな感じに組み上げることは可能なので、全然良いとは思ってますよ!

できないこと

ポンと貼り付けてtransformが自動同期したり、いい感じに隙間を補完してくれたりするものはありません。ただ、Client-Server RPCがあれば、それは、その上で実装していくものだと思うので(いわゆるNantoka ToolkitとかNantoka Contribの範疇)、しゃーないけれど、自前で作ろうという話にはなってきますね。↑のネットワーク構成の話も、隠蔽とまではいかなくても、決まった構成になるのだったらそれなりにバイパスするいい感じのユーティリティは組んでいけるだろうから、その辺のちょっとした増築は、やったほうがいいでしょう。

まとめ

現状実績はないです(今、公開したばかりですからね!)。ただ、グラニで開発中の黒騎士と白の魔王というタイトルに投下しています。

半年以上は使い続けているので、それなりには叩かれて磨かれてはいるかなあ、と。大丈夫ですよ!と言い切るには弱いですが、本気ですよ!とは間違いなく言えます。DLLシェアや自動生成周りが複数人開発でのコンフリクトを起こしがちで、そこが改善しないと大変かなー、というところもありますが、全般的にはかなり良好です。

ちょっと大掛かりだったり、Windows/C#/Visual Studioベッタリな、時代に逆行するポータビリティのなさが開き直ってはいるんですが、結構使い手はあると思うので試してみてもらえると嬉しいですね!あと、大掛かりといっても、知識ゼロ状態からだったら素のPhoton Server使うよりずっと楽だと思います。そもそもにPhotonWireのGetting Startedのドキュメントのほうがよほど親切ですからねぇ、Visual Studioでのデバッグの仕方とかも懇切丁寧に書いてありますし!

VR時代のマルチプレイヤーって結局どうすんねん、と思ってたんですが、Project TangoのサンプルがPhotonだしAltspaceVRもPhotonっぽいので、暫くはPhotonでやってみようかなー。という感です。

MarkdownGenerator - C#におけるAPI Reference生成のためのドキュメントツール

APIリファレンス作りたい?Sandcastle。以上。終了。あるいはdotnet/docfxが良いのではないでしょうか。こいつはdotnet配下にあるように、MSの今後のOSS系のはこれでドキュメント生成されていく可能性があります。

というのは置いといて、私的には実のところ、あんまり重要視していませんでした、ドキュメントツール。.chmにはいい思い出がなくて、というか別に見ないじゃん?htmlで出力してもなー、なんかゴチャゴチャしてて汚いしなー。一方でJavaScriptなんかは様々な格好良くフォーマットされた形式で色々出てるのであった。いいじゃん。いいね。

さて、もう一つ。HTMLで出力しても置き場にこまる。GitHub Pagesにはそんないい思い出がない。別にあんなところをフロントにするよりもリポジトリのアドレス直のほうが断然いいじゃん、みたいな。というわけでアレだ、GitHub Wikiだ。あそこをゴミ置き場にすればいいんだ。という発想で、まずUniRxのリファレンスをGitHub Wikiに置いてみました。

UniRxは、namespaceを意図的にある程度平ったくしてるので、ちょっとごちゃってますが、まぁまぁいいんじゃない?それなりに見れる。悪くはない。少なくともないよりは100億倍良い。

私的にはIntelliSenseがドキュメントだ!みたいな意識がそれなりにあって、最初のチュートリアルみたいなドキュメントがあったら、あとはそれを手がかりにあとはIntelliSenseでなんとかしようぜ、的なところが。実際Roslynなんかはそんな感じがする。Getting Startedはそれなりに厚い、けど全貌からは程遠い。でもAPIドキュメントはない。さあ、IntelliSenseで宝探しだ。って。肯定もしないけれど否定もしない、そういうのも今風よね。でも、まあこの程度のAPIリファレンスでも生成してやると、それはそれで良いな、って思ったのだ。です。

MarkdownGenerator

生成は自家製こんそーるあぷりで行ってます。というわけで公開しました。

dllとxmlを渡すとmdと目次用のHome.mdをばらまくので、GitHub Wikiに投げ込みます。そう、GitHub Wikiはご存じの方も多い通り、それ自体がgitで管理されててCloneできるのです。さいこー。というわけでそのままPushするだけ。Good。完璧。これなら、CIなんかでフックして毎回生成して投げ飛ばしてあげてもいい。よね。

生成結果のStyleはちょっとまだまだ試行錯誤中。まあでも割とこんなもんでいいんちゃうんちゃうん?ユースケースの9割ぐらいはカバーできているでしょう。それ以上はノイズということで。

その他ツール

Sandcastleはそもそも出力をカスタマイズできるので、もう少し真面目というかガッチリしたものが必要ならば、maxtoroq/sandcastle-mdあたりを使ってMarkdownを出力してやると良いでしょう。これなら、きっちりとSandcastleで出力される情報が全部そのまま入ってるので、ちゃんとしてる感は圧倒的に高いです。また、繰り返しますけれどDocFXは今からやるなら最有力候補な気がします。DLLからじゃなくてRoslynでプロジェクトファイルから解析したりとか今風。あくまでstatic file generatorなのでmdじゃなくてhtml出力なので、Wikiに投げ飛ばす用途には向かないのと、ちょっと複雑、使いこなすのは難しい、かな、まあ相応には良さそうかとは。

MarkdownGeneratorは、ちゃんとしてないなりに、私的に重要視してる情報がパッと一覧で見やすくする、ということを重視しているので……。あと、なんかSandcastle使いたくないんだよねー、心理的に。なんだろうね、レガシー臭するからなのかな。食わず嫌いなだけって話でもあるのだけれど。

何れにせよ、GitHubであってもなくてもいいだろうけれど、API Referenceを投げ飛ばす場としては、そういうところ(どういうところ?)がいいですね。独立してるよりもリポジトリに近い場所のほうが素敵度は高い。気がする。あとはなんのかんのでGitHubに慣れきってるというのもあって、GitHubにあると情報がスムースに受け取れる気がするんだよね。これもなんでだろうね。でもそういうのってあるよね。

Unityにおけるコルーチンの省メモリと高速化について、或いはUniRx 5.3.0でのその反映

UniRx 5.3.0をリリースしました!今回のアップデートは、内部的な最適化、です。最適化は、もうそろそろあんまやるところ残ってないよね、なんて思ってたんですが、じっくり考えるとそんなことなく割とあったので埋めました。それが表題のコルーチンの省メモリと高速化です。使い方次第ではありますが、場合によっては今回のアップデートでものすごく恩恵に授かる人もいればそこそこの人もいるかもです。ともあれ基本的に内部的に変更してるだけなので、入れるだけでそれなりに高速化したりする可能性がそれなりにあります。

前回が2月だったので3ヶ月ぶりですね。あまりオペレータ追加がないので、次はオペレータ追加に集中したい気もする。なんか優先的に欲しいのあればリクエストもどうぞ(Observable.Windowとかいい加減そろそろ入れろよって話なんですが)

MicroCoroutine

今回の大きい変化はMicroCoroutine(と、自称してる)の導入です。特に大量にEveryUpdateやEveryValueChangedを呼んでるシチュエーションにおいて10倍、というのは場合によりで大雑把なのですが、相当速くなります。

void Start()
{
    // Start 10000 Coroutines
    for (int i = 0; i < 10000; i++)
    {
        // Standard Unity Coroutine
        // StartCoroutine(Counter());

        // Use UniRx 5.3 - MicroCoroutine
        MainThreadDispatcher
          .StartUpdateMicroCoroutine(Counter());
    }
}

IEnumerator Counter()
{
    while (true)
    {
        count++;
        yield return null;
    }
}

こんな10000個、単純なコルーチンを起動するコードがあったとして

image

大きく違いがでます。ちょっと恣意的すぎではあるんですが、UniRxはコルーチンを簡単にかけるが故に、これに近いシチュエーションってのが意図せず起こりがちではありました。また、Resources.LoadAsyncなど非同期系APIからの取得に関しても、一時的に多くのコルーチンを起動するシチュエーションはあり得るのではないでしょうか。

性能改善した理由は、基本的にはUnityの公式ブログUPDATE()を10000回呼ぶで紹介されていることの話で、10000個のUpdateは遅くて、配列に詰めて直接ループで呼ぼうぜ、と。どうせUpdate内のメソッドは呼ばれてC#の領域で実行されるんだから、マネージド(C#)-アンマネージド(C++)の繋ぎのレイヤーは純粋にオーバーヘッドになってくるよ、と。なるほどそうだねそりゃそうだねぇ。それはStartCoroutineにも言えて、というかコルーチンのほうがもっと性能劣化度が大きいんですよね。

この記事は非常に素晴らしくて、大量にモノ出して速度遅くなってるのがスクリプト起因なら、マネージャー立ててまとめて、あとUpdateに限らずマネージド-アンマネージドの繋ぎをやってる部分が遅いだろうからそこを適切に取り除ける限り除けば、全然まだまだそれなりに捌ける余裕は残ってるぜ。ということで、むしろ希望に満ちていていい感じです。実際、ハイパフォーマンスを謳うDOTweeenとかのライブラリもそんな感じですね、動かすものそれぞれにUpdateするコンポーネントを挿したりはしない、中央管理で動かすのだ、と。

さて、UniRxでは幾つかのメソッドはコルーチン依存でループを回しています。Observable.EveryUpdateとかEveryValueChangedとか。少しに使う分にはいいんですが、気楽に使えるが故に、大量に使うと、10000個とまではいかなくてもやっぱり、それぞれがコルーチンを起動することによるマネージド-アンマネージドオーバーヘッドがそのまま乗っかってきてしまいます。というわけで、やはりコルーチン自前管理の道を進むしかない……。幸い、自前管理で問題になる機能面での低下に関しては、UniRx自体がコルーチンを凌ぐだけの機能を提供しているので、気にしないでよし。というわけで純粋にいかにコルーチン(IEnumerator)を高速に回転させ、高速にメンテナンスするかにだけ集中すればよし。

回転させるのはforループ回すだけの話なんですが、マネージャー作ろうぜ、となった時に、Listに詰めるのはいいんですが、面倒くさいのは削除。削除は要注意で、単純にListのRemoveやって済ませたりするのは結構アレです(Removeは相当高コストな操作です)。かといってDictionaryやSet、LinkedListでやるなんていうのは論外で(列挙の性能が死ぬので本末転倒)、基本的に配列で頑張るべきなんですが、さてはて。結局、その辺のめんどーを見るのがめんどーだからUpdateやStartCoroutineでぶん回すのだ。割と本気で。

ではどうしたか、というと、UniRxのMicroCoroutineのアプローチはRemoveしない。です。しない。空いた部分はnullで埋めて純粋にスキップするだけにする。多少の空きなら、いちいち削るよりもスキップさせたほうが速い。しかし、それだけだとブヨブヨと膨らみ続けてしまうので、xフレーム毎に空きスペースに詰めなおして小さくします。縮める際も前の方に整列させるんじゃなくて、空きスペースに対して後ろから埋めるようにするので、順番はグチャグチャになります。その代わり余計な配列へのセットが発生しないので速い。そして膨らんだ配列は放置して膨らんだままにします、終端のインデックスだけ記録して管理するのみ(ところでアセットストアにアップデート申請出してから気づいたのですが、この配列の使い方なら定期的なお掃除じゃなくて、動かしながら埋めるようなコードにするのも可能っぽい感、なので次回アップデートでそうします)

というわけで、UniRxのMicroCoroutineは中央集権的なので多少膨らむことが許される(でしょう!)ことを利用して、とにかく高速にコルーチンを捌く、ということだけに集中してます。ので速い。下手に自前管理するよりも速いかもしれませんし、Updateで監視するよりもObserveEveryValueChangedのほうがむしろ速い、Rxで書いたほうが速い、みたいな逆転現象も全然発生しうるような話になります。

ObserveEveryValueChanged
EveryUpdate 
EveryFixedUpdate
EveryEndOfFrame
NextFrame
TimerFrame 
IntervalFrame
DelayFrame 
SampleFrame
ThrottleFrame
ThrottleFirstFrame
TimeoutFrame

この辺りのメソッドを使った場合、内部の実装がMicroCoroutineに差し替わったので自動的に恩恵に預かれます。コルーチン -> Observable変換に関しては FromMicroCoroutine が追加されました。基本的にはFromCoroutineと一緒なのですが、MicroCoroutineではyield returnするのはnullだけにしてください、それ以外には対応してません(UnityEditor上ではWarning出して警告します)。MicroCoroutineの制約はそれなんですが、なんだかんだで、8割ぐらいはyield return nullだけで成立するんちゃうんちゃうん、みたいな。賢くやろうとすればもう少しは出来なくもないんですが、シンプルで高速なコルーチンの回転を損ねちゃうのでナシ。IEnuemrator.Currentの呼び出しや、その型チェックすら省きたい。残り2割ぐらいなら普通にStartCoroutineすればいいじゃん、ということで。実際、UniRxの↑のメソッドはそれでかなり置き換えることが出来る、ということを発見できたので、全面的に導入する気になったのです。

また、最悪待ちたい場合は、isDoneのループを回すようにToYieldInstruction経由でIObservableを待てるので、大抵のことはなんでもできます。

IEnumerator MicroCoroutineWithToYieldInstruction()
{
    var www = ObservableWWW.Get("http://aaa").ToYieldInstruction();
    while (!(www.HasResult || www.IsCanceled || www.HasError)) // 3つもプロパティ並べるのダルいので次回アップデートでIsDoneを追加します予定
    {
        yield return null;
    }

    if (www.HasResult)
    {
        UnityEngine.Debug.Log(www.Result);
    }
}

もっとプリミティブに直接利用したい場合は、StartCoroutineの代わりにMainThreadDispatcherに3つ生やしてあります。

MainThreadDispatcher.StartUpdateMicroCoroutine
MainThreadDispatcher.StartFixedUpdateMicroCoroutine
MainThreadDispatcher.StartEndOfFrameMicroCoroutine

それぞれがコルーチンを消費するタイミングで、まぁ普通はStartUpdateMicroCoroutineを使えばよいでしょふ。もし大量のStartCoroutineがプログラム中にあるのなら、これに差し替えるだけで本当にすっごく速くなるでしょう。ほんと。

SubscribeWithState

ここから先はUniRxのアップデートの話だけ。そして本当にMicro Micro Microな最適化であんま意味はないんですが、まず、SubcribeWithStateを追加しました。これによって何が変わるか、というと、例えば……

// Before
public static IDisposable SubscribeToText(this IObservable<string> source, Text text)
{
    return source.Subscribe(x => text.text = x);
}

// After
public static IDisposable SubscribeToText(this IObservable<string> source, Text text)
{
    return source.SubscribeWithState(text, (x, t) => t.text = x);
}

という感じの使い方ができます。どういう違いが出るのかというと、以前にUnityでのボクシングの殺し方、或いはラムダ式における見えないnewの見極め方という記事の中で説明したのですが、ラムダ式はその中身によってコンパイル時に生成されるコードがかなり変わってきます。で、最速なのはそのメソッド内だけで完結していて外部の変数等には一切触っていない状態。onNextはActionなので、副作用かける際にどうしても外部変数をキャプチャしてしまうことが多いんですよね。そこでSubscribeWithStateを使うと、必要な変数を閉じ込めることができるので最速ゴミなしの形で記述できます。

ただまぁ、これやると、じゃあSelectやWhereなんかもState取れたほうがいいんですか?(理屈上はそうです)、とか、ああクロージャ殺さなきゃ死ね死ね死ね、とか思ったりしそうなのですけれど、Subscribeの回数ってパイプライン内の実行頻度に比べれば圧倒的に少なくなるはずなんですよね。だから全体のバランスで見たら無視できるといっても過言ではないはず、特にクロージャでちょっとゴミが出る程度の話は。

なのであんま神経質にやることはないんですが、↑のSubscribeToTextのようなそんな手間もかからないし、UIとかシーンの初期化時にいっぱい登録される可能性があるようなものでライブラリ的な部分でカバーできる質のものならば、少しだけ気を使ってあげると気は安らぐかもしれません。

ReactiveCommand

ReactiveCommandは.NET版のReactiveProeprtyにあった、最後のパーツなんですが、どうなんでしょうね、本来はViewModelのレイヤーのためなんですが、UnityだとPresenterにUI要素がセリ出してきてるのでイマイチベンリかどうか分からなくて入れてなかったんですが。一応、こんな風に使えます。

public class Player
{
   public ReactiveProperty<int> Hp;
   public ReactiveCommand Resurrect;

   public Player()
   {
        Hp = new ReactiveProperty<int>(1000);

        // If dead, can not execute.
        Resurrect = Hp.Select(x => x <= 0).ToReactiveCommand();
        // Execute when clicked
        Resurrect.Subscribe(_ =>
        {
             Hp.Value = 1000;
        }); 
    }
}

public class Presenter
{
    public Button resurrectButton;

    Player player;

    void Start()
    {
      player = new Player();

      // If Hp <= 0, can't press button.
      player.Resurrect.BindTo(resurrectButton);
    }
}

buttonのinteractableとonClickが抽象化されたもの、って感じですね。

その他

リリースノートから。

Add : ReactiveCommand
Add : MainThreadDispatcher.StartUpdateMicroCoroutine, StartFixedUpdateMicroCoroutine, StartEndOfFrameMicroCoroutine
Add : Scheduler.MainThreadFixedUpdate, MainThreadEndOfFrame
Add : ToYieldInstruction(cancellationToken)
Add : Observer.Create(onNext/onNext, onError/onNext, onCompleted) overload
Add : IReadOnlyReactiveProperty.SkipLatestValueOnSubscribe
Add : Observable.WhenAll overload (IObservable<Unit>(params IObservable<Unit>[] sources), this becomes breaking changes)
Add : Observable.FromMicroCoroutine
Add : Observable.AsSingleUnitObservable
Add : Observable.SubscribeWithState
Add : Observable.CreateWithState
Add : Disposable.CreateWithState
Improvement : Use MicroCoroutine on `ObserveEveryValueChanged`, `EveryUpdate`, `EveryFixedUpdate`, `EveryEndOfFrame`, `NextFrame`, `TimerFrame`, `IntervalFrame`, `DelayFrame`, `SampleFrame`, `ThrottleFrame`, `ThrottleFirstFrame`, `TimeoutFrame`
Improvement : Performance improvement for Observable.Range, Repeat when scheduler is Scheduler.Immediate
Improvement : Use Time.unscaledDeltaTime in IgnoreTimeScaleMainThreadScheduler
Fix : ReadOnlyReactiveProperty(source, initialValue) does not publish initial value on subscribe
Fix : IReadOnlyCollection has set indexer
Fix : Ambigious property of IReactiveCollection.Count, Indexer
Fix : Throw invalid error when ObservableWWW.LoadFromCacheOrDownload failed.
Breaking Changes : Added IReadOnlyReactiveProperty.HasValue
Breaking Changes : AsyncConvertsion scheduler to Scheduler.MainThread on WebGL build(WebGL doesn't support ThreadPool)
Other : Update UniRxAnalyzer 1.4.0.1 https://www.nuget.org/packages/UniRxAnalyzer

ToYieldInstructionはUniRx 5.0 - 完全書き直しによるパフォーマンス向上とヒューマンリーダブルなスタックトレース生成で説明しているのですが、Unity 5.3以降のCustomYieldInstuctionを応用したもので、IObservableをコルーチンで処理できるようにするやつで、結構お薦め機能です。MicroCoroutineで回すための補助にもなりますし。

SchedulerにMainThreadFixedUpdateとMainThreadEndOfFrameを足しました。ObserveOnやTimerなどで、その辺の細かい制動をしたい方にどうぞ。

(ReadOnly)ReactivePropertyへのSkipLatestValueOnSubscribe拡張メソッドの追加。これは、(UniRxの)ReactivePropertyはSubscribe時に必ず値をプッシュするようになってるんですが、そういった初期値を無視したいって局面は少なからずあるんですよね。Rx.NET用のReactivePropertyでは、コンストラクタでReactiveProeprtyModeとして、None | RaiseLatestValueOnSubscribe | DistinctUntilChanged を指定できるようなデザインを選んでいるのですが(というのも、Viewにデータバインディングするため構築時の初期値はnullであることが確定している、というシチュエーションが割とあるため)、UniRxのReactivePropertyではSubscribe側が選ぶというデザインにしています。この辺はフレームワークの性質の違いに合わせてるのですが、ともあれ、初期値を無視したい場合は rxProp.SkipLatestValueOnSubscribe().Subscribe() としてもらえれば。

Observable.WhenAllを、IObservable[Unit][]が相手の場合はIObservable[Unit]を返すようにしました。これは、別にUnit[]が返されても何の意味もないからというのと、それによって余計な配列確保をしないという最適化も入れています。この方が絶対に良いんですが、しかし戻り値の型が変わってしまったので破壊的変更にはなっています。最初から気づいておけば良かったですね、すびばせん。

AsSingleUnitObservableは LastOrDefault().AsUnitObservable() みたいな変換をかけるやつで、Async的な保証をかけるのにベンリというあれそれ。

あとは、んー、使ってる人は、うちの社内以外にないのでは疑惑も感じてますが、UniRxAnalyzerを更新してます。コンストラクタにIObservableを突っ込んでいた場合に誤検出していたのを修正しています。

これ、Visual Studio 2015を使って開発している人は絶対に入れたほうがいいですよ!Subscribe忘れて発火しないのに気づかなかったー、みたいなポカミスが圧倒的に防げますので。

まとめ

性能面でより気にせずにカジュアルに色々使えるようになった、というのはいいことかなー。性能面で問題出た際に「そういう使いかた想定してないから」といった却下の仕方って、あんましたくないですからね。聞いてていいものでは全くない。デザインとしてカジュアルに使えるようになっているなら、性能もちゃんと担保していかないし、そういうのが頻発するならライブラリの設計が悪い。と、思ってるので、今回のでよりちゃんと自然に使えるようになったかな、と。ObserveEveryValueChangedは個人的には最高にクールな機能だと思ってるので、気兼ねなく使って欲しいし、やっと本当に気兼ねなく使えるようになりました。

ObservableUpdateTrigger(UpdateAsObservable), Observable.EveryUpdate, Observable.EveryGameObjectUpdate とUpdateのハンドリングも3択、性能特性も三者三様。混乱との対話!別に特に何をレコメンドすることもなく、まあ素直に書くならUpdateTriggerが素直でよく。自身のUpdateループで周りますしね。EveryUpdateはMicroCoroutineなので性能特性的には良さげ、どうせAddTo(this)するならループのライフサイクルもUpdateTriggerと別に変わりはしないし(UpdateTriggerだとDisableでUpdateが回らなくなるので、まぁその辺で挙動に違いは出る)。EveryGameObjectUpdateはMainThreadDispatcherのSubjectに積まれるもので、UpdateTriggerが使える状況なら非推奨かな、あんまりSubjectに頻繁にAdd, Removeするのは性能特性的に悪手なので。UpdateTriggerもSubjectが駆動するのですが、性質的にグローバルじゃないのでAdd, Removeは局所化されるからそこまででは、に通常はなるでしょう、的な。

そんなこんなで、少なくともRxが性能面のネックでー、と言われるのは悔しい話なので、大きいものから小さいものまで、最適化ネタは常に考えてます。利用事例としても、結構ヒットしてる某社の某ゲーム(とは)や最近でた前作に続いてヒットの予感のする某ゲーム(とは)など、かなり使いこなしてる事例もあって(個人的にはとても感動した!)、ちゃんと実用的といってもいいレベルになってると思われます。弊社の開発中タイトルである黒騎士と白の魔王でもガッツリ使っているので、ご興味ある方は中途採用は絶賛募集中です:) 当たり前ですがドッグフーディングは凄く大事で、さすがにデカいバグは出てこないにしても軽微なものはちょいちょい上がってくるので、日々、堅牢さは担保されているな、とかかんとか。あと、使いすぎてるほどに使いすぎてるので、常に性能面でネックになってはいけない、性能面でネックになってはいけない、とマントラを唱えるプレッシャーになってるのもいいことです、多分きっと。

今回のアップデートでツメが甘かった案件としてはAsyncOperation.AsObservableやObservableWWWが内部的にまだFromCoroutine利用なので、FromMicroCoroutineに可能なら差し替えようかな、と。効果のほどとしては、やっぱり場合によりけりですが、初期化とかで大量に回る時は大きく変わるかも。しれない。ともあれ次回アップデートにご期待を。ただyield return wwwやasyncOperationした場合とyield return nullでisDoneチェックする場合とで、戻ってくるタイミングが異なるので、そこのルールを統一させないとかなあ。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive