XRコンテンツで厳禁の処理落ちを軽減! AsyncReadManagerで画像を非同期読み込みする


KeyVisual

概要

AsyncReadManagerとは、アンマネージドなネイティブ側の機能を利用したUnityのAPIです。これを利用して画像読み込み・表示の処理負荷を減らし、AR/VRの天敵であるFPSを改善します。

複数の画像を、標準のUnity API(texture.LoadImage(bytes))を使って読み込むとメインスレッドを止めてしまうため、プチフリーズ状態になります。これを回避しようというのが今回の記事の主旨です。

実際にAndroid端末で動作させてみたのが以下の動画です。Loadボタン押下時は同期的にすべての画像を読み込んでいるため若干カクついています。一方、LoadAsyncボタンを押した際は AsyncReadManager を使って非同期で画像を読み込んでいるためカクつきが発生していません。

また各テクスチャの読み込み時間もかなり短縮され、Pixel3aでテストしたところ、平均読み込み時間は5倍程度早くなりました。

読み込み方法 平均読み込み時間(per texture)
LoadImage 80ms
AsyncReadManager 16ms

GitHubにてサンプルプロジェクトを公開

今回実装した内容は以下のGitHubに公開しているので実際の動作を見ながら記事を読んでいただくとより理解が深まると思います。

https://github.com/MESON-inc/AsyncReadManagerSample

LoadImageが遅い

通常、テクスチャデータを動的にロードする場合、一番シンプルな方法は File.ReadAllBytes(filePath) などを利用してバイトデータをロードし、それを LoadImage(byteArray) に読み込ませる方法でしょう。

byte[] data = File.ReadAllBytes(filePath);
Texture2D tex = new Texure2D(1, 1);
tex.LoadImage(data);

これはとてもシンプルなAPIですが問題があります。ファイルのロードと圧縮されている画像の展開処理による負荷です。なにも気にせず上記の処理を書いてしまうと、ファイルサイズの大きなテクスチャを複数枚読み込むとカクつきの原因となってしまいます。

通常のゲームであれば、ローディング画面などで多少のフリーズが発生しても問題にならないケースもあると思いますが、ことXRコンテンツにおいては常にフリーズを発生させないように注意する必要があります。今回の記事で紹介する AsyncReadManager はそうしたフリーズを防止するひとつの方法となると思います。

非同期読み込みと事前展開で負荷軽減

主な処理負荷の原因は前述の通り、マネージドなUnity APIによる画像の展開処理です。なのでこれを解消するためにはメインスレッド外でファイル読み込みと展開を済ませ、テクスチャの生成のみをメインスレッドで行う、ということでこれを解決します。

結論から言えば、重かったUnityのマネージド領域での画像展開をやめ、非同期でファイル読み込みつつ、展開済みのデータを読み込ませることで高速化を図っています。

非同期ファイル読み込みにAsyncReadManagerを使う

今回の主題のひとつ、 AsyncReadManager。このAPIはUnityのVirtual file systemを利用して非同期にIO処理ができるAPIです。ドキュメントには以下のように記載されています。

With the AsyncReadManager, you can perform asynchronous I/O operations through Unity's virtual file system. You can perform these operations on any thread or job.

AsyncReadManager を使用すると、Unityの仮想システムを通して非同期的にI/O処理を行うことができます。これらの処理は他のスレッドまたはジョブで行うことができます。

https://docs.unity3d.com/ScriptReference/Unity.IO.LowLevel.Unsafe.AsyncReadManager.html

今回はこのAPIを利用して非同期でファイルを読み込ませます。またうれしいことに、このAPIでは読み込んだファイルの内容をポインタ( void* )で受け取ることができます。この次に説明する texture.LoadRawTextureDataIntPtr をそのまま指定できるため親和性が高くなっています。

この読み込みを行っている箇所のコード断片は以下です。

ImageInfo info = ImageConverter.Decode(ptr, (int)size);

Texture2D texture = new Texture2D(info.header.width, info.header.height, info.header.Format, false);
texture.LoadRawTextureData(info.buffer, info.fileSize);
texture.Apply();

RawDataにメタデータを埋め込む

実は今回のサンプルでは AsyncReadManager の読み込みだけでは対応できない点がひとつあります。それは、RawDataとして保存されたバイト配列にはもはやテクスチャの幅と高さ、フォーマット情報が含まれていないということです。そのためそのデータをバイト配列に埋め込む形で対応しました。その埋め込みをしている部分が以下です。

public static byte[] Encode(byte[] data, int width, int height, TextureFormat format)
{
    if (data == null)
    {
        throw new ArgumentNullException();
    }

    byte[] widthData = BitConverter.GetBytes(width);
    byte[] heightData = BitConverter.GetBytes(height);
    byte[] formatData = BitConverter.GetBytes((int)format);

    int headerSize = widthData.Length + heightData.Length + formatData.Length;
    byte[] result = new byte[headerSize + data.Length];

    // Write the header info.
    Array.Copy(widthData, 0, result, 0, widthData.Length);
    Array.Copy(heightData, 0, result, widthData.Length, heightData.Length);
    Array.Copy(formatData, 0, result, widthData.Length + heightData.Length, formatData.Length);

    // Write the data.
    Array.Copy(data, 0, result, headerSize, data.Length);

    return result;
}

texture.GetRawTextureData() で取り出したデータの先頭に、幅と高さ、フォーマットの情報をバイト配列として埋め込んでおきます。

AsyncReadManager で取得できるのがポインタであることも相性がよく、デコードもシンプルに行うことができます。以下がそのデコード処理部分です。

public static unsafe ImageInfo Decode(IntPtr pointer, int fileSize)
{
    ImageHeader header = ImageHeader.Parse(pointer);

    byte* p = (byte*)pointer;

    // Proceed the pointer position to under its header.
    p += ImageHeader.Size;

    return new ImageInfo
    {
        header = header,
        buffer = (IntPtr)p,
        fileSize = fileSize,
    };
}

頭のヘッダを解析したのち、ヘッダサイズだけポインタを進めるだけでそのまま LoadRawTextureData() に渡せるのでとてもシンプルになります。

画像の読み込み処理

実際に読み込みを行っている部分を紹介します。まずは LoadImage を使って同期的に読み込みを行っている部分です。

private void Load(FileList fileList)
{
    Stopwatch sw = new Stopwatch();
    foreach (string filename in fileList.Filenames)
    {
        string path = fileList.GetPersistentDataPath(filename);

        byte[] data = File.ReadAllBytes(path);
        
        sw.Restart();
        
        Texture2D texture = new Texture2D(0, 0);
        texture.LoadImage(data);
        
        sw.Stop();

        _loadAnalyzeData.Add(sw.ElapsedMilliseconds);

        CreatePreview(texture, filename);
    }
    
    Debug.Log($"Sync avg [{_loadAnalyzeData.Count}]: {_loadAnalyzeData.GetAverage().ToString()}ms");
    
    UpdateTimeText(sw.ElapsedMilliseconds);
}

こちらは特に説明は必要ないでしょう。普通に File.ReadAllBytes でデータを読み込ませているだけです。(読み込みデータは今回の例では JPG です)

次は非同期読み込み版です。

private async void LoadAsync(FileList fileList)
{
    Stopwatch sw = new Stopwatch();
    foreach (string filename in fileList.Filenames)
    {
        string path = fileList.GetRawDataSavePath(filename);

        using AsyncFileReader reader = new AsyncFileReader();
        (IntPtr ptr, long size) = await reader.LoadAsync(path);

        sw.Restart();
        ImageInfo info = ImageConverter.Decode(ptr, (int)size);

        Texture2D texture = new Texture2D(info.header.width, info.header.height, info.header.Format, false);
        texture.LoadRawTextureData(info.buffer, info.fileSize);
        texture.Apply();
        
        sw.Stop();
        _loadAsyncAnalyzeData.Add(sw.ElapsedMilliseconds);

        CreatePreview(texture, filename);
    }

    Debug.Log($"Async avg [{_loadAsyncAnalyzeData.Count}]: {_loadAsyncAnalyzeData.GetAverage().ToString()}ms");

    UpdateTimeText(sw.ElapsedMilliseconds);
}

AsyncFileReader クラスによって非同期にファイルが読み込まれ、ヘッダ情報とファイルサイズ情報が返されます。これを利用して実際にテクスチャを生成し、データを流し込んでいるわけです。

Stopwatch クラスを使って時間を計測すると、 LoadImage には平均して 80ms くらいかかり、 LoadRawTextureData だと 16ms くらいの時間になっています。

AsyncReadManagerを使う

次は AsyncReadManager の使い方について詳しく見ていきましょう。

大まかに流れを説明すると、

  1. ReadCommand でファイル読み込みコマンドを用意
  2. NativeArray<ReadCommand> でアンマネージド領域にコマンドを配置
  3. AsyncReadManager にコマンドを投げてファイルデータを取得

という流れになります。

ReadCommandを用意

まずは ReadCommand を準備します。

ネイティブ側で動作させるため、コマンド( ReadCommand )も NativeArray を使用してネイティブ領域に情報を確保します。それを行っているのが以下の部分です。

// ReadCommandひとつ分の領域を確保
_readCommands = new NativeArray<ReadCommand>(1, Allocator.Persistent);

// 確保した領域に必要な情報を書き込む
_readCommands[0] = new ReadCommand
{
    Offset = 0,
    Size = _fileSize,
    Buffer = (byte*)UnsafeUtility.Malloc(_fileSize, UnsafeUtility.AlignOf<byte>(), Allocator.Persistent),
};

ReadCommand のプロパティに Offset があるのに気づくかと思います。これは、ひとつのファイルから連続するバイト配列を読み出し、個別にデータを分けて読み出したい場合に利用するものだと思われます。その際は ReadCommand をひとつだけではなく、取り出したいデータの数分用意し、それぞれオフセットを適切に設定することで、それぞれのコマンドから必要なバイトサイズ分のデータを取り出すことができます。

しかし今回は画像データひとつをまるっと読み出すので ReadCommand はひとつだけ用意しています。

ReadCommandを実行する

領域を確保し、データを書き込んだらそのコマンドを実行します。

_readHandle = AsyncReadManager.Read(path, (ReadCommand*)_readCommands.GetUnsafePtr(), 1);

ReadCommandから結果を受け取る

ReadCommand を実行したら次はその結果を受け取ります。ただこのコマンドは AsyncReadManager の名前から分かる通り非同期に行われます。そのため、読み込みが終了したかどうかをチェックする必要があります。

if (_readHandle.Status == ReadStatus.InProgress)
{
    Thread.Sleep(16);
    continue;
}

async として呼び出したチェックループ内で上記のように、1フレーム経過するごとに状態をチェックします。 ReadHandle#StatusInProgress から変化していたら適切に完了しているかチェックしたのちにデータを取り出します。

ReadCommandBuffer プロパティが対象ファイルデータのポインタとなっているので、それを IntPtr にキャストしてテクスチャとして読み込みます。

IntPtr ptr = (IntPtr)_readCommands[0].Buffer;

AsyncReadManagerをawaitできるようにする

今回の対応では AsyncReadManager からファイルデータを取得する処理を await 化しました。こうすることでシンプルに利用できるのでオススメです。 await / async についての解説記事は後日書こうと思っているので詳細はそちらに譲ることとし、今回はコード断片の紹介だけにとどめます。

using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.IO.LowLevel.Unsafe;
using UnityEngine;

namespace AsyncReader
{
    public class AsyncFileReader : IDisposable
    {
        private class Awaitable : IDisposable
        {
            private Thread _thread;
            private ReadHandle _handle;
            private TaskCompletionSource<bool> _completionSource;
            private bool _success = false;
            private bool _stopped = false;

            public Awaitable(ReadHandle handle)
            {
                _handle = handle;

                _thread = new Thread(CheckLoop)
                {
                    IsBackground = true
                };
            }

            ~Awaitable()
            {
                Dispose();
            }

            private void CheckLoop()
            {
                while (true)
                {
                    if (_stopped) return;
                    
                    if (_handle.Status == ReadStatus.InProgress)
                    {
                        Thread.Sleep(16);
                        continue;
                    }
                    
                    if (_stopped) return;

                    _success = _handle.Status == ReadStatus.Complete;
                    break;
                }

                _completionSource?.TrySetResult(_success);
            }

            public TaskAwaiter<bool> GetAwaiter()
            {
                _completionSource = new TaskCompletionSource<bool>();
                
                _thread.Start();

                return _completionSource.Task.GetAwaiter();
            }

            public void Dispose()
            {
                if (_stopped) return;
                
                if (_thread is { IsAlive: false }) return;
                
                _stopped = true;
                
                _thread.Abort();
            }
        }

        private ReadHandle _readHandle;
        private NativeArray<ReadCommand> _readCommands;
        private long _fileSize;

        public unsafe void Dispose()
        {
            _readHandle.Dispose();

            UnsafeUtility.Free(_readCommands[0].Buffer, Allocator.TempJob);
            _readCommands.Dispose();
        }

        public async Task<(IntPtr, long)> LoadAsync(string filePath)
        {
            UnsafeLoad(filePath);

            Awaitable awaitable = new Awaitable(_readHandle);
            await awaitable;
            
            awaitable.Dispose();

            IntPtr ptr = GetPointer();

            return (ptr, _fileSize);
        }

        private unsafe void UnsafeLoad(string filePath)
        {
            FileInfo info = new FileInfo(filePath);
            _fileSize = info.Length;

            _readCommands = new NativeArray<ReadCommand>(1, Allocator.TempJob);
            _readCommands[0] = new ReadCommand
            {
                Offset = 0,
                Size = _fileSize,
                Buffer = (byte*)UnsafeUtility.Malloc(_fileSize, UnsafeUtility.AlignOf<byte>(), Allocator.TempJob),
            };

            _readHandle = AsyncReadManager.Read(filePath, (ReadCommand*)_readCommands.GetUnsafePtr(), 1);
        }

        private unsafe IntPtr GetPointer()
        {
            return (IntPtr)_readCommands[0].Buffer;
        }
    }
}

まとめ

画像はUnityでよく扱うものである一方で、処理負荷の増大に比較的簡単に関与してしまうものなので適切な取り扱いが必要となります。特に今回は、ネイティブ側で展開するという方法を取って解決したように、Unityのより深い部分を知ることでさらなる最適化できる余地があります。

Unityはとても便利なツールです。しかし、その中身を知っていないと問題が発生した際に適切に対処できなくなってしまうので、こうしたネイティブ周りの知識を手に入れておくと今後の役に立つと思います。

エンジニア絶賛募集中!

MESONではUnityエンジニアを絶賛募集中です! XRのプロジェクトに関わってみたい! 開発したい! という方はぜひご応募ください!

MESONのメンバーページからご応募いただくか、 のDMなどでご連絡ください。

書いた人

えど

比留間 和也(あだな:えど)

カヤック時代にWEBエンジニアとしてリーダーを務め、その後VRに出会いコロプラに転職。 コロプラでは仮想現実チームにてXRコンテンツ開発に携わる。 DAYDREAM向けゲーム「NYORO THE SNAKE & SEVEN ISLANDS」をリリース。その後、ARに惹かれてMESONに入社。 MESONではARエンジニアとして活躍中。
またプライベートでもAR/VRの開発をしており、インディー部門でTGSに出展など公私関わらずAR/VRコンテンツ制作に精を出す。プライベートな時間でも開発しているように、新しいことを学ぶことが趣味で、最近は英語を学んでいる。

GitHub /

MESON Works

MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。

MESON Works