はじめに

株式会社WinTicketでUnity&XRエンジニアをしています。加田です。私たちスポーツ映像テック事業部では、新たなスポーツ映像の創出を目指し、日々開発をしています。現在、データの可視化にはゲームエンジンのUnityを用いて、リアルタイムCG合成をしています。
本記事では、Unity製描画アプリにおいて、複数のスポーツに対応するための通信プロトコルとしてProtocol Buffersを導入した経緯と実装について共有します。

この記事で学べること

  • Protocol Buffersの基本概念と、生のbyte配列通信からProtocol Buffersへ移行するメリット
  • UnityプロジェクトにProtocol Buffersを導入する具体的な手順(NuGetForUnityの利用、パッケージインストール、protoファイルのコンパイル方法)
  • Unity(C#)でUDP通信経由でProtocol Buffersメッセージを受信・パースする実装方法
  • 異なる言語間(C#、Go、Python)での通信プロトコルを統一する方法
  • 型安全性を確保しながら、パフォーマンスとメンテナンス性を向上させる実践的なアプローチ

想定読者

  • Unityでネットワーク通信(特にUDP通信)を実装しているエンジニア
  • 複数の言語間で通信プロトコルを統一したいエンジニア
  • 型安全性やパフォーマンス向上に興味のあるエンジニア
  • バイト配列を直接扱う通信実装の課題を感じているエンジニア
  • Protocol BuffersのUnityへの導入手順を知りたいエンジニア

目次

  • Protocol Buffersを使った通信とは

    Protocol Buffersを使った通信とは

    課題:生のbyte配列を使った通信の限界

    Protocol Buffersを導入する前はUDP通信でbyte配列を直接やり取りしていました(参考)。受信側となるUnityではC#で実装されていますが、送信側のアプリはGoやPythonで書かれており、関係者間でバイトアライメントを決めて実装をしていました。byte配列でのやり取りは要素の順番を間違えるとパースエラーが発生してしまうため、実装には細心の注意が必要でした。また、変数の追加・削除も容易に行うことができず、どの時点の仕様で実装されているかが分からないというバージョニングの問題もありました。

    Protocol Buffersの導入

    これらの課題を解決するため、Protocol Buffersを導入し、型安全で効率的な通信を実現しました。

    Protocol Buffersは、Googleが開発したバイナリシリアライズ形式です。.protoファイルでデータ構造を定義し、そこから各言語(C#、Go、Pythonなど)のコードを自動生成できます。これにより、異なる言語間での通信でも型安全性を保ちながら、JSONと比較して高速でコンパクトなデータ転送が可能になります。

    Unityプロジェクトへの導入

    ここからはUnityプロジェクトへProtocol Buffersを導入する方法を紹介します。

    パッケージのインストール

    まず、NuGetForUnityをプロジェクトにインストールします。
    Package Managerを開き、Add package from git URL...で下記のURLを入力してAddボタンでインストールします。

    https://github.com/GlitchEnzo/NuGetForUnity.git?path=/src/NuGetForUnity
    

    NuGetForUnity

    NuGetForUnityがインストールできたら、ツールバーのNuGetManage NuGet Packagesを開き、下記2つのパッケージをインストールします。
    gRPC通信自体は行いませんが、protocコンパイラを利用するために、Grpc.Toolsパッケージを導入しています。

    • Google.Protobuf
    • Grpc.Tools

    Google.Protobuf
    Grpc.Tools

    Protoファイルの用意

    今回は送信用に簡単なProtoファイルを作成しました。

    syntax = "proto3";
    
    // Pingメッセージ: UDP通信で使用するテスト用メッセージ
    message Ping {
      // メッセージ内容
      string message = 1;
      // 送信時刻(Unixタイムスタンプ)
      int64 sent_unix = 2;
    }
    

    この.protoファイルを送信側と受信側で共有し、各言語にコンパイルすることでProtocol Buffersでの通信を実現します。

    Unity(C#)でのProtoファイルコンパイル方法

    今回はAssetsの直下にProtoというフォルダを作り、その中に.protoファイルを格納しました。

    Assets/
    └── Proto/
        └── ping.proto
    

    同じフォルダで下記のコマンドを実行すると、protoファイルをコンパイルしたPing.csが生成されます。
    コンパイルに必要なツールはProjectRoot/Packages/Grpc.Tools.x.xx.x/toolsにOSごとに分かれて配置されています。
    パスはバージョンごとに異なるため、2.76.0以外のバージョンがインストールされている場合は読み替えてください。

    Windows(x64)

    ..\..\Packages\Grpc.Tools.2.76.0\tools\windows_x64\protoc.exe ^
      ./ping.proto ^
      --csharp_out=./ ^
      --grpc_out=./ ^
      --plugin=protoc-gen-grpc=..\..\Packages\Grpc.Tools.2.76.0\tools\windows_x64\grpc_csharp_plugin.exe
    

    Mac

    ../../Packages/Grpc.Tools.2.76.0/tools/macosx_x64/protoc \
      ./ping.proto \
      --csharp_out=./ \
      --grpc_out=./ \
      --plugin=protoc-gen-grpc=../../Packages/Grpc.Tools.2.76.0/tools/macosx_x64/grpc_csharp_plugin
    

    通信で受け取ったbyte配列をパースする

    Ping.csが生成され、コンパイルが通ったら受信処理を実装します。
    以前の記事で作成したUDP受信クラスを流用します。

    using System;
    using System.Net;
    using System.Net.Sockets;
    using R3;
    
    ///
    /// ネットワーク経由でデータを受け取るクラス
    ///
    public class NetworkDataReceiver : IDisposable
    {
        UdpClient udpClient = null;
    
        Subject<byte[]> subject = new();
    
        ///
        /// データの受信イベント(メインスレッドではないので注意)
        ///
        public Observable<byte[]> OnReceivedBytes => this.subject;
    
        public int Port { get; private set; }
    
        ///
        /// コンストラクタ
        ///
        public NetworkDataReceiver(int port)
        {
            this.Port = port;
            this.udpClient = new UdpClient(port);
            this.udpClient.BeginReceive(OnReceived, this.udpClient);
        }
    
        private void OnReceived(System.IAsyncResult result)
        {
            UdpClient getUdp = (UdpClient)result.AsyncState;
            IPEndPoint ipEnd = null;
    
            var getByte = getUdp.EndReceive(result, ref ipEnd);
    
            this.subject.OnNext(getByte);
    
            getUdp.BeginReceive(OnReceived, getUdp);
        }
    
    
        public void Dispose()
        {
            this.udpClient.Close();
        }
    }
    
    using UnityEngine;
    using R3;
    
    public class ProtoCheck : MonoBehaviour
    {
        NetworkDataReceiver dataReceiver;
    
        void Start()
        {
            this.dataReceiver = new NetworkDataReceiver(port:50051);
            this.dataReceiver
                .OnReceivedBytes
                .ObserveOnMainThread()  // UIへの適用など、Unityの関数を使う場合にはメインスレッドで処理をする
                .Subscribe(bytes =>
            {
                // Protobufメッセージをデシリアライズ
                Ping ping = Ping.Parser.ParseFrom(bytes);
    
                Debug.Log($"メッセージ: {ping.Message}, 送信時刻(Unix): {ping.SentUnix}");
            }).AddTo(this);
        }
    
        void OnDestroy()
        {
            this.dataReceiver.Dispose();
        }
    }
    

    受信したbyte配列をParseFromメソッドに渡すと、対象のオブジェクトに変換されます。
    これにより、型安全な状態で通信が可能となります。
    protoファイルにコメントを記述しておくと、C#側でもハイライト表示されるため便利です。

    Comment

    Protoファイルの更新により通信内容が変わったときは、C#にコンパイルした時点で変更されたことを検知できます。
    Protoファイルが更新されたら、C#に自動変換する仕組みを構築すれば、さらに安全にやり取りすることが可能となります。

    まとめ

    本記事では、Unityアプリにおいて通信プロトコルとしてProtocol Buffersを導入した経緯と実装について紹介しました。

    Protocol Buffersの導入により、型安全性とパフォーマンスを向上させ、複数スポーツのデータを統一的に扱える通信プロトコルの構築ができました。今後も改善を続けていきます。

    Appendix

    Protocol Buffersを検証するためにPythonで簡易的な送信サーバーを作成しましたので、そのコードも載せておきます。
    あらかじめprotocコマンドを使ってping.protoping_pb2に変換しています。

    import socket
    import time
    import ping_pb2
    
    HOST = "127.0.0.1"
    PORT = 50051
    
    def main():
        # UDP ソケット
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    
        try:
            while True:
                # Protobuf メッセージ作成
                msg = ping_pb2.Ping(
                    message="Send UDP Message",
                    sent_unix=int(time.time()),
                )
    
                # シリアライズ
                payload = msg.SerializeToString()
    
                # UDP は sendto 1 回 = 1 datagram
                sent = sock.sendto(payload, (HOST, PORT))
                print(f"sent {sent} bytes")
    
                # 1秒待機
                time.sleep(1)
        except KeyboardInterrupt:
            print("\n送信を停止しました")
        finally:
            sock.close()
    
    if __name__ == "__main__":
        main()
    

    参考リンク

アバター画像
株式会社WinTicket所属 Unity&XRエンジニア。現在は競輪中継映像『WINLIVE』の描画パートを担当