e.blog

主にUnity/UE周りのことについてまとめていきます

UNETのLow Level APIを使ってシンプルな位置同期の仕組みを作る

概要

UNETとはUnityが提供しているネットワークゲームを作るためのシステムです。
UNETには「High Level API」と「Low Level API」があり、High Level APIについては以前、記事に簡単にまとめました。

edom18.hateblo.jp

今回はLow Level APIについて書きたいと思います。
サンプルとして、ごく簡単に位置を同期してみます。

実際に動かしてみたのがこちら↓

なお、今回の実装のサンプルはGithubにアップしてあるので興味がある人は見てみてください。

github.com

また、今回の実装にあたっては以下の記事を参考にさせていただきました。ありがとうございます。

tarukosu.hatenablog.com

概観する

まず、基本的なコネクションのフローなどをざっくりと見ていきたいと思います。

登場人物

ネットワーク対応にするにあたり、必要となるクラスなどを紹介します。

クラス

メインとなるクラスです。NetworkTransportsealedクラスになっていて、実質staticクラスになっています。

ネットワーク関連の初期化(IP AddressやPort番号の設定など)、データの送受信などネットワークに関連する処理は本クラスを用いて行います。

以下のHostTopologyクラスのコンストラクタ引数に指定するコンフィグ情報です。

トポロジーとは

こちらの記事から引用させてもらうと、

元々は位相幾何学の意味であるが、パソコン用語としては、LANの接続形態の総称。ネットワークトポロジーとも呼ばれる。LANには接続形態によって、リング型やバス型、スター型などがあり、接続する機器によって用途や目的に合った接続形態を使用する必要がある。

つまりは、どういう形態のネットワークなのか、を定義します。
コンストラクタにはコンフィグオブジェクトと最大接続数を指定します。

ConnectionConfig config = new ConnectionConfig();

// ... 中略 ...

int maxConnection = 10;
HostTopology topology = new HostTopology(config, maxConnection);

そして生成したホストトポロジーオブジェクトをホストとして登録します。

int hostId = NetworkTransport.AddHost(topology, _localPort);

登録すると、Host IDが返されるので保持しておきます。(後の通信時に利用します)

enum

QosType 説明
Unreliable There is no guarantee of delivery or ordering.
UnreliableFragmented There is no guarantee of delivery or ordering, but allowing fragmented messages with up to 32 fragments per message.
UnreliableSequenced There is no guarantee of delivery and all unordered messages will be dropped. Example: VoIP.
Reliable Each message is guaranteed to be delivered but not guaranteed to be in order.
ReliableFragmented Each message is guaranteed to be delivered, also allowing fragmented messages with up to 32 fragments per message.
ReliableSequenced Each message is guaranteed to be delivered and in order.
StateUpdate An unreliable message. Only the last message in the send buffer is sent. Only the most recent message in the receive buffer will be delivere |d.
ReliableStateUpdate A reliable message. Note: Only the last message in the send buffer is sent. Only the most recent message in the receive buffer will |be delivered.
AllCostDelivery A reliable message that will be re-sent with a high frequency until it is acknowledged.
UnreliableFragmentedSequenced There is garantee of ordering, no guarantee of delivery, but allowing fragmented messages with up to 32 fragments per mess |age.
ReliableFragmentedSequenced Each message is guaranteed to be delivered in order, also allowing fragmented messages with up to 32 fragments per message.

Quality of serviceの略です。到達の保証や順序の保証についてのクオリティを指定します。

NetworkEventType 説明
DataEvent Data event received. Indicating that data was received.
ConnectEvent Connection event received. Indicating that a new connection was established.
DisconnectEvent Disconnection event received.
Nothing No new event was received.
BroadcastEvent Broadcast discovery event received. To obtain sender connection info and possible complimentary message from them, call NetworkTransport.GetBroadcastConnectionInfo() and NetworkTransport.GetBroadcastConnectionMessage() functions.

データを受信した際のイベントタイプです。

フロー

簡単なフローは以下の通りです。

  1. NetworkTransport.Init();で初期化
  2. ConnectionConfigでコンフィグ情報を生成する
  3. 必要なQosType(Quality of service)をチャンネルに追加する
  4. HostTopolosyトポロジーオブジェクトを生成
  5. NetworkTransport.AddHost(...);でホスト(トポロジー)を登録
  6. NetworkTransport.Connect(...);でサーバへ接続(クライアントの場合)

セットアップ

さて、では実際のコードを参考にフローを見てみます。

ネットワークの初期化

サーバ/クライアントに関わらず、ネットワークを開始するためのセットアップを行います。

/// <summary>
/// ネットワークのセットアップ
/// </summary>
private void NetworkSetup()
{
    Debug.Log("Setup network.");

    _isStarted = true;

    // ネットワークの初期化処理
    // NetworkTransportクラスの他のメソッドを呼び出す前に初期化が必要
    NetworkTransport.Init();

    // コンフィグ情報の生成
    ConnectionConfig config = new ConnectionConfig();
    _channelId = config.AddChannel(_qosType);

    // トポロジーオブジェクトの生成
    int maxConnections = 10;
    HostTopology topology = new HostTopology(config, maxConnections);

    _hostId = NetworkTransport.AddHost(topology, _localPort);
}

コネクション、データ受信などのネットワークイベントをハンドリングする

ネットワークの初期化が終わったら、サーバへの接続およびデータの受信についてのハンドリングを行います。

前述の通りNetworkEventTypeというイベントタイプが定義されており、データを受信した際のタイプとして使用します。

データの受信にはNetworkTransport.Receive(...);メソッドを利用します。
詳細はコードを見てください。

/// <summary>
/// ネットワークイベントのハンドリング
/// </summary>
private void Receive()
{
    int receiveHostId;
    int connectionId;
    int channelId;
    byte[] receiveBuffer = new byte[_maxBufferSize];
    int bufferSize = _maxBufferSize;
    int dataSize;
    byte error;

    // 受信データは複数ある場合があるので、ループでデータが無くなるまで処理する
    do
    {
        NetworkEventType eventType = NetworkTransport.Receive(out receiveHostId, out connectionId, out channelId, receiveBuffer, bufferSize, out dataSize, out error);

        switch (eventType)
        {
            case NetworkEventType.Nothing:

                // 受信データなし

                return;
            case NetworkEventType.ConnectEvent:
                Debug.Log("Connected.");

                // Connection処理

                break;
            case NetworkEventType.DisconnectEvent:
                Debug.Log("Disconnected.");

                // Disconnection処理

                break;
            case NetworkEventType.DataEvent:
                
                // データ受信の処理

                break;
        }
    } while(true);
}

サーバへ接続する

サーバへ接続するにはNetworkTransport.Connect(...);メソッドを利用します。
以下のようにして、Host IDServer AddressServerPortExeption Connection IDを指定して接続を試みます。

あくまで試みるだけで、実際に接続が完了した場合はデータ受信(ハンドリング)のところで、ConnectEventが渡ってくるので、そこで適切に処理します。

/// <summary>
/// サーバへコネクションを張る
/// </summary>
private void Connect()
{
    byte error;
    _connectionId = NetworkTransport.Connect(_hostId, _serverAddress, _serverPort, 0, out error);
}

シリアライズしてデータを送受信する

接続が完了したら、次は必要なデータを送信します。
ただ注意が必要なのは、Vector3などの構造体はもちろん、floatなどのプリミティブな値もそのままでは送信することができません。

それらをbyte配列に変換し、シリアライズしてからデータを送信する必要があります。

/// <summary>
/// 位置を同期する
/// </summary>
private void SyncPosition()
{
    if (!_isServer && !_hasConnected)
    {
        return;
    }

    if (!_hasAuthorized)
    {
        return;
    }

    byte[] x = ConversionUtil.ToBytes(_target.position.x);
    byte[] y = ConversionUtil.ToBytes(_target.position.y);
    byte[] z = ConversionUtil.ToBytes(_target.position.z);

    byte[] pos = ConversionUtil.Serialize(x, y, z);

    if (_isServer)
    {
        for (int i = 0; i < _clientIds.Count; i++)
        {
            SendData(pos, _clientIds[i], pos.Length);
        }
    }
    else
    {
        SendData(pos, _connectionId, pos.Length);
    }
}
/// <summary>
/// データ送信
/// </summary>
/// <param name="data">送信するデータ配列</param>
public void SendData(byte[] data, int connectionId, int dataSize)
{
    byte error;
    NetworkTransport.Send(_hostId, connectionId, _channelId, data, dataSize, out error);
}

データのシリアライズ

シリアライズとは、こちらの記事から引用させてもらうと

シリアライズとは、複数の並列データを直列化して送信することである。

具体的には、メモリ上に存在する情報を、ファイルとして保存したり、ネットワークで送受信したりできるように変換することである。他方、既にファイルとして存在しているデータや、一旦シリアライズされたデータがネットワークから送られてきた際に、プログラムで扱えるようにする作業をデシリアライズと呼ぶ。

これが前述の「byte配列に変換して」の部分ですね。
intfloatなどは4バイトで表現されます。
つまり、これを1バイトごとに分解してシリアライズし、それをまとめて送る必要があります。

今回のサンプルでは、位置データ(Vector3)の各要素(x, y, z)をbyte配列に変換し、さらにそれをシリアル化してまとめて送信しています。

変換用のユーティリティクラスは以下のように実装しました。
(あくまで今回の簡易的な位置同期のための実装です)

[StructLayout(LayoutKind.Explicit)]
public struct UIntFloat
{
    [FieldOffset(0)]
    public uint intValue;

    [FieldOffset(0)]
    public float floatValue;

    [FieldOffset(0)]
    public double doubleValue;

    [FieldOffset(0)]
    public ulong longValue;
}

/// <summary>
/// 値をByte列に、Byte列を値に変換する
/// </summary>
public class ConversionUtil
{
    static public float ToFloat(uint value)
    {
        UIntFloat uif = new UIntFloat();
        uif.intValue = value;
        return uif.floatValue;
    }

    static public float ToFloat(byte[] values)
    {
        int x = 0;

        if (System.BitConverter.IsLittleEndian)
        {
            x |= (values[3] <<  0);
            x |= (values[2] <<  8);
            x |= (values[1] << 16);
            x |= (values[0] << 24);
        }
        else
        {
            x |= (values[0] <<  0);
            x |= (values[1] <<  8);
            x |= (values[2] << 16);
            x |= (values[3] << 24);
        }

        UIntFloat uif = new UIntFloat();
        uif.intValue = (uint)x;
        return uif.floatValue;
    }

    static public double ToDouble(ulong value)
    {
        UIntFloat uif = new UIntFloat();
        uif.longValue = value;
        return uif.doubleValue;
    }

    static public double ToDouble(byte[] values)
    {
        long x = 0;

        if (System.BitConverter.IsLittleEndian)
        {
            x |= ((long)values[7] <<  0);
            x |= ((long)values[6] <<  8);
            x |= ((long)values[5] << 16);
            x |= ((long)values[4] << 24);
            x |= ((long)values[3] << 32);
            x |= ((long)values[2] << 40);
            x |= ((long)values[1] << 48);
            x |= ((long)values[0] << 56);
        }
        else
        {
            x |= ((long)values[0] <<  0);
            x |= ((long)values[1] <<  8);
            x |= ((long)values[2] << 16);
            x |= ((long)values[3] << 24);
            x |= ((long)values[4] << 32);
            x |= ((long)values[5] << 40);
            x |= ((long)values[6] << 48);
            x |= ((long)values[7] << 56);
        }

        UIntFloat uif = new UIntFloat();
        uif.longValue = (ulong)x;
        return uif.doubleValue;
    }

    /// <summary>
    /// Floatの値をByte列に変換、ビッグエンディアンとして返す
    /// </summary>
    /// <param name="value">変換するfloat値</param>
    /// <returns>ビッグエンディアンのByte配列</returns>
    static public byte[] ToBytes(float value)
    {
        UIntFloat uif = new UIntFloat();
        uif.floatValue = value;

        uint x = uif.intValue;
        byte a = (byte)((x >>  0) & 0xff);
        byte b = (byte)((x >>  8) & 0xff);
        byte c = (byte)((x >> 16) & 0xff);
        byte d = (byte)((x >> 24) & 0xff);

        if (System.BitConverter.IsLittleEndian)
        {
            return new[] { d, c, b, a };
        }
        else
        {
            return new[] { a, b, c, d };
        }
    }

    /// <summary>
    /// Doubleの値をByte列に変換、ビッグエンディアンとして返す
    /// </summary>
    /// <param name="value">変換するdouble値</param>
    /// <returns>ビッグエンディアンのByte配列</returns>
    static public byte[] ToBytes(double value)
    {
        UIntFloat uif = new UIntFloat();
        uif.doubleValue = value;

        ulong x = uif.longValue;
        byte a = (byte)((x >>  0) & 0xff);
        byte b = (byte)((x >>  8) & 0xff);
        byte c = (byte)((x >> 16) & 0xff);
        byte d = (byte)((x >> 24) & 0xff);
        byte e = (byte)((x >> 32) & 0xff);
        byte f = (byte)((x >> 40) & 0xff);
        byte g = (byte)((x >> 48) & 0xff);
        byte h = (byte)((x >> 56) & 0xff);

        if (System.BitConverter.IsLittleEndian)
        {
            return new[] { h, g, f, e, d, c, b, a };
        }
        else
        {
            return new[] { a, b, c, d, e, f, g, h };
        }
    }

    static public byte[] Serialize(params byte[][] bytesArray)
    {
        int total = 0;
        for (int i = 0; i < bytesArray.Length; i++)
        {
            total += bytesArray[i].Length;
        }

        byte[] result = new byte[total];
        int index = 0;
        for (int i = 0; i < bytesArray.Length; i++)
        {
            System.Array.Copy(bytesArray[i], 0, result, index, bytesArray[i].Length);
            index += bytesArray[i].Length;
        }

        return result;
    }

    static public float Deserialize(byte[] bytes, int start, int end)
    {
        int num = end - start;
        byte[] data = new byte[num];

        for (int i = 0; i < num; i++)
        {
            data[i] = bytes[start + i];
        }

        return ToFloat(data);
    }
}

実際に利用に足るものにするには、以下のUnityのNetworkBufferのように、様々な値を簡単に取り扱えるようにする必要があります。
各値をbyte配列にしたり、などを実装しているので参考にしてみるといいと思います。

Unity-Technologies / Networking / source / Runtime / NetworkBuffer.cs — Bitbucket

ちなみに上で登場しているUIntFloat構造体ですが、メモリレイアウトを明示することによって効率的にintfloatなどを相互変換しています。

細かい点については以下の記事を見てください。

ufcpp.net

そこで示されているのは以下のようなUnionライクな構造体です。

using System;
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Explicit)]
struct Union
{
    [FieldOffset(0)]
    public byte A;
    
    [FieldOffset(1)]
    public byte B;
    
    [FieldOffset(2)]
    public byte C;
    
    [FieldOffset(3)]
    public byte D;
    
    [FieldOffset(0)]
    public int N;
}

public class Hello
{
    public static void Main()
    {
        Union x = new Union{ N = 0x12345678 };
        Console.WriteLine(x.A.ToString("x")); // => 78
        Console.WriteLine(x.B.ToString("x")); // => 56
        Console.WriteLine(x.C.ToString("x")); // => 34
        Console.WriteLine(x.D.ToString("x")); // => 12
    }
}

プロトコルについて考える

最後に少しだけプロトコルについて。

上記記事から引用させてもらうと、

コンピューター同士が通信をする際の手順や規約などの約束事。ネットワークでコンピューターが使う言語のようなもので、双方が理解できる同じプロトコルを使わないと通信は成立しない。

ということ。

Low Level APIではデータ送信のルールは実装者が決める必要があります。
どういう順番でデータを格納し、先頭から何バイトまでがヘッダを表すのか、などです。

コンピュータが認識できるのは01のビット信号のみです。
そしてそれを「バイト」という単位で扱い、それがどういう順番で並び、それがどういう意味を持っているのか、は人間が考えないとなりません。

今回のサンプルでは位置同期だけを行い、さらには同期するオブジェクトはひとつだけに限定していたため、送られてきたデータが位置を表すx, y, zのfloat値が3要素分、と固定して解釈していました。

しかし、実際に使えるものにするためには、「どのオブジェクトの位置なのか」や「誰から送られてきたのか」などの制御が必要になってきます。

そしてそれらは、ヘッダなど「データの構造を示すデータ」を有することで実現します。
このヘッダなどの「メタデータ」が何バイトから何バイトまでか、を規定しそれに基づいて処理を行う仕様が「プロトコル」というわけですね。

なので、Low Level APIを使って実際に使えるレベルの通信を行うためにはまだ実装しないとならないことがたくさんあります。
が、最低限の通信に関してはそこまで複雑なことはやっていません。

これを元に、簡単な通信の仕組みを作れれば色々と捗りそうですね。