Chapter 17

🌶 通信データの最適化

o8que
o8que
2021.03.07に更新

Photonの1メッセージの最大サイズは1200バイトです。複数の小さい送信データは、可能な限り1つのメッセージにまとめられて送信されます。大きい送信データは、複数のメッセージに分割して送信されます。データのサイズを減らすことで、通信のパフォーマンスを改善することができます。

小さいサイズのデータ型を使う

  • 小さい整数値なら、int型よりshort型、short型よりbyte型を使う
  • Vector3型のzの値が不要なら、Vector2型を使う
  • 座標がグリッドで表現されているなら、グリッド番号を使う
    (例えば、将棋の盤面は計81マスなので、Vector2型の代わりにbyte型の0~80で座標を指定できます)
  • Quarternion型はオイラー角でも問題がないなら、Quaternion.eulerAnglesのVector3型を使う
  • 2Dゲームなどで回転軸が決まっているなら、Quarternion型の代わりにfloat型を使う

値の精度を落とす

例えば、角度(degree)はfloat型で通信すると、型情報含めて5バイトが必要になります。小数点以下を切り捨てて、short型で通信すれば、3バイトになります。角度を0°~360°の範囲に正規化し、送信時には値を半分にして、受信時には値を倍にして戻すようにすれば、byte型の範囲(0~255)で通信できるようになるため、サイズを2バイトに減らせます。

using UnityEngine;

public static partial class GameProtocol
{
    // 角度(degree)をbyte型のデータに変換するメソッド
    public static byte SerializeAngle(float degree) {
        // 角度を0°~359°に正規化する
        int normalized = Mathf.FloorToInt(degree + 36000f) % 360;
        return (byte)(normalized / 2);
    }

    // byte型のデータを角度(degree)に変換するメソッド
    public static float DeserializeAngle(byte bytes) {
        return bytes * 2f;
    }
}

ビットパッキング

byte型は、要素数8のビット列とみなすことができます。同じようにint型は、要素数4のバイト列(要素数32のビット列)とみなすことができます。ビット演算を使って、1つの値の中に複数の値を詰め込めば、サイズを減らすことができます。この方法はビットパッキングと呼ばれます。

Color型とVector2Int型を、Photonでサポートしてるデータ型で通信できるようにしてみましょう。

using UnityEngine;

public static partial class GameProtocol
{
    // Color型をint型のデータに変換するメソッド
    public static int SerializeColor(Color color) {
        Color32 color32 = color;
        return (color32.r << 24) | (color32.g << 16) | (color32.b << 8) | color32.a;
    }

    // int型のデータをColor型に変換するメソッド
    public static Color DeserializeColor(int bytes) {
        return new Color32(
            (byte)((bytes >> 24) & 0xFF),
            (byte)((bytes >> 16) & 0xFF),
            (byte)((bytes >> 8) & 0xFF),
            (byte)(bytes & 0xFF)
        );
    }

    // Vector2Int型をlong型のデータに変換するメソッド
    public static long SerializeVector2Int(Vector2Int vector2) {
        return ((long)vector2.x << 32) | (long)vector2.y;
    }

    // long型のデータをVector2Int型に変換するメソッド
    public static Vector2Int DeserializeVector2Int(long bytes) {
        return new Vector2Int(
            (int)((bytes >> 32) & 0xFFFFFFFF),
            (int)(bytes & 0xFFFFFFFF)
        );
    }
}

さらに応用例として、以下のようなプレイヤーのステータスを通信することを考えてみましょう。

  1. 性別(0:男性、1:女性)- 必要なビット数1(範囲 : 0~1)
  2. レベル(最大99)- 必要なビット数7(範囲 : 0~127)
  3. HP(最大9999)- 必要なビット数14(範囲 : 0~16383)
  4. MP(最大999)- 必要なビット数10(範囲 : 0~1023)

各ステータスをそのままint型で通信すると、型情報を含めて20バイトが必要です。性別とレベルをbyte型、HPとMPをshort型で通信するようにすれば、10バイトに減らせます。さらに、各パラメーターが取りうる値の範囲を表現できるビット数を求めると、ちょうど合計32ビットになるので、1つのint型に全部を詰め込むことで、5バイトまで圧縮できます。

public static partial class GameProtocol
{
    // プレイヤーのステータスをint型のデータに変換するメソッド
    public static int SerializePlayerStatus(int gender, int lv, int hp, int mp) {
        return (gender << 31) | (lv << 24) | (hp << 10) | mp;
    }

    // int型のデータをプレイヤーのステータスに変換するメソッド(戻り値はタプルで返す)
    public static (int gender, int lv, int hp, int mp) DeserializePlayerStatus(int bytes) {
        return (
            (bytes >> 31) & 0b_1,
            (bytes >> 24) & 0b_11_11111,
            (bytes >> 10) & 0b_1111_11111_11111,
            bytes & 0b_11111_11111 
        );
    }
}

浮動小数点数の圧縮

float型は非常に広い範囲の数値を表現でき、大抵のケースでは十分な精度があります。しかし通信データのサイズを極力小さくしたい場合には、必要以上の精度の高さは無駄にサイズを増やしてしまう問題点へと変わります。ここではfloat型の値のデータのサイズを減らす方法について考察します。

整数(固定小数点数)に変換する

例えば、float型の値を100倍して四捨五入すれば、0.01刻みの実数値をint型の整数値で表現することができます。すると、値のとりうる範囲によってbyte型やshort型にしたり、ビット演算で任意のビット数に切り詰めたりすることができます。以下は、-300.00~300.00の座標の値を0~60000の(16ビット)整数に変換して、int型へ詰め込むサンプルです。

public static partial class GameProtocol
{
    public static int SerializePosition(Vector2 position) {
        int px = Mathf.RoundToInt((Mathf.Clamp(position.x, -300f, 300f) + 300f) * 100f);
        int py = Mathf.RoundToInt((Mathf.Clamp(position.y, -300f, 300f) + 300f) * 100f);
        return (px << 16) | py;
    }

    public static Vector2 DeserializePosition(int bytes) {
        float px = ((bytes >> 16) & 0xFFFF) / 100f - 300f;
        float py = (bytes & 0xFFFF) / 100f - 300f;
        return new Vector2(px, py);
    }
}

この方法は、とても簡単ですが効果的です。値にかかわらず分解能の大きさ(上の例では0.01刻み)を一定にできるため、オブジェクトの座標などのデータを圧縮するのに適しています。一方で、オブジェクトの速度などの有効桁数の方が重要なデータでは無駄な冗長さが発生する可能性があります。ここで、以下のような例を考えてみましょう。

  • 1から2への移動と、101から102への移動(オブジェクトの座標)の見え方
  • 1から2への加速と、101から102への加速(オブジェクトの速度)の見え方

オブジェクトの座標は、同じ移動量ならその変化の見え方も同じになるので、分解能の大きさが一定であることはメリットになります。それに対してオブジェクトの速度は、同じ加速度でも速度が大きくなるにつれて変化が見えにくくなるため、その際の誤差程度の違いを細かく表現できる分だけ無駄に大きなデータサイズが必要になるでしょう。

半精度浮動小数点数に変換する

半精度浮動小数点数は16ビットで表現される浮動小数点数です。従来のfloat型(単精度浮動小数点数)より値の範囲や精度が大きく落ちますが、データサイズが半分になります。C#では(2020年末の時点で)まだサポートされていない型ですが、Unityでは公式PackageのMathematicsにhalf型が用意されているので、まずはそれを使ってみましょう。

以下は、2Dオブジェクトの速度の値をhalf型に変換して、int型へ詰め込むサンプルです。

public static partial class GameProtocol
{
    public static int SerializeVelocity(Vector2 velocity) {
        int vx = new half(velocity.x).value;
        int vy = new half(velocity.y).value;
        return (vx << 16) | vy;
    }

    public static Vector2 DeserializeVelocity(int bytes) {
        float vx = new half { value = (ushort)((bytes >> 16) & 0xFFFF) };
        float vy = new half { value = (ushort)(bytes & 0xFFFF) };
        return new Vector2(vx, vy);
    }
}

絶対値が大きくなるほど分解能も大きくなっていく浮動小数点数の性質は、オブジェクトの速度などのデータを圧縮するのに適しています。逆にオブジェクトの座標などのデータは、整数に変換できるならそうした方が良いでしょう。

指数部のバイアスを調整する

半精度浮動小数点数はゲームで扱う分には十分に小さな値を表現できますが、逆に最大値は65504と大きな値を表現したい場合には少し心もとありません。もしここで浮動小数点数の指数部のバイアスを調整できれば、同じデータサイズの中で表現できる値の範囲を変えることができます。ただし、浮動小数点数の型の指数部のバイアスを調整する機能が用意されていることは通常はないため、そういった機能を持つ変換ライブラリを探すか自前で実装する必要があります。以下に筆者が作成した浮動小数点数表現を変換するクラスを公開していますので、ここからはこれを使って話を進めていきます。

LowPrecisionFloatFormatter.cs

LowPrecisionFloatFormatterクラスはコンストラクタの引数で、float型から変換したい浮動小数点数(ここでは半精度浮動小数点数)の符号部ビット数(1ビット)・指数部ビット数(5ビット)・仮数部ビット数(10ビット)を指定します。ToDetailString()から浮動小数点数フォーマットの詳細な情報を確認できるので、表示してみると以下のようになります。

Debug.Log(new LowPrecisionFloatFormatter(1, 5, 10).ToDetailString());
// (1, 5, 10, 15, true, true)     <-- 符号部・指数部・仮数部・指数部のバイアス・0予約フラグ・NaN予約フラグ
// Range: (6.103516E-05, 65504)   <-- 絶対値の最小値と最大値
// ULP: (5.960464E-08, 32)        <-- (値が最も0に近い時の)最小分解能と(値が最も最大値に近い時の)最大分解能

IEEE 754標準の半精度浮動小数点数の指数部のバイアスは15なので、指数部で2^{-14}~2^{15}が表現されています。LowPrecisionFloatFormatterはコンストラクタのオプション引数から指数部のバイアスが指定できるので、例えば、この値を8に指定すると、指数部で表現される範囲を2^{-7}~2^{22}にして値を変換できるようになります。

Debug.Log(new LowPrecisionFloatFormatter(1, 5, 10, 8u).ToDetailString());
// (1, 5, 10, 8, true, true)
// Range: (0.0078125, 8384512)
// ULP: (7.629395E-06, 4096)

以下は、2Dオブジェクトの速度の値をLowPrecisionFloatFormatterを使って変換して、int型へ詰め込むサンプルです。

public static partial class GameProtocol
{
    private static readonly LowPrecisionFloatFormatter format = new LowPrecisionFloatFormatter(1, 5, 10, 8u);

    public static int SerializeVelocity(Vector2 velocity) {
        int vx = (int)format.Serialize(velocity.x);
        int vy = (int)format.Serialize(velocity.y);
        return (vx << 16) | vy;
    }

    public static Vector2 DeserializeVelocity(int bytes) {
        float vx = format.Deserialize((uint)((bytes >> 16) & 0xFFFF));
        float vy = format.Deserialize((uint)(bytes & 0xFFFF));
        return new Vector2(vx, vy);
    }
}

指数部のバイアスを8に調整したことで、データサイズは半精度浮動小数点数と同じ16ビットのままで表現できる値の最大値が8384512となり、1000000のような値でも問題なく変換できるようになります。ただし当然ですが、精度は大きく落ちているため、復元される値は元の値との誤差が大きい可能性があることに注意です。

var format = new LowPrecisionFloatFormatter(1, 5, 10, 8u);

Debug.Log(format.Deserialize(format.Serialize(1000000f)));
// 999936

指数部の予約値を除外する

浮動小数点数の指数部の最小値と最大値は、特殊な値(0、非正規化数、無限大、NaN)表現のために予約されています。LowPrecisionFloatFormatterはコンストラクタのオプション引数から、それぞれの特殊な値を予約するか除外するかのフラグを指定できます。特殊な値を通信する必要がない場合に除外して、その分の空きを通常の値表現に使用することで、わずかに表現できる値の範囲を増やすことができます。これは指数部のビット数が小さいほどメリットが大きくなるでしょう。

Debug.Log(new LowPrecisionFloatFormatter(1, 5, 10, reservedFor0: false, reservedForNaN: false).ToDetailString());
// (1, 5, 10, 15, False, False)
// Range: (3.051758E-05, 131008)
// ULP: (2.980232E-08, 64)

Debug.Log(new LowPrecisionFloatFormatter(1, 5, 10).ToDetailString());
// (1, 5, 10, 15, True, True)
// Range: (6.103516E-05, 65504)
// ULP: (5.960464E-08, 32)

ちなみに特殊な値を除外した状態で特殊な値を変換すると、0は最小値で、無限大やNaNは最大値で復元されるようにしています。

var format = new LowPrecisionFloatFormatter(1, 5, 10, reservedFor0: false, reservedForNaN: false);

Debug.Log(format.Deserialize(format.Serialize(0f)));
// 3.051758E-05

Debug.Log(format.Deserialize(format.Serialize(float.PositiveInfinity)));
// 131008

任意のビット数の浮動小数点数に変換する

LowPrecisionFloatFormatterは、符号部のビット数・仮数部のビット数・指数部のビット数をfloat型のビット数を超えない範囲の任意のビット数に指定できて、さらに指数部のバイアスや予約された値の除外なども指定できます。これらすべてを組み合わせることで、半精度浮動小数点数よりデータサイズが小さく、かつ必要な分だけの範囲と精度を持った、カスタムの浮動小数点数フォーマットを作り出すことができます。

サンプルとして、3Dオブジェクトの速度をint型に詰め込んで通信したいとします。そのために、各成分は10ビット程度で表現できると良さそうです。速度の値は-100~100の範囲内でかつ絶対値の最小値は0.01以下であれば良いとしましょう。条件を満たすような浮動小数点数フォーマットを作ることができたら、後は他のサンプルと同じように値を変換します。

Debug.Log(new LowPrecisionFloatFormatter(1, 4, 5, 9u, true, false).ToDetailString());
// (1, 4, 5, 9, True, False)
// Range: (0.00390625, 126)
// ULP: (0.0001220703, 2)
public static partial class GameProtocol
{
    private static readonly LowPrecisionFloatFormatter format = new LowPrecisionFloatFormatter(1, 4, 5, 8u);

    public static int SerializeVelocity(Vector3 velocity) {
        int vx = (int)format.Serialize(velocity.x);
        int vy = (int)format.Serialize(velocity.y);
        int vz = (int)format.Serialize(velocity.z);
        return (vx << 20) | (vy << 10) | vz;
    }

    public static Vector3 DeserializeVelocity(int bytes) {
        float vx = format.Deserialize((uint)((bytes >> 20) & 0b_11111_11111));
        float vy = format.Deserialize((uint)((bytes >> 10) & 0b_11111_11111));
        float vz = format.Deserialize((uint)(bytes & 0b_11111_11111));
        return new Vector3(vx, vy, vz);
    }
}