Search Unity

Unity 2022におけるC#ランタイムスクリプトについて

  • スクリプティング
  • 開発者向け

はじめに

この記事では、Unity 2022で利用できるC#のスクリプティング環境について紹介します。また、GDC 2022やUnite 2022で発表された、今後のC#スクリプティング環境の進化についても触れます。

本記事の検証は下記の環境で行いました。

  • macOS Monterey(MacBook Pro 2018)
  • Unity 2022.2.0b16
  • JetBrains Rider 2022.2.1

Unity 2022で利用できるC#スクリプティング環境について

Unity2022.2.0b16現在、下記のC#スクリプティング環境をサポートしています。

  • C# 9.0の一部の機能をサポートするコンパイラ
  • .NET Standard 2.1

まずUnity 2022では、C# 9.0 の一部の機能をサポートするコンパイラが提供されています。

C# 9.0 の機能のうち、利用できない機能の一覧は下記のとおりです。(参考: Unity - Manual: C# compiler

  • Suppress emitting localsinit flag
  • Covariant return types
  • Module Initializers
  • Extensible calling conventions for unmanaged function pointers
  • Init only setters

次にAPIセットは .NET Standard 2.1 をサポートしています。これに伴って C# 8.0を完全にサポートしました。

また、.NET Standard 2.1では様々なAPIが追加されました。その中でも Span<T> とそれに関するAPIの追加が多く、 Span<T> を使ったC#スクリプティングが可能になりました。

ちなみに C#の言語バージョンのデフォルトでは、 .NET Standard 2.1の言語バージョンは C# 8.0 を想定していますが、UnityのC#コンパイラとしてはC# 9.0 の機能の一部を先行して実装しているかたちとなっています。(参考: C# language versioning - C# Guide | Microsoft Learn

コンパイラの言語バージョンと期待するAPIセットの差異によって発生する問題の一例として「Init only setters」があります。こちらはコンパイラ側の対応は完了していますが、対で必要となる System.Runtime.CompilerServices.IsExternalInit が .NET Standard 2.1 には入っていないため利用できません。

ただしコンパイラ自体の対応は完了しているので、「Init only setters」を利用するためのワークアラウンドとして、System.Runtime.CompilerServices.IsExternalInit のコードをプロジェクトに持ってくることでこの機能を利用することができます。(ただし機能自体は未対応なので、この機能を利用して発生する不具合などについてはサポートの対象外となるでしょう。)

C# 9.0

C# 9.0で利用できる機能のうち、特にUnityで利用できる機能について紹介します。(参考: C# 9.0 の新機能 - C# ガイド | Microsoft Learn

レコード型(Record Types)

レコード型を使用する - C# チュートリアル | Microsoft Learn

レコード型は、データを扱うことに特化した型です。たとえば下記のような宣言で FirstName と LastName をプロパティに持つ Person クラスを定義できます。

public record Person(string FirstName, string LastName);

var p = new Person("Taro", "Tanaka");
Debug.Log($"{p.FirstName} {person.LastName}"); // Taro Tanaka
// ToStringもいい感じに生成する
Debug.Log($"{p.ToString()}"); // Person { FirstName = Tanaka, LastName = Taro }

レコード型は上記のような宣言を行うだけで、下記のコードもコンパイラが自動生成します。

  • 等値判定用のメソッド( Equals や GetHashCode 、 == 、 IEquatable<T> )
  • ToString
  • with 式のためのクローンメソッド
  • EqualityContract プロパティ

ちなみにレコード型ではクラスが生成されますが、C# 10.0の record struct では構造体としてコンパイルされるレコード型が利用できます。

ただし、レコード型を実際に展開するところでInit only setterが利用されるので、下記のような定義をプロジェクト内に配置する必要があります。

namespace System.Runtime.CompilerServices
{
    public class IsExternalInit { }
}

パターン マッチングの拡張機能(Pattern matching enhancements)

パターン - C# リファレンス | Microsoft Learn

パターンマッチングにおいていくつか機能が追加されました。

「and」・「or」・「not」というパターン結合子を利用した論理パターンが記述できるようになりました。それぞれ「&&」・「||」・「!」演算子を用いたパターン記述に似ていますが、(人によると思いますが)より視認性の高い記述ができるようになったと言えます。

static string GetCalendarSeason(DateTime date) => date.Month switch
{
    3 or 4 or 5 => "spring",
    6 or 7 or 8 => "summer",
    9 or 10 or 11 => "autumn",
    12 or 1 or 2 => "winter",
    _ => throw new ArgumentOutOfRangeException(
        nameof(date), $"Date with unexpected month: {date.Month}."),
};

関係演算パターンが追加されました。「<」・「<=」・「>」・「>=」の4つの関係演算子を用いて数値の大小関係をパターン中に記述できます。

static string GetCalendarSeason(DateTime date) => date.Month switch
{
    >= 3 and < 6 => "spring",
    >= 6 and < 9 => "summer",
    >= 9 and < 12 => "autumn",
    12 or (>= 1 and < 3) => "winter",
    _ => throw new ArgumentOutOfRangeException(
        nameof(date), $"Date with unexpected month: {date.Month}."),
};

型パターンの記述が簡略化されました。C# 9.0以前では型パターンの記述に Type variable というかたちで必ず変数を指定する必要がありました。これは変数名に「_」を用いることで破棄できましたが、それでも必ず「_」の記述が必要で少し冗長でした。

C# 9.0以降では下記コードのように、変数を省略して記述を簡略化できるようになりました。

public static decimal CalculateToll(this Vehicle vehicle) => vehicle switch
{
    Car => 2.00m,
    Truck => 7.50m,
    null => throw new ArgumentNullException(nameof(vehicle)),
    _ => throw new ArgumentException(
        "Unknown type of a vehicle", nameof(vehicle)),
};

ターゲット型new演算子(Target-typed new expressions)

インスタンスを生成の際に、インスタンスが代入される型が推測できる場合に new 以降の型名を省略できるようになりました。具体的には、下記のような記述ができるようになりました。

Dictionary<string, List<int>> field = new() {
    { "item1", new() { 1, 2, 3 } }
    // 本来は下記の記述が必要だった
    // { "item1", new List<int>() { 1, 2, 3 } }
};

class IdNameMap
{
    // フィールドの初期化でも使える
    Dictionary<int, string> Map { get; } = new();
}

とくに記述する型名が長い場合に、元コードと比べるとすっきりとして便利です。

ただし、乱用には注意が必要です。具体的には下記のように「newを行っている場所」と「代入先のフィールドやプロパティの定義位置」が大きく離れる、または別ファイルの場合は、ぱっとコードを見ただけではどの型のインスタンスが代入されたかわかりづらくなります。

class C
{
    // これは、型とnewが同じ場所にあるので置いやすい
    private List<int> _x = new(); 
   
    // 何百行も処理が入るとする

    public void Reset() =>
        _x = new(); // _xの型とnewが遠いので、型情報が追いづらい
}

ターゲット型条件演算子(Target-Typed Conditional Expression)

Target-typed conditional expression - C# 9.0 specification proposals | Microsoft Learn

?: 演算子 - C# リファレンス | Microsoft Learn

条件演算子の第2・3項がターゲット型から型推論されるようになりました。具体的には下記のような記述ができるようになりました。

// conditionはなにかしらの真偽値が渡ってくるとする

// C# 9.0以前はコンパイルエラー
int? x = condition ? 12 : null;
// var x = condition ? 12 : null;
// はC# 9.0以降もコンパイルエラー

上記のコードにおいてターゲット型は変数 x の int? です。C# 9.0以前のコンパイラで条件演算子の結果の型は、第2項と3項の共通の型で決めていました。そのため上記コードの 12 と null で共通の型を見つけることができずにコンパイルエラーします。

C#9.0以降では、ターゲット型として int? を指定することで、第2項と3項を int? として型推論することでコンパイルが通るようになります。ただしターゲット型を var で指定するなどで明示されない場合はコンパイルエラーとなります。

静的匿名関数(Static anonymous functions)

Static anonymous functions - C# 9.0 specification proposals | Microsoft Learn

static 修飾子をラムダ式または匿名関数に指定できるようになりました。静的なラムダ式または静的な匿名関数ではローカル変数またはインスタンスの状態をキャプチャできません。つまり、外部変数などを利用しないラムダ式を明示したい場合は static をつけることで、意図しない誤った変数キャプチャをコンパイルエラーとして落とす事ができます。

具体的には下記のようなコードがコンパイルエラーとなります。

var a = 0;

// コンパイルエラー
Func<int, int> func = static x => a * x;

ラムダ式の引数の破棄(Lambda discard parameters)

Lambda discard parameters - C# 9.0 specification proposals | Microsoft Learn

ラムダ式 - C# リファレンス | Microsoft Learn

イベントのサブスクリプションとサブスクリプション解除を行う方法 - C# プログラミング ガイド | Microsoft Learn

ラムダ式の引数を、「 _」を用いることで値の破棄が行えるようになりました。

Func<int, int, int> constant = (_, _) => 42;

これは特に、イベントハンドラーを登録する際にそのイベントハンドラーが不要な変数を破棄したいときに便利です。

.NET Standard 2.1

.NET Standard 2.1 では、Unity 2021.2以前のUnityがサポートする .NET Standard 2.0 と比べると約 3,000個のAPIが追加されました。具体的に追加されたAPIについては下記のページから確認できます。

standard/netstandard2.1.md at v2.1.0 · dotnet/standard

先述のとおり、追加されたAPIの中で特に大きく割合を占めるのが、既存APIに対しての Span<T> に関する機能の追加になります。また ArrayPool<T> ・ Range・Index・HashCode・ Memory<T> などのクラスが追加されています。

また.NET Standard 2.1の対応により、UnityがC# 8.0を完全にサポートしました。

たとえば、C# 8.0機能のうち非同期ストリームは .NET Standard 2.1 のAPIの IAsyncEnumerable<T> と IAsyncDisposable を必要とします。そのため、2021.2以前のUnity(.NET Standard 2.1をサポートしていないUnity)でこれらの機能を利用するためには、必要なAPIを自分で定義するなどのワークアラウンドが必要でした。 .NET Standard 2.1の対応により、これらのワークアラウンドを行わずとも C# 8.0の機能をフルに活用できるようになりました。

ここでは .NET Standard 2.1によって利用できるようになった C# 8.0の機能や Span<T>、 ArrayPool<T> について紹介します。

非同期ストリーム

Async streams - C# 8.0 specification proposals | Microsoft Learn

using ステートメント - C# リファレンス | Microsoft Learn

C# 8.0では、非同期メソッドの実装が拡張され、下記のような実装が行えるようになりました。

  • 非同期 foreach
    • await foreachという書き方で非同期なデータ列挙が行える
  • 非同期イテレーター
    • 非同期メソッド内に yield をかけるようになった
  • 非同期 using
    • await usingという書き方で、非同期なリソース破棄が行える

非同期foreachは端的に言うとforeachにasync/awaitが利用できる機能です。これによってデータを非同期に消費できます。下記のようなコードが記述できます。

async Task AsyncForeachAsync(IAsyncEnumerable<int> data)
{
   await foreach (var number in data)
   {
       Debug.Log($"{number}");
   }
}


非同期イテレーターは、非同期メソッド内に yield がかけるようになりました。await と yield が混在できるようになり、非同期にデータを生成できるようになりました。

たとえば下記の書き方で、1秒に1回、整数値を返す非同期イテレーターが実装できます。

async IAsyncEnumerable<int> GenerateDataAsync(
   [EnumeratorCancellation]CancellationToken token = default)
{
   for (var i = 0; i < 10; i++)
   {
       if (token.IsCancellationRequested) yield break;

       // 1秒待機
       await Task.Delay(TimeSpan.FromSeconds(1));

       // 非同期メソッドで、途中に戻り値を返せる
       yield return i;
   }
}


非同期foreach と 非同期イテレーターを組み合わせると、一連のデータを非同期に取得して処理できます。たとえばページネーションのような構造を持つAPIに対する一連のデータ取得を、非同期イテレーターを用いて下記のように記述できます。

// ページネーション構造を持つデータを
// 非同期イテレーターを用いて下記のように実装できる
async IAsyncEnumerable<int> FetchDataAsync(
   [EnumeratorCancellation] CancellationToken token = default)
{
   for (var page = 0; page < 10; page++)
   {
       if (token.IsCancellationRequested) yield break;
       // ウェブAPIをフェッチして結果を返す
       yield return await FetchAPI(page);
   }
}
// 非同期イテレーターを、非同期foreachを用いて処理する
await foreach (var result in FetchDataAsync())
{
    Debug.Log($"{result}");
}


非同期usingは同期版のusingステートメントと似たような機能で、非同期にリソースの破棄を行うことができます。 System.IAsyncDisposable を実装したうえで、 await using という構文を用いることで、自動で DisposeAsync が呼び出されます。

async Task AsyncFileCopyAsync()
{
    // fromStreamとtoStreamはスコープを抜けると
    // 自動でDisposeAsyncが呼び出される
    await using (
        Stream fromStream = File.OpenRead("from.txt"),
        toStream = File.OpenWrite("dest.txt"))
    {
        await fromStream.CopyToAsync(toStream);
    }
}


非同期ストリームは.NET Standard 2.1で追加された、System.Collections.Generic 名前空間の IAsyncEnumerable<T> と IAsyncDisposable が利用されます。それぞれ以下の定義となっています。

public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}

 public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
    T Current { get; }
    ValueTask<bool> MoveNextAsync();
}

 public interface IAsyncDisposable
{
    ValueTask DisposeAsync();
}

範囲とインデックス

Ranges and indices - C# 8.0 draft specifications | Microsoft Learn

C# 8.0では、配列などに対して下記のような範囲・インデックスの指定が可能になりました。

var ary = new int[]{1, 2, 3, 4, 5, 6};
// 1番目から3番目までの範囲を取得
var sub1 = ary[1..3]; // 1,2

// 後ろから2番目の要素を取得
var el1 = ary[^2]; // 5

// 組み合わせても使える
// 1番目から後ろから1番目の範囲を取得
var sub2 = ary[1..^1]; // 2, 3, 4, 5

// 1番目から末尾までの範囲を取得
var sub3 = ary[1..]; // 2, 3, 4, 5, 6

// 先頭から後ろから1番目の範囲を取得
var sub4 = ary[..^1]; // 1, 2, 3, 4, 5

この構文は、内部的に .NET Standard 2.1 で追加された System.Range および System.Index を利用しています。

// 後ろから1番目を示すIndex構造体のインスタンス
var i1 = ^1;

// 1番目から3番目までの範囲を表すRange型のインスタンス
var r1 = 1..3;

// 1番目から後ろから1番目までの範囲を表すRange型のインスタンス
var r2 = 1..^1;

なので「^i」や「i..j」という記述で「対応するRange・Index型のインスタンスが生成できる構文が実装された」のと、「Range・Index型に対応する配列などのインデクサーが実装された」、というかたちでこの機能が実現しています。

既定のインターフェイスメソッド

Default interface methods - C# 8.0 specification proposals | Microsoft Learn

Default implementations in interfaces - .NET Blog

C# で既定のインターフェイス メソッドを使用してインターフェイスを安全に更新する | Microsoft Learn

C# 8.0ではインターフェイスに対してデフォルト実装を持てるようになりました。これによって、既定のインターフェイスに対してメソッドを安全に追加できるようになりました。

例えば下記のようなインターフェイスと、それを実装するクラスがあるとします。

interface ILogger
{
    void Log(LogLevel level, string message);
}

class MyLogger : ILogger
{
     public void Log(LogLevel level, string message) =>
        Debug.Log($”[{level}] {message}”);
}

このインターフェイスに新たにメソッドを定義すると、そのインターフェイスを実装する既存の全クラスで追加されたメソッドを実装しないとコンパイルエラーになります。 この問題に対してC# 8.0 では、下記のようにインターフェースにデフォルト実装をもたせることで回避できます。

interface ILogger
{
    void Log(LogLevel level, string message);
    // 追加されたメソッド
    void Log(Exception ex) => Log(LogLevel.Error, ex.ToString());
}

// ILogger.Log(Exception ex)にはデフォルト実装が存在するので
// コンパイルエラーにならない
class MyLogger : ILogger
{
     public void Log(LogLevel level, string message) =>
        Debug.Log($”[{level}] {message}”);
}

この機能は .NET Standard 2.1 サポートに伴うランタイム更新によって利用可能になりました。

Span<T>とは

C# - Span のすべて: .NET の新しい頼みの綱を探索する | Microsoft Learn

Span<T> は C# 7.2で追加された構造体で、連続して並んだデータの一定範囲を操作するための型です。

Unityのコンパイラは C# 7.2 自体を Unity 2019 LTSの段階でサポートしているのでコンパイラ的には利用可能だったのですが、 Span<T> 構造体が .NET Standard 2.1 に含まれるため、Unity 2021 LTS(厳密には 2021.2)から正規に利用できる機能となります。

連続して並んだデータとは、たとえば配列や文字列などを指し、配列の一部の区間や部分文字列を表現できます。

// 配列に対するSpan
var array = new int[8];
Span<int> arraySpan = array.AsSpan().Slice(1, 4);

// 文字列に対するSpan
ReadOnlySpan<char> subString = "abcdefghi".AsSpan().Slice(2, 3);
Debug.Log($"{subString.ToString()}"); // cde

// スタック領域に対するSpan(後述)
Span<int> stackAry2 = stackalloc int[16];

Span<T> は連続して並んだデータ、配列や文字列以外にもスタック領域やC#のマネージド領域以外のメモリなど様々なものを指すことができます。

Span<T>とstackalloc

C#には stackalloc という、通常ヒープ領域から確保される配列をスタック領域から確保する構文が用意されています。

配列をヒープ領域から確保するとヒープアロケーションが発生するため、特に Update メソッド内など毎フレーム実行される処理での都度配列確保は、ゲームのパフォーマンスの観点から気をつけるべきポイントとなっています。

そのため Update メソッド内で利用する配列は、 Awake や Start などで初期化を行うときに事前に確保しておいてその配列を使い回すか、事前に確保した配列をプールなどの工夫が必要になります。

stackalloc はスタック領域から配列を確保するためヒープアロケーションが発生しません。そのため都度確保してもパフォーマンス上は気にせず使うことができます。また、スタック領域へのアクセスはヒープ領域と比べると高速なのでパフォーマンス面でも有利です。ただし、スタック領域はヒープ領域に比べて小さいため、極端に大きい配列の確保は事前確保するようにしましょう。

そんな便利な stackalloc ですが、返却されるのがポインタのため、利用するのにunsafeを許可する必要があります。

unsafe
{
    int* stackArray1 = stackalloc int[16];
}

これは Span<T> を用いることで、 stackalloc をunsafeではなく安全なコード内で扱うことができます。

Span<int> stackAry2 = stackalloc int[16];

Span<T> による文字列のアロケーション回避

Span<T> による用途の1つとして、文字列操作のアロケーション回避があります。

たとえば、C#での文字列操作APIの多くは結果の返却に新たな文字列を返却します。String.Substring は元の文字列の部分文字列を返しますが、戻り値を返す際に新たな String のインスタンスを生成します。

また、文字列を特定のセパレータで分割して返却する String.Split も戻り値の返却に string[] を返却しますが、部分文字列はすべて新たな String インスタンスを生成します。

このとき都度ヒープアロケーションが発生するため、特に元の文字列が大きく部分文字列を生成する回数が大きいケースではパフォーマンスが悪化します。

パフォーマンスを向上させるためにはヒープアロケーションをできるだけ回避するようなコードを記述する必要があります。Span<T> や .NET Standard 2.1で追加された Span<T> に関するAPIを用いることで、このような最適化を行いやすくなりました。

例として簡単なCSVパーサーを最適化する例を考えます。簡単のために1行のみのCSVをパースすることを考え、またすべてのカラムは Int32 という前提を置きます。

まず String.Split を用いた実装を下記に示します。

var line = "123,456,789";
foreach (var subStr in line.Split(','))
{
    var val = Int32.Parse(subStr);
}

前述のとおり、上記のコードでは line.Split(',') の部分でヒープアロケーションが発生します。これを Span<T> を用いてヒープアロケーションを回避してみます。下記にコードを示します。

var line = "123,456,789";
var span = line.AsSpan();
var startIndex = 0;
while (startIndex < line.Length)
{
    // セパレータで区切られた1カラムを探す
    var endIndex = line.IndexOf(',', startIndex);
    // 最後のカラムを処理するためにendIndexの操作
    if (endIndex == -1) endIndex = line.Length;

    // 部分文字列をReadOnlySpan<char>で受け取る
    var subStr = span.Slice(startIndex, endIndex - startIndex);
    // ReadOnlySpan<char>をInt32としてパースする
    var val = Int32.Parse(subStr);

    // 次のカラムを探すためstartIndex更新
    startIndex = endIndex + 1;
}

このプログラムでは、whileの1ループごとにセパレータで区切られた1カラムを探し、そのカラム ReadOnlySpan<char> で部分文字列として切り出し、その部分文字列を Int32.Parse に渡して整数としてパースします。

このようにSpan<T> として元の文字列をスライスして、スライスした文字列をそのまま Int32.Parse に渡すことでCSVパーサーのゼロアロケーション化が実現できました。

ポイントは2点です。

  1. 部分文字列を元の文字列のスライスで表現する
  2. Span<T> を受け取れるAPIにそのまま引き渡す

前述のとおり、C#で部分文字列を扱うAPIの多くは新たに String インスタンスを生成します。それを、連続して並んだデータの一定範囲を指すことのできる Span<T> (文字列の場合は元の文字列の部分文字列を)を用いて部分文字列を表現することでアロケーションを回避しています。

また、 Span<T> を受け取れるAPIに Span<T> のまま渡すということも重要です。今回の例では、 .NET Standard 2.1 で追加された下記のAPIを利用しています。

Int32.Parse メソッド (System) | Microsoft Learn

public static int Parse (ReadOnlySpan<char> s, IFormatProvider? provider);

このAPIは与えられた ReadOnlySpan<char> をヒープアロケーションなしに解析します。

ちなみに、もしこのAPIが存在しない場合には、上記のCSVパーサーの Int32.Parse 部分は下記のように記述する必要があります。

var val = Int32.Parse(subStr.ToString());

ReadOnlySpan<T>.ToString() は Span<T> の文字列表現を返却するAPIですが、この文字列生成は新たな String インスタンスを生成します。つまりヒープアロケーションが発生します。(参考: ReadOnlySpan<T>.ToString メソッド (System) | Microsoft Learn

このように、 Span<T> によるヒープアロケーション回避の最適化を行うためには、Span<T> をできる限りそのまま引き回せるようにAPIの整備されていることが重要になります。.NET Standard 2.1では多くの Span<T> に関するAPIが追加されたと言いましたが、まさにこの辺の事情のため、だと言えます。

ArrayPool<T>

先述の通りUpdate メソッド内などの頻繁に実行される可能性のある処理の中での配列を確保すると、その都度ヒープアロケーションが発生し、パフォーマンスが悪化します。

このような問題は、初期化時に配列を事前確保するか、オブジェクトプールパターンを用いて事前に確保された配列を使い回すことで、使う都度のヒープアロケーションを回避することでパフォーマンスを向上させることができます。

.NET Standard 2.1 ではオブジェクトプールパターンの実装として ArrayPool<T> というクラスが用意されました。これは名前の通り配列をプールして使い回すことのできるクラスです。(参考: ArrayPool<T> クラス (System.Buffers) | Microsoft Learn

ArrayPool<T> の使い方を下記に示します。

var aryLength = 16;
// 配列をプールから借りてくる
var rentAry = ArrayPool<int>.Shared.Rent(aryLength);

// なにか配列に対して処理
// 試しに配列に対して適当な値をつっこんで
// その合計を取る
for (var i = 0; i < aryLength; i++)
{
    rentAry[i] = i;
}

for (var i = 0; i < aryLength; i++)
{
    sum += rentAry[i];
}


Debug.Log($"sum = {sum}");

// 借りた配列を返却する
ArrayPool<int>.Shared.Return(rentAry);

配列をプールから借りるには ArrayPool<T>.Rent を呼びます。引数には借りたい配列の長さを渡します。また、利用した配列が使い終わったら ArrayPool<T>.Return でプールに返却します。

Returnの第2引数には再利用前に借りた配列をゼロ初期化するかを指定できます。特にTが参照型の場合に配列をクリアせずに再利用すると、意図しない参照を引き継いでしまったり、参照が外れないためにインスタンスがGCの対象にならない、などといった問題が発生します。

そのためTが参照型の場合は、必ずReturnの第2引数をtrueにしてゼロ初期化を行いましょう。ちなみにTが参照型かどうかは、同じく.NET Standard 2.1で追加された RuntimeHelpers の IsReferenceOrContainsReferences<T> を用いることで判定できます。

このように便利なArrayPool<T>ですが、利用する際の注意点がいくつかあります。

まず、Rentメソッドで指定する配列長ですが、返ってくる配列は指定した配列長以上であることが保証されますが、それよりも長い配列が返ってくる可能性があるという点です。例えば配列長に 10 を指定すると配列長が 16 の配列が返ってきます。

var ary = ArrayPool<int>.Shared.Rent(10);
// ary.length = 16
Debug.Log($"ary.length = {ary.Length}");
ArrayPool<int>.Shared.Return(ary);

そのため、forループを回すとき、終了条件で配列の長さをLengthプロパティで取ると期待した動作にならない可能性があります。またforeach でループを回しても同様に期待した動作に可能性がある点にも注意が必要です。

また前述のとおり、借りた配列はゼロ初期化が保証されていません。そのために利用する際に適切に初期化などを行う必要があります。

Source Generator

Unity 2022ではSource Generatorが利用できます。Source Generatorを用いることで開発者は、ユーザーのC#コードに対して、コンパイル後に追加のコードを生成できるようになりました。

(参考: Unity - Manual: Roslyn analyzers and source generators、ソース ジェネレーター | Microsoft Learn

具体的には下記のようなフローでコード生成を行います。

Source Generatorを利用することで、いわゆるボイラープレートのようなコードを自動生成できます。Unityの提供する公式パッケージでもSource Generatorを用いたコード生成の事例が増えてきました。たとえば実行時に.NETオブジェクトの各種情報にアクセスするためcom.unity.propertiesパッケージでは、コード生成が ILPostProcessorベースの実装からSource Generatorベースの実装に置き換わりました。(参考: Changelog | Properties | 2.0.0-exp.13

オープンソースでは UnitGenerator というライブラリがValue objectパターンに必要になる定形的なコードをSource Generatorを用いて自動生成していたり、 MemoryPack というシリアライザがクラスに対するシリアライズ・デシリアライズの実装をSource Generatorを用いて自動生成しています。

Unity 2020 LTS のサポートは2023年の半ばに切れる予定ですが、 その後は2021 LTSがサポートの下限になるため、Source Generatorは実質Unityの標準機能となります。そのため今後はよりSource Generatorを用いたライブラリが増えていくことが予想されます。

コンパイル後処理としてのILPostProcessorとSource Generator

Source Generatorはユーザーの書くC#コードに対してコンパイル後処理として追加のコードを差し込む機能ですが、UnityはSource Generatorが導入される以前から、コードの生成や編集を行う仕組みをILPostProcessorという形で提供されています。

ILPostProcessorはMono.Cecilというライブラリを用いて、C#コードをコンパイル後に生成される .NETの中間言語であるIL(Intermediate Language)に対して加工を行います。(参考: 目指せ黒魔術!絶対に真似してはいけないILポストプロセッサー作成法 | Unity Learning Materials

たとえばECSのコアパッケージであるEntitiesパッケージでは、コードの自動生成にILPostProcessorを利用しています。C#をコンパイルしたあとのILを加工することで、一部コードを自動生成しています。

「コンパイル後になにか処理を差し込んでコードを追記する」という観点ではILPostProcessorとSource Generatorは似ている点があります。ですがILは機械語に近い言語なため、追記するコードをC#コードとしてそのまま書けるSource Generatorは、ILPostProcessorと比べて保守しやすいというメリットがあります。

前述の通り、Unityの公式パッケージの一部がコードの自動生成はILPostProcessorからSource Generatorに置き換えが進んでることから、コードの自動生成についてはSource Generatorによる実装が主流になるでしょう。(参考: Changelog | Properties | 2.0.0-exp.13

ただし、Source Generatorはあくまでもコードの自動生成を行うためのツールなので、既存のコードを編集するような処理を行いたい場合は、引き続きILPostProcessorが利用されることになるでしょう。

Source Generatorを用いた簡単なコード生成を試してみる

ここからはSource Generatorの実装方法を紹介しながらSource Generatorについて解説していきます。

簡単なコード生成の例として、既存のクラスに対してAutoProperty 属性がついたフィールドに、そのフィールドのGetter・Setterプロパティを自動生成するような、Unityで動くSource Generatorを作ってみます。

Unityで動かすために、通常のSource Generatorと違った作りをする必要はありませんが、Source Generatorで利用するパッケージのバージョンが一部固定されています。

Source Generatorの作成にはまず、Source Generator用のC#プロジェクトを作成してそちらでジェネレータの実装を行います。その後、ビルド時に出力されるDLLをUnityプロジェクトにインポートし、インスペクター上で設定を行うことでUnity上でコード生成が行われるようになります。

Unityで利用できるSource Generatorを実装する

Source Generatorを実装するためにはまず、.NET Standard 2.0をターゲットにしたプロジェクトを作成し、NuGet経由で Microsoft.CodeAnalysis 3.8 パッケージをインストールします。

そのプロジェクト下に ISourceGenerator インターフェースを実装してかつ Generator 属性がついたクラスを実装します。 ISourceGenerator インターフェースは下記の定義となります。

public interface ISourceGenerator
{
    void Initialize(GeneratorInitializationContext context);
    void Execute(GeneratorExecutionContext context);
}

Initialize はSource Generatorを初期化する際に呼び出されます。このメソッドでは主に、元のソースコードからコード自動生成のためのコードを収拾するために ISyntaxReceiver を行います。

Execute は実際にコード生成を行う際に呼び出されるので、このメソッド内に実際にコード生成のロジックを記述することになります。例えば今回利用する AutoProperty 属性のコード生成を行うには下記のような Execute メソッドを記述します。

[Generator]
public class AutoPropertyGenerator : ISourceGenerator
{
    // AutoPropertyAttributeのコード本体
    private const string AttributeText = @"
using System;
namespace AutoProperty
{
    [AttributeUsage(AttributeTargets.Field,
                    Inherited = false, AllowMultiple = false)]
    sealed class AutoPropertyAttribute : Attribute
    {
        public AutoPropertyAttribute()
        {
        }
    }
}
";
    public void Execute(GeneratorExecutionContext context)
    {        // AutoProperty.Generated.csという名前でソースコードを生成する
        context.AddSource(
            "AutoProperty.Generated.cs",
              SourceText.From(AttributeText, Encoding.UTF8));
    }

    // 一旦初期化では何もする必要がないのでなにもなし
    public void Initialize(GeneratorInitializationContext context) {}
}

コード生成自体は上記のように単純で、C#のコードのテキストを生成して context.AddSource に渡すだけで実現できます。

これをUnityで動かすには上記コードをプロジェクトに配置後にプロジェクトをビルドし、プロジェクトは以下に生成されるDLL(例えば ./bin/Release/netstandard2.0/AutoPropertyGenerator.dll 配下に生成される)をUnityプロジェクトに配置します。配置後にDLLを選択してインスペクタを開き、下記の用に設定します。

DLLをSource GeneratorとしてUnityに組み込むには「Asset Labels」に「RoslynAnalyzer」というタグを設定します。「Select platforms for plugin」はUnityドキュメントでは「Editor」と「Standalone」に設定するように記載されていますが、すべてのチェックを外してもSource Generatorとしては動作するようです。

AutoPropertyGeneratorを完成させる - プロパティの生成

AutoProperty 属性の自動生成はできたので、あとは AutoProperty 属性が付いたフィールドに対して プロパティを生成するコード生成を実装します。

大きく分けて下記の流れとなります。

  1. コード中のフィールド一覧を収集するSyntaxReceiver を実装して Initialize メソッドでその SyntaxReceiver を登録する
  2. Execute メソッド内で、1.で収集したフィールド一覧が取れるので、そのうち AutoProperty 属性がついたもののみを抽出する
  3. 2.で抽出したフィールドに対してプロパティを生成する
    • partial クラス経由で追加する

まず1.の SyntaxReceiver を実装していきます。 ISyntaxReceiver を実装するクラスを用意します。クラスの全文を示します。

class SyntaxReceiver : ISyntaxReceiver
{
    public List<FieldDeclarationSyntax> TargetFields { get; } = new
List<FieldDeclarationSyntax>();
    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        if (syntaxNode is FieldDeclarationSyntax field &&
            field.AttributeLists.Count > 0)
        {
            TargetFields.Add(field);
        }
    }
}

OnVisitSyntaxNode はC#コードをパースしたときのシンタックスツリーのノードが都度渡ってきます。

フィールドはFieldDeclarationSyntax クラスで表現されるため、その型が渡ってきたときに TargetFields へ追加しています。今回は AutoProperty がついたフィールドが対象で、つまり属性が1つ以上がついている必要があります。そのため属性数が0より多いもののみを追加しています。

このクラスを Initialize メソッド内で RegisterForSyntaxNotifications を用いて登録します。

public void Initialize(GeneratorInitializationContext context)
{
    context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}

次に2. を実装します。 RegisterForSyntaxNotification で登録した SyntaxReceiver は、 Execute メソッド内で下記のように取得できます。

public void Execute(GeneratorExecutionContext context)
{
    // context.SyntaxReceiverというプロパティに格納されているので
    // 自分の実装した型にキャストして利用する
    var receiver = context.SyntaxReceiver as SyntaxReceiver;
    if (receiver == null) return;

    // receiverを用いて処理を行う
    // ~
}

上記の receiver.TargetFields に、ユーザーが書いたC#コードの中から属性が1つ以上ついたフィールドの一覧が格納されているので、これを解析して AutoProperty 属性がついたフィールドの一覧を収集します。下記にそのコードを示します。

var fieldSymbols = new List<IFieldSymbol>();
foreach (var field in receiver.TargetFields)
{
    var model = context.Compilation.GetSemanticModel(field.SyntaxTree);
    foreach (var variable in field.Declaration.Variables)
    {
        var fieldSymbol = model.GetDeclaredSymbol(variable) as IFieldSymbol;
        // フィールドの属性から `AutoProperty` 属性があるかを確認
        var attribute = fieldSymbol.GetAttributes()
            .FirstOrDefault(attr =>
                 attr.AttributeClass.Name == "AutoPropertyAttribute");
        if (attribute != null)
        {
            // あったら追加
            fieldSymbols.Add(fieldSymbol);
        }
    }
}

receiver.TargetFieldsに格納されているFieldDeclarationSyntaxはそれ自体はC#コード中のフィールド定義の位置が格納されたオブジェクトなので、そこからプログラムで扱いやすい IFieldSymbol を取得し、 GetAttributes メソッド経由で属性一覧を列挙して AutoProperty 属性の有無を判定しています。

最後に3.を実装します。前述の通りSource Generatorでは追加のコード生成しか行えないため、既存のクラスに対しては partial クラス経由でコードを差し込みます。

// クラス単位にまとめて、そこからpartialなクラスを生成したい
// ので、フィールドを所持しているクラスでGroupBy
foreach (var group in fieldSymbols.GroupBy(field => field.ContainingType))
{
    // classSourceにクラス定義のコードが入る
    var classSource = ProcessClass(group.Key, group.ToList());
    // クラス名.Generated.cs という名前でコード生成
    context.AddSource(
        $"{group.Key.Name}.Generated.cs",
        SourceText.From(classSource, Encoding.UTF8));
}

ProcessClass がクラスのコードを生成するメソッドです。生成されたコードを「クラス名.Generated.cs」という名前で追加しています。 ProcessClass の実装を下記に示します。

private string ProcessClass(
    INamedTypeSymbol classSymbol, List<IFieldSymbol> fieldSymbols)
{
    var builder = new StringBuilder($@"
namespace {classSymbol.ContainingNamespace.ToDisplayString()}
{{
    public partial class {classSymbol.Name}
    {{
");
    foreach (var fieldSymbol in fieldSymbols)
    {
        // フィールド定義ごとに対応するプロパティを生成
        var className = fieldSymbol.Type.ToDisplayString();
        var propertyName = GetPropertyName(fieldSymbol.Name);
        builder.Append($@"
        public {className} {propertyName}
        {{
            get
            {{
                return this.{fieldSymbol.Name};
            }}
            set
            {{
                this.{fieldSymbol.Name} = value;
            }}
        }}
");
        }
        builder.Append($@"
    }}
}}
");
    return builder.ToString();
}

文字列生成が混じっていて若干読みづらいですが、StringBuilder で追記したいC#コードを組み立てています。

これで AutoPropertyGenerator は完成です。あとはビルドして生成されるDLLを先述した手順でUnityに組み込むことでコード生成が行われます。たとえば下記のコードを記述すると、

namespace Sample
{
    public partial class Player : MonoBehaviour
    {
        [AutoProperty, SerializeField] private int _hp = 10;
        [AutoProperty, SerializeField] private int _mp = 20;
    }
}

下記コードが追記されることが確認できます。

namespace Sample
{
    public partial class Player
    {

        public int Hp
        {
            get
            {
                return this._hp;
            }

            set
            {
                this._hp = value;
            }
        }

        public int Mp
        {
            get
            {
                return this._mp;
            }

            set
            {
                this._mp = value;
            }
        }

    }
}

今回紹介したコードは全体的にエラー処理が甘い(元クラスがpartialでない場合や名前空間が定義されてないクラスなどが考慮できてないなど)ので、実導入する際には適切なエラーハンドリングが必要になります。

Roslyn Analyzer

Unity - Manual: Roslyn analyzers and source generators

Roslyn アナライザーを使用したコード分析 - Visual Studio (Windows) | Microsoft Learn

Unity 2022ではRoslyn Analyzerが標準で利用できます。Roslyn Analyzerを用いると、開発者がUnityプロジェクトのC#コードを解析して、独自の警告やエラーを設定できます。

既存のUnity向けのRoslyn Analyzer実装としてMicrosoftが提供する Microsoft.Unity.Analyzers があります。これはUnityでのC#スクリプティングにおいて注意すべき実装に対して警告・エラーを出すことができます。たとえばこのAnalyzer内に実装されている「UNT0001 Empty Unity message」では、下記のような実装に対して警告を出すことができます。

using UnityEngine;

class Camera : MonoBehaviour
{
    private void FixedUpdate()
    {
    }

    private void Foo()
    {
    }
}

上記コードはUnityでのアンチパターンです。MonoBehaviourのStartやUpdateなどのメソッドは定義してしまうと空メソッドが呼び出され続けてしまい、特にUpdateやFixedUpdateなどの毎フレーム呼び出されることでは不要なメソッド呼び出しのために空メソッドの数が多い場合はパフォーマンスの低下を招きます。これは、定義しないとメソッド呼び出しも行われないため、可能な限り、空定義のUpdateやFixedUpdateは削除することが望ましいとされています。

 Microsoft.Unity.Analyzersを用いると、このようなUnity特有の問題に対しても警告を出すことができます。また、CodeFixという仕組みがあわせて用意されていて、Visual StudioやRiderなどのIDEでは、CodeFixをもとに自動でコード修正できます。

そのほかのAnalyzerとして、BannedApiAnalyzersという特定のメソッド呼び出しを行った場合に警告を出すAnalyzerや、MessagePack for C# に同梱している MessagePackAnalyzer は、MessagePackObject属性がついた(MessagePackとしてシリアライズ可能なマークをした)クラス内で、プロパティのアクセス修飾子と属性に食い違いがあると警告を出すAnalyzerを用意することで、シリアライズの設定の食い違いをC#の警告として検知できるようにしています。

もちろんRoslyn Analyzerは独自の実装が可能なので、プロジェクトのルールに応じた警告・エラーを定義することもできます。

既存のRoslyn Analyzerの導入方法

下記の手順で既存のRoslyn Analyzerを導入できます。

  1. Roslyn AnalyzerのDLLをダウンロードしてUnityプロジェクトに配置、設定を行う
  2. Ruleset filesを記載する

Microsoft.Unity.Analyzers を導入し、先述したUNT0001をUnityコンソールとRider上で確認してみます。

まず、Roslyn AnalyzerのDLLをダウンロードしてきます。こちらのページからNuGet経由で.nupkgファイルをダウンロードし、拡張子を.zipに変更してzipファイルとして解凍します。その中に Microsoft.Unity.Analyzers.dll があるので、これを Assets 配下に移動します。

DLLの設定をインスペクタ上で行います。この設定は前述のSource Generatorとほぼ同じで、下記のように設定を行います。

  • 「Select platforms for plugin」はすべてチェックを外す
  • 「Asset Labels」に「RoslynAnalyzers」を設定する

設定後のインスペクタは下図のとおりです。

次にRuleset filesを設定します。これはRoslyn Analyzersの設定を記述するファイルで、Assetsに特定の名前で配置することで動作します。命名とその動作については下記のとおりです。

  • Default.ruleset
    • Unityプロジェクト上の、asmdefが定義されていない全C#スクリプトに適用される
    • Assets直下に配置する
  • [PredefinedAssemblyName].ruleset
    • 指定したアセンブリ名に対応するcsproj配下のC#スクリプトに、Default.rulesetのルールを上書きするかたちで適用される
    • たとえばAssembly-CSharp.rulesetを定義すると、Assembly-CSharp.dll内のC#スクリプトにのみルールが適用される
    • Assembly-CSharp.ruleset、Assembly-CSharp-firstpass.ruleset、Assembly-CSharp-Editor.ruleset、Assembly-CSharp-Editor-firstpass.rulesetの4つは、Assets直下に配置する
    • asmdefによって別アセンブリにした場合 アセンブリ名.ruleset を切ることで、そのアセンブリ用のルールも定義できます。

今回はUnityプロジェクト全体にルールを適用してみます。Default.rulesetファイルをAssets直下に配置し、下記のように記述します。

<?xml version="1.0" encoding="utf-8"?>
<RuleSet Name="Default Rule Set" Description=" " ToolsVersion="10.0">
   <!-- Analyzerごとのルール設定 ->
   <Rules AnalyzerId="Microsoft.Unity.Analyzers"
RuleNamespace="Microsoft.Unity.Analyzers">
          <!-- ルールのIDと、Actionに警告にするか、エラーにするかなどを指定する ->
       <Rule Id="UNT0001" Action="Warning" />
   </Rules>
</RuleSet>

上記で「UNT0001」を警告として扱う設定となります。保存後、下記のコードを記述します。

using UnityEngine;

public class TestBehaviour : MonoBehaviour
{
   void Update()
   {
   }
}

Unityコンソールを確認すると下記のように警告としてレポートされることが確認できました。

今回は警告として扱いましたが、rulesetの設定を下記の様に書き換えることで、

<?xml version="1.0" encoding="utf-8"?>
<RuleSet Name="Default Rule Set" Description=" " ToolsVersion="10.0">
   <!-- Analyzerごとのルール設定 ->
   <Rules AnalyzerId="Microsoft.Unity.Analyzers" RuleNamespace="Microsoft.Unity.Analyzers">
          <!-- UNT0001をエラーに ->
       <Rule Id="UNT0001" Action="Error" />
   </Rules>
</RuleSet>

下記のように、エラーとして扱うこともできます。エラーにするとC#でのコンパイルエラーと同様に、ゲームの実行もできなくなります。

またRiderなど対応したエディタでは、Unityに出ているものと同じ警告やエラーを表示することもできます。

今後のC#スクリプティング環境について

今後のC#のスクリプティング環境の今後の改善は、Unite 2022やGDCで言及がありました。また、Unityブログでも紹介されています。

C#スクリプティングに絞ると、大きくは下記のような改善が予定されています。

  • ビルドパイプラインのMSBuildへの移行
  • .NET CoreCLRランタイムへの移行による.NETランタイムのモダン化
  • async/awaitベースの非同期処理の導入

Unityは現状、独自のC#ビルドパイプラインを組んでいますが、これをMSBuildに移行することを予定しています。これによってパフォーマンス向上や、NuGetエコシステムにうまく寄せることでNuGetパッケージをUnityでより簡単に扱えるなどの生産性の向上が期待できます。

また、.NETランタイム環境のモダン化も予定されています。現状のUnityのC#環境は、Mono .NETランタイムをベースにしてますが、これを .NET CoreCLRランタイムへ移行することが予定されています。

 2023年内にプレイヤー側を、2024年内にはエディター側を含めて .NET Core CLRランタイムへ移行される予定です。あわせて dotnet/runtime レポジトリからベースライブラリのサポートを予定するため、ここ数年特に進化の速いC#・最新の.NET環境に近い環境をそのままUnityでも利用できる可能性があります。

 実際に、2024年中のエディターを含めたランタイムの移行時には .NET 7.x または .NET 8.0 のすべてのAPIへのアクセスが予定されています。

非同期処理については、Unityではコルーチンをベースにした実装が主流ですが、今後のUnityでは下記のようなasync/awaitを用いた非同期処理の記述もサポートすることを予定しています。

また主要な改善では上げていませんが、本記事で紹介した Span<T> を用いたUnityの各種APIのパフォーマンスやメモリ割り当ての改善、ILポストプロセッシングの実行時間短縮などの継続的なスクリプティング環境の改善が引き続き行われる予定です。

まとめ

Unity 2022で利用できるC#スクリプティングの現状として C# 9.0や .NET Standard 2.1によって利用可能になった C#8.0の機能や追加されたAPIの紹介やSpan<T>、Source Generatorの仕組みと簡単なコード自動生成の実装、Roslyn Analyzerによる独自ルールの追加、最後に、今後来る C#スクリプティング環境の進化について紹介しました。

これはUnity 2022で使えるようになった機能というよりは、2020 LTS および 2021 LTSで追加された機能なので特別新しいではありませんが、C# 9.0や.NET Standard 2.1の機能によって、より強力かつパフォーマンスを意識したコーディングが行いやすくなったり、Roslyn Analyzerによる独自ルールの追加によってコーディングミスをコンパイラレベルで検知・修正できたり、Source Generatorによって強力なコード自動生成も行えるようになりました。

またUnityのスクリプティング環境は、C#の最新の機能を徐々に取り込むことで、日々進化しています。今後CoreCLRランタイムへの移行によって、その進化はさらに加速するでしょう。

このようにC#の各種機能を使いこなすことで、より生産性の高い開発環境が構築できます。

この記事が、最新のUnity C#スクリプティング環境のキャッチアップに役立てば幸いです。

--

筆者紹介:ゆっち〜(向井 祐一郎)

株式会社サイバーエージェントに2015年度新卒として入社し、同年に子会社の株式会社アプリボットに配属。
「Neir Re[in]carnation」のキャラクター制御などに関わりつつ、Lead Developer Experience(LDX)としてクライアントエンジニアの開発体験の向上に会社横断で取り組む。
Unityをはじめとしたゲームに関する技術に触れることが好きで、会社有志の活動としてUniTipsUnity パフォーマンスチューニングバイブルなどの執筆に関わる。
個人でも毎週のUnityに関するニュースや記事をまとめた Unity Weeklyの運用を行う。
Twitter: https://twitter.com/yucchiy_

この記事はいかがでしたか?