本記事は サムザップ Advent Calendar 2022 の12/16の記事です。
Unity 2021.2 より .NET Standard 2.1 がサポートされました。
.NET Standard 2.1 では、その前身の .NET Standard 2.0 と比べて約3000もの API が追加・変更されています。
その中でもゲーム開発に良く使いそうなもの・パフォーマンスの向上が見込めるものをピックアップして紹介したいと思います。
System 名前空間
Span<T>1
Span<T>
に関しては先達の素晴らしい記事が沢山ありますので本記事では詳しく扱いませんが、
スタック、マネージドヒープ、アンマネージドメモリ及びそれらのスライスへの統一されたアクセスを提供する、.NET Standard 2.1 での最も目玉となる追加と言えるでしょう。
ちなみにですが Span<T>
はポインタから作ることが出来ますので、Unity の NativeArray<T>
からインスタンス化することが可能です。
Unity は TextAsset.GetData や DownloadHandler.nativeData など、 NativeArray<T>
でデータを取得できるものがありますので、ゼロアロケーションでデータを扱う手段が増えるのは嬉しいですね。
unsafe void SpanFromNativeArray()
{
TextAsset asset = Resources.Load<TextAsset>("...");
// TextAssetからメモリ割り当て無しで中身のバイト列へアクセスする
NativeArray<byte> data = asset.GetData<byte>();
Span<byte> span = new Span<byte>(data.GetUnsafePtr(), data.Length);
// do something ...
}
一度ポインタを経由するので Unsafe が前提にはなりますが、NativeArray<T>
から C# の API へコピーせずにデータを渡すことが出来るというのは覚えておくと役に立つかもしれません。
なお Unity 2022.2 からは NativeArray<T>
から直接 Span<T>
へ変換するメソッド2が追加されています、
System.Buffers 名前空間
ArrayPool<T>3
指定した型の配列のプール機能を提供するクラスで、一時的なバッファを頻繁に作成・破棄するケースでメモリアロケーションとGCコストの削減が期待できます。
ただし ArrayPool<T>
には
- .Rent は指定した長さ丁度ではなく、指定した長さ以上の配列が返される
- .Return で返却する配列はデフォルトでは初期化されない
など、いくつか注意点が有ります。
前者には、借りた配列をそのままではなく ArraySegment<T>
や Memory<T>
, Span<T>
などのスライスで扱うことで回避できるでしょう。
// 最低でも512バイトの配列を借りる
var buffer = ArrayPool<byte>.Shared.Rent(512);
// 必要なサイズのスライスをSpanで取得してゼロクリアする
var span = buffer.AsSpan(0, 512);
span.Fill(0);
// do something ...
// 借りた配列を返す
ArrayPool<byte>.Shared.Return(buffer);
後者は参照を含んだ配列をクリアしないで返してしまうと、メモリリークの原因にもなり得ますので注意が必要です。
IBufferWriter<T>4
Stream に似た役割のインタフェースですが、値を受け取って書き込むのではなくバッファを返して、呼び出し元に直接書き込んで貰うというところが違います。
標準では ArrayBufferWriter クラスがこのインタフェースを実装しています。
// Stream を使った書き込み、一度バッファへ出力してから書き込む
Stream stream = new MemoryStream();
byte[] buffer = Encoding.UTF8.GetBytes("some text");
stream.Write(buffer);
// IBufferWriter を使った書き込み、writer から Span を取り出して直接書き込む
IBufferWriter<byte> writer = new ArrayBufferWriter<byte>();
Span<byte> span = writer.GetSpan(Encoding.UTF8.GetMaxByteCount("some text".Length));
int numWritten = Encoding.UTF8.GetBytes("some text", span);
writer.Advance(numWritten);
実装は若干複雑になりますが、Streamと比べて中間バッファを一つ減らせるので、その分のコピーコストの削減が期待できます。
System.Buffers.Binary 名前空間
BinaryPrimitives5
エンディアンを指定してバイト列との値の読み書きを行えるクラスです。
例えばMessagePackなど、エンディアンが固定されたフォーマットを扱う際に役に立つでしょう。
// 0xC0DE をビッグエンディアンで書き込む
byte[] bigEndian = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(bigEndian, 0xC0DE);
// 00 00 C0 DE
Debug.Log(string.Join(" ", bigEndian.Select(b => b.ToString("X2"))));
// 0xC0DE をリトルエンディアンで書き込む
byte[] littleEndian = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(littleEndian, 0xC0DE);
// DE C0 00 00
Debug.Log(string.Join(" ", littleEndian.Select(b => b.ToString("X2"))));
System.Buffers.Text 名前空間
Base646
Base64変換処理自体は System.Convert
クラスにも実装されていますが、
Base64 クラスでは UTF8 エンコードされた Base64文字列データを、その配列内で直接変換するメソッドなど、より低レイヤな機能が提供されています。
// UTF8エンコードされたBase64文字列を取得する
byte[] base64TextBytes = Encoding.UTF8.GetBytes(Convert.ToBase64String(new byte[] { 42 }));
// Base64文字列の変換を base64TextBytes の中で行う
Base64.DecodeFromUtf8InPlace(base64TextBytes, out var bytesWritten);
// 変換したしたバイト数が bytesWritten に格納されるので、そのサイズでスライスして扱う
Span<byte> decoded = base64TextBytes.AsSpan(0, bytesWritten);
// 42
Debug.Log(decoded[0]);
Utf8Formatter7, Utf8Parser8
UTF8エンコードされた文字列データから直接値へ変換、またはその逆に値からUTF8エンコードされた文字列データへ直接変換するクラスです。
string
または char[]
への変換を挟まないために高速に動作することが期待出来ます。
// buffer へ 42 をUTF8文字列として直接書き込む
byte[] buffer = new byte[64];
Utf8Formatter.TryFormat(42, buffer, out var bytesWritten);
// 42
Debug.Log(Encoding.UTF8.GetString(buffer.AsSpan(0, bytesWritten)));
System.IO.Compression 名前空間
BrotliStream9, BrotliDecoder10, BrotliEncoder11
より高圧縮率な Brotli データ形式を扱うクラスと構造体です。
BrotliStream
は Stream
を継承した汎用的なクラスですが、
BrotliDecoder
/ BrotoliEncoder
は Brotli に特化した構造体で、ゼロアロケーションで解凍・圧縮を行うことが出来ます。
前述の IBufferWriter
と BrotliDecoder
を使用した解凍処理は以下のように実装することが出来ます。
Span<byte> compressed = ...;
using var decoder = new BrotliDecoder();
var writer = new ArrayBufferWriter<byte>();
while (true)
{
var buffer = writer.GetSpan();
var status = decoder.Decompress(compressed, buffer, out var consumed, out var written);
writer.Advance(written);
switch (status)
{
case OperationStatus.Done:
var decompressed = writer.WrittenSpan;
// do something ...
return;
case OperationStatus.DestinationTooSmall:
// buffer のサイズが足りなかった、解凍済みバイト数分進めて再度処理を行う
compressed = compressed[consumed..];
break;
default:
throw new Exception(status.ToString());
}
}
まとめ
.NET Standard 2.1 のサポートによって Unity で低レイヤを扱う機能が大幅に強化されました。
特にゲーム開発ではパフォーマンスを意識して、メモリアロケーションを減らすことが関心事になったりもしますので、これらの機能をうまく活用出来ればと思います。