Unityで非同期に行うスクリーンショットの取得・永続化・読み出し
検証環境
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
を作成することができます。
注意点としては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変換が可能になりました。
そして変換したNaitiveArray
をMemory
型に載せてFileStream.WriteAsync
のリクエストを叩くことで、マネージドヒープを経由せずにpng変換から保存を行っています。
FileStream
に渡すReadOnlyMemory
はTexture2D
の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.ReadAllBytes
とTexture2D.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