見出し画像

REALITYのUnity部分で使っているPNGテクスチャをASTCに置き換える大作戦 Now In REALITY Tech #126

残暑もようやく落ち着き、上海蟹が美味しい季節になりましたね。アバターシステムチーム エンジニアの若旦那です。

さて、今回はREALITYのUnity部分で扱っているテクスチャについて、パフォーマンス改善の観点から色々検証を行なった話をします。

REALITYのUnity部分で扱われているテクスチャの形式

通常のアバターアイテム・家具に使用されるテクスチャ

通常のアバターアイテムや、家具は、3Dアートの方がUnity上で直接組み込み作業を行いますので、これらのテクスチャはAssetBundleとしてビルドされます。ビルドされたAssetBundleに含まれるテクスチャの実フォーマットはASTC形式になっています。

ポスター家具・ユーザー制作アバターアイテムで使用されるテクスチャ

REALITYでは、画像を指定するだけで、家具やアバターアイテムを制作できるような仕組みがあります。この仕組みを使って、公式番組の特定シーンを切り抜いたポスター家具や、イベントの特典として制作されるオリジナルTシャツアバターアイテムが作られています。
ここでは運用上の利便性の観点から、AssetBundleは用いずに、PNG形式のファイルを動的にロードする方式が取られています。


ポスター家具とオリジナルTシャツ

ワールド内の掲示物

ワールド内には、サイネージなどの画像を表示するようなオブジェクトがあります。AssetBundleに含まれているASTC形式のものもありますが、差し替えを楽に行いたい観点からPNG形式になっているものもあります。

PNG形式テクスチャのパフォーマンスに対するインパクト

ASTC形式は、GPUで効率的に扱えるように設計されていますが、PNG形式の場合はGPUで直接扱えないため、ロード時にCPUでデコードし、フル解像度のテクスチャデータとしてメモリに展開されます。そのため処理負荷の上昇やメモリ効率の低下といったパフォーマンス上の悪影響があります。

アバターシステムチームにて、まず、ASTC化されているクローゼットのアイコンを全てPNGで扱った場合にどれほどのメモリ効率が悪化するか定量的な計測を行いました。

上の図は、2063件のクローゼットアイコンをASTC形式のAssetBundle経由でロードした場合と、GCS上のURLからPNG形式でロードした場合のメモリ使用量などの差を計測したときの結果を示しています。

今回はメモリ使用量の話がメインなので他の項目は割愛しますが、ASTC形式の場合1アイコンあたり38KBのメモリ使用に対して、PNG形式の場合1アイコンあたり581KBと、メモリ効率の観点でASTCとPNGでは15倍程度の差があることがわかります。

PNGテクスチャを利用している箇所のASTC移行に向けた取り組み

PNG形式をこのまま使い続けるのはパフォーマンス上よくないことがわかったので、現在PNGが使用されている箇所について、今後ASTCに移行できるようにエンジニアが様々な検証や実装を行いました。

UnityでファイルからASTCテクスチャを読み込めるようにする

もともと、REALITYのUnityでASTCのテクスチャを扱うには、AssetBundleビルドして読み込むというルートしかありませんでした。しかし、AssetBundleを使用する場合、リリースタイミングが限定される・扱いたい人がUnityを触る必要がある、などの運用上の不都合がありました。そこで、AssetBundleを用いずとも、インターネットからダウンロードした単体のASTCファイルをUnity側で扱えるようにする実装を行い、この問題に対応しました。

実装について、使用するUnityのAPIから辿って簡潔に説明します。

Unityには、Texture2D.LoadRawTextureData というAPIがあり、ASTC形式のバイナリから直接Textureを生成できます。

このAPIには、マネージドメモリ上のバイト配列の他、NativeArrayやバイト配列のポインタを引数に取るようなアンマネージドメモリから読み込めるオーバーロードが存在します。メモリ効率の観点から読み込んだファイルをマネージド領域には展開したくないので、ネイティブ領域(アンマネージドメモリ)から読み込むように実装してきます。

ファイルをネイティブ領域に読み込むAPIとして、AsyncReadManager があります。ReadCommandを作成し、このAPIを呼び出すと、バックグラウンドでファイルの読み込みを行なってくれます。戻り値のReadHandleを監視することで状態を確認できたりします。

// (補足) unsafeな処理を切り出しておく

private static unsafe ReadHandle AsyncReadManagerRead(string path, NativeArray<ReadCommand> readCommands, uint readCmdCount)
            => AsyncReadManager.Read(path, (ReadCommand*) readCommands.GetUnsafePtr(), readCmdCount);

private static unsafe ReadCommand CreateReadCommand(NativeArray<byte> buffer)
{
    return new ReadCommand
    {
        Offset = 0,
        Size = buffer.Length,
        Buffer = buffer.GetUnsafePtr(),
    };
}

// (中略)

// (補足) NativeArrayは、不要になったら明示的にDisposeする必要があります

// 実際にファイルを読み込む処理
var fileInfo = new FileInfo(path);
var readCommand = new NativeArray<ReadCommand>(1, Allocator.Persistent);
var buffer = new NativeArray<byte>((int) fileInfo.Length, Allocator.Persistent);
readCommand[0] = CreateReadCommand(buffer);
var readHandle = AsyncReadManagerRead(path, readCommand, 1);

読み込み終えたら、ASTCファイルのヘッダー情報が正しいかどうか・ファイルサイズが正しいかどうかを検証した後に、バイナリの開始からヘッダーの部分だけ飛ばしたポインタを前述のTexture2D.LoadRawTextureData に渡すことで、晴れてTextureのロードが完了します。

// ASTCのメタ情報を示す構造体 
public struct AstcMetadata
{
    public int BlockWidth;
    public int BlockHeight;
    public int TextureWidth;
    public int TextureHeight;
}

// ASTCファイルのメタデータ(ヘッダー)のサイズ
private const int AstcMetadataLength = 16;


public static Texture2D GetTextureFromBuffer(ReadOnlySpan<byte> buffer, bool updateMipmaps,
    bool makeNoLongerReadable)
{
    // ファイルを読み込み、ASTCのフォーマットを取得する
    if (!TryGetAstcFormat(buffer, out var astcMetadata))
    {
        return null;
    }

    // (中略) ここに、メタデータを元にファイルのサイズに問題ないかを確認する処理が入る。

    TextureFormat textureFormat;

    // (中略) ここに、メタデータを元に、テクスチャのフォーマットを取得する処理が入る

    var texture = new Texture2D(astcMetadata.TextureWidth, astcMetadata.TextureHeight, textureFormat, false);
  
    unsafe
    {
        fixed (byte* ptr = buffer)
        {
            // ここはASTCのヘッダー部分を含まない部分のみを渡す
            texture.LoadRawTextureData((IntPtr) ptr + AstcMetadataLength,
                astcMetadata.TextureWidth * astcMetadata.TextureHeight);
        }
    }

    texture.Apply(updateMipmaps, makeNoLongerReadable);
    return texture;
}

unsafeコードを使用しているため、ここでの検証を厳密に行わないとクラッシュにつながります。様々な異常ケースで問題ないことを、ユニットテストでも入念に確認しています。テストの例を一つだけ示しておきます。

/// <summary>
/// ASTCファイルからテクスチャを取得する
/// </summary>
public static async UniTask<Texture2D> GetAstcTextureFromFileAsync(string path, bool updateMipmaps, bool makeNoLongerReadable,
    CancellationToken ct)

[Test]
public async Task ASTCのファイルとして不正なサイズの時にGetAstcTextureFromFileAsyncからExceptionが発生すること()
{
    // 一時ファイルを作る
    var filePath = Path.GetTempFileName();

    // 解像度が4x4 の astc6x6
    var astc = new byte[]
    {
        0x13, 0xAB, 0xA1, 0x5C, 0x06, 0x06, 0x01, 0x00, 0x00, 0x04, 0x00, 0x00, 0x04, 0x01, 0x00, 0x00, 0xFC, 0xFD, 0xFC, 0xFD,
    };

    // ファイルに書き込む
    File.WriteAllBytes(filePath, astc);

    var tex = await AstcUtil.GetAstcTextureFromFileAsync(filePath, false, true, new CancellationToken());
    Assert.That(tex, Is.Null);
}


PNGをアップロードしたらサーバーでASTCに自動変換する仕組みを用意する

上の方で述べた通り、現在PNGを扱っている箇所では、運用の担当者がPNGファイルをブラウザからアップロードする方式になっています。今までPNGだったものの代わりにASTCでアップロードするだけでもいいのですが、ASTCファイルは一般的な形式ではなく、運用担当者に変換作業を手動でやってもらうとなると、ツールの導入やフローの整備などが必要になり面倒です。

そこで、サーバーエンジニアのうすぎぬさんが、CloudRun JobでアップロードされたPNGファイルをサーバー側にて自動でASTCファイルに変換する仕組みを検証してくださいました。

変換の流れ

まず、CloudRunで動かすASTCのエンコーダ部分について紹介します。
ARM社が公開しているOSSの astc-encoder がありますのでこちらを使用します。標準的なパッケージマネージャからインストールできますので、次のようなDockerfileで簡単にJobを作成できました。

FROM google/cloud-sdk:slim

WORKDIR /app

RUN apt update && apt install -y \
wget \
unzip \
astcenc \

COPY astc_compresser.sh .

RUN chmod 755 astc_compresser.sh

CMD ["./astc_compresser.sh"]

astc_compresser.shの内容はシンプルで、astc-encoder の変換コマンドを指定したファイルに対して呼び出しています。

次に、GCS上にPNGファイルがアップロードされるのを検知したらJobが立ち上がるように構成します。

main:
  params: [event]
  steps:
    - init:
        assign:
          - project_id: ${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}
          - event_bucket: ${event.data.bucket}
          - event_file: ${event.data.name}
          - event_file_type: ${event.data.contentType}
          - target_bucket: hogehoge
          - job_name: astc-encoder
          - job_location: asia-northeast1
    - check_input_file:
        switch:
          - condition: ${event_bucket == target_bucket and event_file_type == "image/png"}
            next: run_job
          - condition: true
            next: end
    - run_job:
        call: googleapis.run.v1.namespaces.jobs.run
        args:
          name: ${"namespaces/" + project_id + "/jobs/" + job_name}
          location: ${job_location}
          body:
            overrides:
              containerOverrides:
                env:
                  - name: INPUT_BUCKET
                    value: ${event_bucket}
                  - name: INPUT_FILE
                    value: ${event_file}
        result: job_execution
    - finish:
        return: ${job_execution}

CloudStorageのイベントでトリガーするようにWorkflowsを定義します。

以上で、サーバー側でPNGファイルのアップロードを検知したらASTCファイルに変換する、という一連が自動で行えるようになりました。


ブラウザでASTCに自動変換する仕組みを用意する

サーバー側で変換する仕組みの検証のほかに、ブラウザ側でPNGをASTCに変換してからサーバーにアップロードする方法も考えられます。この方法は、運用担当者が手動で変換しなくていいという問題を解決しつつ、サーバー側の経済的コストやメンテナンスコストを抑えることができます。

エンコードを実際に行う部分ですが、こちらでも、サーバー側と同様に astc-encoder を利用します。こちらのコードを Emscripten にてwasm向けにビルドして利用します。

astc-encoderを普通に利用する場合、コマンドラインツールとして実行ファイルがビルドされますが、今回はブラウザ上のデータに対して操作するため、データの入力を扱う部分をC++、JavaScript双方で若干の実装が必要になりました。

まず、C++側で画像を実際にエンコードさせる受け口の部分についてです。

    int encodeAstc(uint8_t* imageData, int width, int height, uint8_t* astcData, int astcSize, int blockWidth, int blockHeight) {

        astcenc_config config;
        config.block_x = blockWidth;
	    config.block_y = blockHeight;
	    config.profile = profile;

    	astcenc_error status = astcenc_config_init(ASTCENC_PRF_LDR, blockWidth, blockHeight, 1, ASTCENC_PRE_MEDIUM, 0, &config);

        if (status != ASTCENC_SUCCESS) {
            return -1;
        }

        // astcenc_contextの作成
        astcenc_context* context;

        status = astcenc_context_alloc(&config, 32, &context);
        if (status != ASTCENC_SUCCESS) {
            return -1;
        }

        unsigned int block_count_x = (width + blockWidth - 1) / blockWidth;
        unsigned int block_count_y = (height + blockHeight - 1) / blockHeight;

        astcenc_image image;
        image.dim_x = width;
        image.dim_y = height;
        image.dim_z = 1;
        image.data_type = ASTCENC_TYPE_U8;
        image.data = new void*[1];
        image.data[0] = imageData;


	    size_t comp_len = block_count_x * block_count_y * 16;
	    uint8_t* comp_data = new uint8_t[comp_len];

        if((int)comp_len + (int)sizeof(astc_header) > astcSize){
            // JavaScript側で確保したバッファーが小さすぎる
            return -1;
        }

        // ASTCエンコードの実行
        compression_workload work;
		work.context = context;
		work.image = &image;
		work.swizzle = swizzle;
		work.data_out = comp_data;
		work.data_len = comp_len;
		work.error = ASTCENC_SUCCESS;

		launch_threads("Compression", 32, compression_workload_runner, &work);

        if (status != ASTCENC_SUCCESS) {
            astcenc_context_free(context);
            return -1;
        }

        // ASTCファイルのヘッダーを付加する
        astc_header hdr = configureHeader(width, height, 1, blockWidth, blockHeight, 1);

        memcpy(astcData, reinterpret_cast<char*>(&hdr), sizeof(astc_header));
        memcpy(astcData + (int)sizeof(astc_header), comp_data, comp_len);
        astcenc_context_free(context);

        return (int)comp_len + (int)sizeof(astc_header) ; // 実際のASTCデータサイズを返す
    }

このコードは、ブラウザ側から呼ばれるC++を一部抜粋したものです。

入力画像や出力画像のポインタ、各種パラメータを受け取って実際にエンコーダを呼び出す関数を作成しています。入力画像の形式はPNGではなくデコード済みのRGBAのバイト列を想定しています。

次に、ブラウザ側で画像を読み込んでこの関数に渡す部分について軽く説明します。

const reader = new FileReader();
reader.onload = function () {
    const img = new Image();
    img.onload = function () {

        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');
        canvas.width = img.width;
        canvas.height = img.height;
        ctx.drawImage(img, 0, 0);
        const imageData = ctx.getImageData(0, 0, img.width, img.height);

        encode(imageData.data, img.width, img.height);
    };
    img.src = reader.result;
};

// fileはinput要素から取得したファイル
reader.readAsDataURL(file);


function encode(data, width, height) {
    const ptr = Module._malloc(data.length);
    Module.HEAPU8.set(data, ptr);

    // ASTCエンコードを実行
    const astcSize = 1024 * 1024 * 5; // 仮の出力バッファサイズ
    const astcPtr = Module._malloc(astcSize);
    const encodedSize = Module._encodeAstc(ptr, imageWidth, imageHeight, astcPtr, astcSize, 6, 6);

    // ASTCファイルの取得
    const astcData = new Uint8Array(Module.HEAPU8.buffer, astcPtr, encodedSize).slice(0, encodedSize);

    // メモリ解放
    Module._free(ptr);
    Module._free(astcPtr);

    return new Blob([astcData], { type: 'application/octet-stream' });

}

ファイルのアップロードをクリックした際に、input要素からファイルを取得し、これをOffscreenなCanvasに描画します。
そのあと、CanvasRenderingContext2D.getImageData()からRGBA配列を得られます(実際にはC++側で定義している配列とピクセルが上下反転するのでその辺はうまく操作する必要があります)。これをModule._mallocで確保した領域に格納して、C++側のエンコードを呼び出すことで、指定した出力バッファーからデータを取得することができます。

以上のような実装を行うことで、ブラウザ側で、PNGファイルを自動でASTCファイルに変換した上でアップロードすることができました。


最後に

記事で紹介したものの他に、例えば「REALITYアプリでPNGをダウンロードした後に、端末側でComputeShaderを用いてASTCに変換してキャッシュする」という手法も最近話題に上がり、今後検証のフェーズに進んでいくのではないかと思います。このように課題に対して様々なアプローチを考え、検証を行い、改善に取り組むというフローは今回の事例以外でもよく取られています。
私個人としては、エンジニアリングの仕事としての根幹的な楽しさがここにあると考えていたので、今回はこの事例について取り上げさせていただきました!

また、REALITYでは、ASTC化以外にも、様々な場面でパフォーマンスをチューニングしています。より軽量なアプリを提供できるよう努めてまいりますので、今後もご愛顧賜りますようお願いいたします。