👾

Unityで非同期に行うスクリーンショットの取得・永続化・読み出し

2023/04/30に公開

検証環境

unity 2021.3.1f1

結論とコード

下記APIを使用することで、非同期化します。
スクリーンショット : AsyncGPUReadbackAPI
保存 : FileStream.WriteAsync
読み出し : UnityWebRequest

コードはこちらになります。
AsyncTexture.cs
ScreenShotPublisher.cs

同期版のコード確認

まずは同期処理の確認です。

スクショの取得と保存

1.テンポラリテクスチャを取得します。
2.任意のカメラで描画を行い、テンポラリテクスチャに焼きます。
3.焼いたテクチャをpngにエンコードし保存します。

スクショ取得の同期コード
private Camera _target;
private int _width;
private int _height;

void SaveScreenShot()
{
    var renderTexture = RenderTexture.GetTemporary(_width, _height, 24, RenderTextureFormat.ARGB32);
    _target.enabled = true;
    _target.targetTexture = renderTexture;
    _target.Render();
        
    var currentRT = RenderTexture.active;
    RenderTexture.active = renderTexture;
                
    Texture2D screenShot = new Texture2D(_width, _height, TextureFormat.ARGB32, false);
    screenShot.ReadPixels(new Rect(0, 0, _width, _height), 0, 0);
    screenShot.Apply();
                
    RenderTexture.active = currentRT;
        
    _target.targetTexture = null;
    _target.enabled = false;
    RenderTexture.ReleaseTemporary(renderTexture);

    File.WriteAllBytes(FilePath,screenShot.EncodeToPNG());
}

スクショの読み込み

ファイル読み込みでバイトデータを取得し、
Texture2D.LoadImageを使用してテクスチャを作成します。

スクショ読み込みの同期コード
private string FilePath => $"{Application.dataPath}/image.png";

Texture2D LoadScreenShot()
{
    var bytes = File.ReadAllBytes(FilePath);
    var texture = new Texture2D(0,0, TextureFormat.RGBA32, false);
    texture.LoadImage(bytes);

    return texture;
}

非同期化

主に下記APIに対応するようにして非同期化します。

スクリーンショット : AsyncGPUReadbackAPI
保存 : FileStream.WriteAsync
読み出し : UnityWebRequest

スクショ取得の非同期化

スクリーンショットの取得を非同期化するためには、
取得したRenderTextureからのTexture2D作成の非同期化が必要です。

同期処理ではReadPixelsを使用していましたが、非同期化するためにはAsyncGPUReadbackAPIを使用します。

//sourceはスクショしたいRenderTexture
var request = await AsyncGPUReadback.Request(source, 0, TextureFormat.ARGB32);
if (request.hasError)
{
    Debug.LogError("AsyncGPUReadback has error");
    return null;
}

var data = request.GetData<Color32>();
var screenShot = new Texture2D(source.width, source.height, TextureFormat.ARGB32, false);
screenShot.LoadRawTextureData(data);
screenShot.Apply();

GetData<Color32>NativeArrayを返すため、マネージド側のアロケーションを避けてTexture2Dを作成することができます。

https://docs.unity3d.com/ScriptReference/Rendering.AsyncGPUReadbackRequest.GetData.html

注意点としてはAsyncGPUReadbackが対応していないプラットフォームがあり、SystemInfo.supportsAsyncGPUReadbackを使用して使用可能か確認する必要があります。

スクショ保存の非同期保存

スクリーンショットの保存を非同期化するためには、
FileStream.Writeを非同期版のFileStream.WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)を使用します。

FileStream.Writeにはbyte[]のオーバーロードが存在しており、下記のように非同期化も可能です。

var png = loadTex.EncodeToPNG();
using var stream = File.OpenWrite(path);
await stream.WriteAsync(png);

この方法はシンプルですが、png変換したテクスチャの内容がマネージドメモリに乗ってしまうため、メモリ量やアロケーションの観点から非効率でした。

しかし、2020.2からNaitveArrayを受け取るAPIが追加されマネージドヒープに載せることなくpng変換が可能になりました。
https://docs.unity3d.com/2023.2/Documentation/ScriptReference/ImageConversion.EncodeNativeArrayToPNG.html

そして変換したNaitiveArrayMemory型に載せてFileStream.WriteAsyncのリクエストを叩くことで、マネージドヒープを経由せずにpng変換から保存を行っています。

FileStreamに渡すReadOnlyMemoryTexture2DのRawDataから作成することができます。

//テスト用のテクスチャを用意します
var loadTex = new Texture2D(1,1);
loadTex.LoadImage(File.ReadAllBytes(sourcePath));

var data = loadTex.GetRawTextureData<byte>();
var png = ImageConversion.EncodeNativeArrayToPNG(data,loadTex.graphicsFormat,(uint)loadTex.width,(uint)loadTex.height,0);

var memory = default(Memory<byte>);
unsafe
{
    var mgr = new NativeArrayManager<byte>((byte*)png.GetUnsafePtr(), png.Length);
    memory = mgr.Memory;
}

var stream = File.OpenWrite(path);
await stream.WriteAsync(memory,cancellationToken);
stream.Dispose();

png.Dispose();

Memory型に乗せるためのMemoryManagerはこちらになります。

MemoryManager
public unsafe class NativeArrayManager<T> : MemoryManager<T> where T : unmanaged
{
    private readonly IntPtr _pointer;
    private readonly int _length;

    public NativeArrayManager(IntPtr pointer, int length)
    {
        _pointer = pointer;
        _length = length;
    }

    public NativeArrayManager(T* pointer, int length)
    {
        _pointer = (IntPtr)pointer;
        _length = length;
    }

    public override Span<T> GetSpan()
    {
        return new Span<T>((void*)_pointer, _length);

    }

    public override MemoryHandle Pin(int elementIndex = 0)
    {
        return new MemoryHandle((void*)(_pointer + (elementIndex * Marshal.SizeOf<T>())));
    }

    public override void Unpin()
    {
        //ネイティブ メモリ管理なのでノータッチ
    }

    protected override void Dispose(bool disposing)
    {
        //ネイティブ メモリ管理なのでノータッチ
    }
}

非同期読み出し方法

ローカルなpngテクスチャの読み出しを非同期化するには、File.ReadAllBytesTexture2D.LoadImageの非同期化が必要です。
これにはUnityWebRequestが使用できます。

var path = Path.Combine(Application.persistentDataPath, "sample.png");
var uri =$"file://{path}";
using var request = UnityWebRequestTexture.GetTexture(uri);
await request.SendWebRequest();
var texture = DownloadHandlerTexture.GetContent(request);

余談:他の手法での非同期化

非同期読み出しを行う方法として、AsyncReadManagerを使った手法が存在します。
こちらの手法だと、Texture2D.GetRawTextureDataで取得したデータにサイズやフォーマットのヘッダ情報を付与して保存します。

書き込みのイメージコード
public async UniTask Save(Texture2D source, string path, CancellationToken cancellationToken)
{
    var rawTextureData = source.GetRawTextureData<byte>();
    var headerSize = Header.Size;
    var outputSize = headerSize + rawTextureData.Length;
    var outputData = ArrayPool<byte>.Shared.Rent(outputSize);

  // byte[]にデータを書き込む
    CreateOutputData(source.width, source.height, source.format, rawTextureData, outputData);

    //poolのRentはジャストサイズ以上を返す場合があるらしいので保存長を指定する
    //https://ikorin2.hatenablog.jp/entry/2020/07/25/113904
    using var stream = File.OpenWrite(path);
    await stream.WriteAsync(outputData, 0, outputSize, cancellationToken);
    
    ArrayPool<byte>.Shared.Return(outputData);
}

この方法で書き込まれたテクスチャをAsyncReadManagerを使用して読み込むことで、非同期化を行います。

AsyncReadManagerでの読み込みコード
public async UniTask<Texture2D> Load(string path, CancellationToken cancellationToken)
{
    if (!File.Exists(path))
    {
        Debug.LogError("File Not Found");
        return null;
    }
    
    var fileInfo = new FileInfo(path);
    var fileSize = fileInfo.Length;
    if (fileSize <= 0)
    {

        Debug.LogError("File Size is 0");
        return null;
    }

    ReadHandle readHandle;
    //非同期読み込みが4フレーム以内に終わる保証はないのでPersistent
    var readCmds = new NativeArray<ReadCommand>(1, Allocator.Persistent);
    unsafe
    {
        var cmd = new ReadCommand();

        cmd.Offset = 0;
        cmd.Size = fileSize;
        cmd.Buffer =
            (byte*)UnsafeUtility.Malloc(cmd.Size, UnsafeUtility.AlignOf<byte>(), Allocator.Persistent);

        readCmds[0] = cmd;

        readHandle = AsyncReadManager.Read(path, (ReadCommand*)readCmds.GetUnsafePtr(), (uint)readCmds.Length);
    }

    var awaitable = new ReadHandleAwaitable(readHandle,cancellationToken);
    var isSuccess = await awaitable;

    awaitable.Dispose();

    if (!isSuccess)
    {
        Debug.LogError("Read Failed");

        unsafe
        {
            UnsafeUtility.Free(readCmds[0].Buffer, Allocator.Persistent);
        }

        readCmds.Dispose();
        readHandle.Dispose();

        return null;
    }

    var pBuffer = IntPtr.Zero;
    unsafe
    {
        pBuffer = (IntPtr)readCmds[0].Buffer;
    }

    var header = ReadHeader(pBuffer);
    var texture = new Texture2D(header.Width, header.Height, TextureFormat.ARGB32, false, true);
    var dataSize = fileSize - Header.Size;

    //header分抜く
    pBuffer = IntPtr.Add(pBuffer, Header.Size);

    //texture.LoadImage()だと重くてスパイクするので、直接Rawデータを読む
    texture.LoadRawTextureData(pBuffer, (int)dataSize);
    texture.Apply();

    unsafe
    {
        UnsafeUtility.Free(readCmds[0].Buffer, Allocator.Persistent);
    }

    readCmds.Dispose();
    readHandle.Dispose();

    return texture;
}

private struct Header
{
    public int Width { get; set; }
    public int Height { get; set; }
    public TextureFormat Format { get; set; }

    public static int Size => sizeof(int) * 3;
}

private Header ReadHeader(IntPtr pData)
{
    var header = new Header();
    var buffer = ArrayPool<byte>.Shared.Rent(Header.Size);
    Marshal.Copy(pData, buffer, 0, Header.Size);

    var width = BitConverter.ToInt32(buffer, 0);
    var height = BitConverter.ToInt32(buffer, sizeof(int));
    var format = (TextureFormat)BitConverter.ToInt32(buffer, sizeof(int) * 2);

    ArrayPool<byte>.Shared.Return(buffer);

    header.Width = width;
    header.Height = height;
    header.Format = format;

    return header;
}

private class ReadHandleAwaitable : IDisposable
{
    private ReadHandle _handle;
    private TaskCompletionSource<bool> _taskCompletionSource;

    private bool _isDisposed;
    private bool _isSuccess;

    private readonly uint _waitForMilliseconds;
    
    private readonly object _lockObject = new object();
    private CancellationToken _cancellationToken;

    public ReadHandleAwaitable(ReadHandle handle, CancellationToken cancellationToken,uint waitForMilliseconds = 32)
    {
        _handle = handle;
        _waitForMilliseconds = waitForMilliseconds;
        _cancellationToken = cancellationToken;
    }

    ~ReadHandleAwaitable()
    {
        Dispose();
    }

    private void WaitReadComplete(object state)
    {
        while (true)
        {
            lock (_lockObject)
            {
                if (_isDisposed)
                {
                    return;
                }
            }
            
            if (_cancellationToken.IsCancellationRequested)
            {
                _taskCompletionSource?.TrySetCanceled();
                return;
            }

            var isProgress = _handle.Status == ReadStatus.InProgress;
            if (isProgress)
            {
                Thread.Sleep((int)_waitForMilliseconds);
                continue;
            }

            lock (_lockObject)
            {
                if (_isDisposed)
                {
                    return;
                }
            }
            
            if (_cancellationToken.IsCancellationRequested)
            {
                _taskCompletionSource?.TrySetCanceled();
                return;
            }

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

        _taskCompletionSource?.TrySetResult(_isSuccess);
    }

    public TaskAwaiter<bool> GetAwaiter()
    {
        _taskCompletionSource = new TaskCompletionSource<bool>();
        ThreadPool.QueueUserWorkItem(WaitReadComplete);

        return _taskCompletionSource.Task.GetAwaiter();
    }

    public void Dispose()
    {
        lock (_lockObject)
        {
            if (_isDisposed)
            {
                return;
            }

            _isDisposed = true;
        }
    }
}

自環境で検証した結果以下の点が気になったため、
本記事ではUnityWebRequestTexture.GetTextureを使用しています。

1.書き出されるデータの容量が増える
1024×1024のpng画像を保存するケース
png -> 43.2kB
RawTextureData -> 5.33MB

2.自環境で計測した結果パフォーマンスがそこまで出ませんでした

読み込みシナリオ(試行回数1000回)

シナリオ内容 処理API 速度(中央値)
同期読み込み File.ReadAllBytes / ImageConversion.LoadImage 6.83ms
pngを非同期に読み込み UnityWebRequestTexture 7.50ms
AsyncReadManagerでRawTextureData読み込み AsyncReadManager.Read / Text2D.LoadRawTextureData 51.72ms

永続化シナリオ(試行回数1000回)

シナリオ内容 処理API 速度(中央値)
同期エンコードと保存 ImageConversion.EncodeToPNG / FileStream.Write 21.39ms
pngを非同期コンバート・保存 ImageConversion.EncodeNativeArrayToPNG / FileStream.WriteAsync 51.52ms
RawTextureDataを保存 Text2D.GetRawTextureData / FileStream.Write 51.54ms

テストコードはこちらになります。
テストシナリオ
AsyncReadManagerでの読み込み機能

まとめ

下記APIに対応するようにして非同期化することができます。

スクリーンショット : AsyncGPUReadbackAPI
保存 : FileStream.WriteAsync
読み出し : UnityWebRequest

コードはこちらになります。
AsyncTexture.cs
ScreenShotPublisher.cs

Discussion