Color型をuintにして12バイト節約したら2倍以上高速になった話

Color型はuintに変換することができます。
いきなり方法を述べると下記の通り。
Color型のRGBA成分をそれぞれbyte型に変換し、1つのuintに詰め込めばOKです。

/// <summary>
/// Color型のRGBA成分をbyteに変換し、uintに変換する
/// </summary>
public static uint PackColor(in Color color01)
{
    Color color256 = color01 * 255.0f;
    return (uint)(((byte)color256.r << 24) | ((byte)color256.g << 16) | ((byte)color256.b << 8) | (byte)color256.a);
}

uintを再びColor型に戻すには逆の処理をします。

/// <summary>
/// 4分割してuint型に入れたRGBA成分を元に戻し、Color型に変換する
/// </summary>
public static Color UnpackColor(uint packedColor)
{
    Color color256 = new Color((packedColor & 0xFF000000) >> 24, (packedColor & 0x00FF0000) >> 16, (packedColor & 0x0000FF00) >> 8, packedColor & 0x000000FF);
    return color256 / 255.0f;
}

1つ1つ解説します。
Color型をuint型に変換すると何が良いのでしょうか?

それはバイト数を見れば分かります。
試しにColor構造体の中身を見てみましょう。実際はメソッドなどが定義されていますが、省いて記載しています

public struct Color
{
	public float r;
	public float g;
	public float b;
	public float a;
}

Color構造体は4つのfloatの集合です。
floatは4バイトなので、4×4で16バイトの構造体ですね。

16バイトのColor型に対して、uintは4バイトしかありません。
Color型をuint型に変換することで12バイトの節約になります。
これがColor型をuint型に変換するメリットです。

ここで「でも結局最後はColor型に戻すんでしょ? 意味なくない?」
と思うのは至極当然です。

私は大量のColor型をGPUに送る必要があり、その一連の処理にある程度の処理時間が掛かっていました。
使用していた構造体は以下のようなものです。

// 四辺形のデータ構造体
public struct QuadProperty
{
    // 四辺形の頂点の位置
    // ※実際に私が使用しているものにはfloat3x4としてまとめて定義していますが、解説のためにこうしています
    public Vector3 PositionA;
    public Vector3 PositionB;
    public Vector3 PositionC;
    public Vector3 PositionD;
    // 四辺形の頂点の色
    public Color ColorA;
    public Color ColorB;
    public Color ColorC;
    public Color ColorD;

    public QuadProperty(Vector3 posA, Vector3 posB, Vector3 posC, Vector3 posD, Color colorA, Color colorB, Color colorC, Color colorD)
    {
        this.PositionA = posA;
        this.PositionB = posB;
        this.PositionC = posC;
        this.PositionD = posD;
        this.ColorA = colorA;
        this.ColorB = colorB;
        this.ColorC = colorC;
        this.ColorD = colorD;
    }
}

要するに下記のようなものを描画するための構造体です。

4つの頂点と色があるQuad
4つの頂点と色がある四辺形

Vector3は3つのfloatの構造体、それが4つあるので3×4×4で48バイトあります。
Colorは前述の通りで、4×4×4で64バイトです。
なので、このQuadProperty構造体は合計112バイトあります。

そこでColor型をuint型に変換し、合計64バイトに節約しました。
お気づきの方はすみません。タイトル詐欺です。実際には合計112バイトから12×4=48バイト節約して合計64バイトになったのです

// 四辺形のデータ構造体
public struct QuadProperty
{
    // 四辺形の頂点の位置
    public Vector3 PositionA;
    public Vector3 PositionB;
    public Vector3 PositionC;
    public Vector3 PositionD;
    // 四辺形の頂点の色
    public uint PackedColorA;
    public uint PackedColorB;
    public uint PackedColorC;
    public uint PackedColorD;

    public QuadProperty(Vector3 posA, Vector3 posB, Vector3 posC, Vector3 posD, Color colorA, Color colorB, Color colorC, Color colorD)
    {
        this.PositionA = posA;
        this.PositionB = posB;
        this.PositionC = posC;
        this.PositionD = posD;
        this.PackedColorA = SomethingClass.PackColor(colorA);
        this.PackedColorB = SomethingClass.PackColor(colorB);
        this.PackedColorC = SomethingClass.PackColor(colorC);
        this.PackedColorD = SomethingClass.PackColor(colorD);
    }
}

このQuadProperty構造体のインスタンスをC# Job Systemで大量に生成し、NativeArrayに詰めて、Graphics.RenderMeshIndirect()を通してGPUに送っています。
これを毎フレーム行っています。
※Graphics.RenderMeshIndirect()はGraphics.DrawMeshInstancedIndirect()とほぼ同じメソッドです。
 引数がRenderParamsという構造体にまとまったもので、やることは一緒です。
 GPUインスタンシングで同じマテリアルとメッシュに異なる頂点データを与えることができ、1度のドローコールで大量に描画できます。
 (後からGPUインスタンシングと256頂点未満のメッシュは相性が悪いことを知ったけど、それは見なかったことに……)

本題ですが、バイト数を節約したことでこの部分の処理時間が半分程度になりました。

Color型をuint型に変換する前
サイズ削減前(112バイト)
Color型をuint型に変換した後
サイズ削減後(64バイト)

なお、構造体のリストを受け取ったGPU側ではシェーダーを通して描画しています。
冒頭に記載したUnpackColor()に相当する処理をシェーダーで行った上で描画しているということですね。

画像だと文字が見切れていますが、Render Threadの右の方にあるCamera.Renderの時間も短くなっています。
3.65ms→1.62msと結構衝撃的な減り方をしていますね。
フレームごとにブレはありましたが、概ね2msの削減で合っていました。

具体的にどこが短くなったのかまでは確認しなかったので、ボトルネックになっていたのがCPU側なのかGPU側なのかは分かりませんが、GPU側には今回の副産物があります。
シェーダー側で行っていた一部のUV処理においてfloat(もしくはhalf)で計算していたものをuintとして計算するようにしたので、少なくともGPU側は速くなっているはずです。
CPUだと整数の方が圧倒的に演算が速いのは有名ですが、GPUでもそれは同じなのでしょうね。
浮動小数点数(FP32、FP16)よりも整数(INT32)が速いのは感覚的に当然です。

このテストを行った際にはQuadProperty構造体を5万個近く生成していたので、GPUには概ね5.6MBのデータを送信していたことになります。
テスト環境のメモリはDDR4-2666(最大21.3GB/sの転送速度)なので、最速でも約0.26msかかる計算です。
これが3.2MBに削減され、最速約0.15msになりました。
(具体的な数字を出すとそれほど速くならないと感じますが、転送速度だけでこの時間ということです)

その処理の前に、CPU内でもC# Job Systemで生成したデータをリストに詰め込む処理があります。
こちらは具体的な処理時間を出すのが難しいのですが、5万要素、合計5.6MBをリストに追加する処理となれば、それなりの時間になるのは想像に難くないでしょう。

1.4ms程度から0.75ms程度に減ったのはデータ量を節約したことによる影響以外にありません。
構造体のサイズに影響される処理は他にもあるので、最終的にフレームレートが20~30fpsほど向上しました。

こういうシチュエーションにおいては有効なテクニックなんですね。

Windowsのペイントのカラーパレット
Windowsのペイントのカラーパレット。赤、緑、青はそれぞれ0~255まで入力できる

さて、色情報についても少しお話します。

RGBは256階調でフルカラーとされます。
一般的なディスプレイはフルカラーで1677万7216色表現できるのですが、それはRGBが256階調だからです。
256×256×256=16,777,216通りの表現ができるということです。

Color型のRGBは0.0~1.0のfloat型になっています。
Unityのカラーパレットで一見0~255に見えるものでも、内部的には0.0~1.0になります。

Unityのカラーパレット(0~256)
Unityのカラーパレット(0~255)
Unityのカラーパレット(0~1)
Unityのカラーパレット(0.0~1.0)

「最初から最後まで0~255の値でいいじゃん」と思うかもしれませんが、世の中には一般的なフルカラーを超えるディープカラー(10億色表示が可能)に対応したディスプレイもあり、様々な環境に対応するために0.0~1.0の数値に正規化する必要があるわけです。

ただディープカラーに対応したディスプレイというのは現状は主にデザイナー向けなどで、非常に高額です。
GPUがディープカラーに対応しているとも限りません。
また素材面でも制約があり、例えばJPEG画像だとディープカラーに対応できません。

ディスプレイ、GPU、画像ファイルなどすべてが対応していなければディープカラーにはなりません。
ということで、一般的には0.0~1.0の値は256階調として表現されます。

アプリケーション側となるUnityは0.0~1.0に正規化した数値を用いることで、内部的にはディープカラー以上の色に対応しています。
今回はそのディープカラー対応をわざと切り捨てて112→64バイトに削減した、ということになります。

前置きが長くなりましたが、0~255で表現できるなら、数値を8bit(1バイト)に収めることができますね。
つまりRGBはbyte型に変換できます。
A成分も256段階として扱えば、RGBAは4つのbyte型になります。
具体的には0.0~1.0で表現されたRGBAに255をかけた上でbyte型にキャストすればOKです。

uint型は4バイトなので、この4つのbyte型を1つのuint型に詰め込むことができます。
冒頭で行っていたビット演算はそういうことです。

/// <summary>
/// Color型のRGBA成分をbyteに変換し、uintに変換する
/// </summary>
public static uint PackColor(in Color color01)
{
    Color color256 = color01 * 255.0f;
    return (uint)(((byte)color256.r << 24) | ((byte)color256.g << 16) | ((byte)color256.b << 8) | (byte)color256.a);
}

/// <summary>
/// 4分割してuint型に入れたRGBA成分を元に戻し、Color型に変換する
/// </summary>
public static Color UnpackColor(uint packedColor)
{
    Color color256 = new Color((packedColor & 0xFF000000) >> 24, (packedColor & 0x00FF0000) >> 16, (packedColor & 0x0000FF00) >> 8, packedColor & 0x000000FF);
    return color256 / 255.0f;
}

ちなみにbyte型はシェーダー側では定義できず、uintとして扱うと都合が良く、良いことずくめです。
(余談ですが、昔のWebGLはビット演算ができなかったとか)

この辺の話はかなりローテクなのですが、まさか今になってやることになるとは思っていませんでした。
オブジェクト指向的に抽象化するスキルも必要ですが、ゲームプログラミングって結構ハードウェア寄りだなぁと感じています。

  • URLをコピーしました!
目次