はじめに
こんにちは!
プリントシール機のソフトウェア開発を担当している小川です。
今回の記事ですが、タイトル通り、Godot(C#)のExport属性とUnityのSerializeField属性の比較を行っていきます。
そのため、UnityやGodotといったゲームエンジンを使ったプログラミング経験者向けの内容となっていますので、ご了承ください。
UnityではSerializeField属性を利用することで、インスペクターに値を設定することができます。
利点が多くあるため、よく使っている方も多いのではないでしょうか。
Godotも同様な機能を備えており、Export属性を利用することでインスペクターに設定することができます。
しかし、UnityのSerializeField属性とGodotのExport属性で仕様が異なるので、備忘録がてらまとめたものを今回ご紹介します。
皆様の開発のご助力となれば幸いです。
///////////////
☆バージョン情報☆
Godot 4.5.1 ( C# )
.NET 8.0
Unity 6.3 LTS
///////////////
背景
近年、Godotというゲームエンジンの名を耳にすることが多くなりました。
Godotは始めるハードルが低いというのもあり、技術検証がてら触ってみることにしました。
言語機能の豊富さとUnity資産の流用の可能性を鑑み、私はGDScriptではなくC#を使っているのですが、
当然ながら、GodotとUnityでは仕様が異なる
巷のGodotユーザはGDScriptを利用する場合が多く、GodotのC#についての記事が少ない
といったことがあったため、今回、Godotを触ってみたいUnity経験者への一助として、記事を書くことにしました。
前提知識
GodotやSerializeField属性について軽く説明します。本編が見たい方は飛ばしてください。
Godotとは
Godotとは、オープンソースソフトウェアとして開発されているゲームエンジンです。 クロスプラットフォーム対応で、主要OSは全て対応しており、ダウンロードも無料です。 godotengine.org オープンソースで開発されているゲームエンジンは数多くありますが、現状最も有名なものがGodotかと思います。 ちなみに読み方は「ゴドー」ですが、人によっては「ゴドット」と言ったりしているようで、まちまちです。 私は正確な読み方で読む方が好みなので、「ゴドー」と発音しています。
言語としては、GDScriptというPythonに似たオリジナル言語と、C#(最新だと.NET)が使えます。 2Dと3Dの両方の開発が可能です。
なんといってもその特徴としては、エディターの軽さかなと思います。 本体のバイナリサイズは200MBもなく、動作も軽快です。 Windows版だとインストーラーではなくポータブル方式で提供されています。

SerializeField属性とは
Unityにおいて、変数の値をインスペクターで編集できる機能です。 intのようなプリミティブ型から、Prefabまで、様々なものをインスペクター上で設定できます。 変数にSerializeField属性を付与することで、利用可能になります。
[SerializeField] private int hoge = 0;

SerializeField属性を使う理由としては以下が挙げられます。
コードに変更が入らない
コードに変更が入ると、エンジニア以外が触れなくなる、(運用にもよりますが)コードレビューが必要になる、といったことが発生してしまいます。カプセル化を守れる
アクセス修飾子がprivateのままでもシリアライズできるため、スクリプト上でのカプセル化が守られます。
Godotにおいてこの機能に相当するのがExport属性です。
[Export] private int hoge = 0;
Godot上で、このようにExport属性をつけることで、

Unityと同様、インスペクター上で値を編集できるようになります。
これらの属性を使う注意点として、GetComponentやResources.Loadとは異なり実行時のコストはほぼ無い一方、シーンのロード時にオーバーヘッドが発生する点が挙げられます。 そのため、大きなクラスであったり大量に使用したりする際はシーンロード時の時間増加に繋がる可能性があります。
比較
実際にUnityのSerializeField属性とGodotのExport属性を、「どの型がどこまでシリアライズ可能か」という観点で比較します。
プリミティブ型・String型
Unity
// 可能 [SerializeField] private int hoge = 0; [SerializeField] private string hoge = string.Empty; // 値の制限 [SerializeField, Range(0, 100)] private int hoge = 0;
Godot
// 可能 [Export] private int hoge = 0; [Export] private string hoge = string.Empty; // 値の制限 [Export(PropertyHint.Range, "-10,20,1")] private int hoge = 0;
プリミティブ型・String型は双方シリアライズ可能です。 値の制限に関しては、エディター上で制限であり、実行時には制限されません。 ちなみにプリミティブ型に限らずですが、UnityではFormerlySerializedAs属性を付けて値を保持したまま変数名を変更することが可能であるものの、Godotでは現状できません。
ゲームエンジン組み込み型
Unity
// 可能 [SerializeField] private UnityEngine.Vector3 hoge;
Godot
// 可能 [Export] private Godot.Vector3 hoge;
双方可能です。
配列とコレクション
Unity
// 配列は可能 [SerializeField] private int[] hoge = { 0 }; // リストは可能 [SerializeField] private List<int> hoge = new List<int>(); // リスト以外(例えば辞書型)は不可 [SerializeField] private Dictionary<int> hoge = new Dictionary<int>();
Godot
// 配列は可能 [Export] private int[] hoge = { 0 }; // リスト不可 [Export] private List<int> hoge = new List<int>(); // リスト以外(例えば辞書型)も不可 [Export] private Dictionary<int> hoge = new Dictionary<int>(); // 可能 [Export] private Godot.Collections.Array<int> hoge = new Array<int>(); // 可能 [Export] private Godot.Collections.Dictionary<int> hoge = new Dictionary<int>();
Godotの方は、System.Collections.Genericは全て不可ですが、GodotのAPIとして用意されているGodot.Collectionsは全て可能です。
JIT最適化等を考えるとGodot.Collectionsの値は実行時にSystem.Collections.Genericに変換して利用するのが良さそうです。
ちなみにGodot.CollectionsにListは存在しません。Godot.Collections.Arrayを代替利用します。
これは、Godot.CollectionsはエディターだったりGDScriptと互換性がある型になっているからです。(=Variant互換)
プロパティ
Unity
// 不可 [SerializeField] private int Hoge {get; set;} = 0; // バッキングフィールドであれば可能 [SerializeField] private int hoge = 0; public int hoge { get { return hoge; } set { hoge = value; } } // 属性ターゲットをfieldにすれば可能だが、実質バッキングフィールドのSerializeField属性と変わらず、問題点あり [field: SerializeField] public int Hoge { get; private set; }
Godot
// 可能 [Export] private int Hoge {get; set;} = 0;
Godotはプロパティもシリアライズできますが、Unityはバッキングフィールドでないとできません。
特にfield: SerializeField属性を使う場合に注意が必要です。この書き方は、属性ターゲットでプロパティのフィールドを指定するというちょっと裏技のような書き方で、一見プロパティをシリアライズしているように見えて実際シリアライズされるのは隠れたバッキングフィールドです。
なので、FormerlySerializedAs属性が使えなかったり、バッキングフィールドの変数名がバージョン変更等により変更されるとリセットされてしまうといったことが起きてしまいます。
Godot側でもプロパティをシリアライズした時に実際に値が入るのは隠れたバッキングフィールドですが、正式にゲームエンジン側で対応しているため、Unityのfield: SerializeField属性のようなことは起きないと考えられます。
Enum
Unity
// 可能 [SerializeField] private HogeEnum hoge = HogeEnum.hoge; public enum HogeEnum{ hoge, fuga, piyo }
Godot
// 可能 [Export] private HogeEnum hoge = HogeEnum.hoge; public enum HogeEnum{ hoge, fuga, piyo }
双方可能です。 どちらもエディター上にはドロップダウンメニューとして表示されます。
アクセス修飾子
Unity
// 可能 public int hoge = 0; // publicでもインスペクターに表示されない [NonSerialized] public int hoge = 0;
Godot
// 不可 public int hoge = 0;
SerializeField属性やExport属性を付けた場合、アクセス修飾子関係なくシリアライズされます。
Unityでは変数に属性なしでもアクセス修飾子としてpublicを指定した場合、自動でシリアライズされます。
(防ぎたい場合はNonSerialized属性かHideInInspector属性を付ける)
Godotの場合は、シリアライズにアクセス修飾子は関係せず、明示的にExport属性を付けない限りシリアライズされません。
static・const・readonly
Unity
// 不可 [SerializeField] static int hoge = 0; // 不可 [SerializeField] const int hoge = 0; // 不可 [SerializeField] readonly int hoge = 0;
Godot
// 不可 [Export] static int hoge = 0; // 不可 [Export] const int hoge = 0; // 不可 [Export] readonly int hoge = 0;
static、const、readonlyは当然ながらどちらも使えません。
インターフェース
Unity
// 不可 [SerializeField] private IHoge hoge; //SerializeReference属性を使えば一応可。ただしインスペクターに設定できるのはインターフェースを継承したクラスのフィールド。 [SerializeReference] private IHoge hoge; public interface IHoge { void Fuga(); } [Serializable] public class Hoge : IHoge { public int fuga= 0; void Fuga(); }
Godot
// 不可 [Export] private IHoge hoge;
インターフェースはどちらも不可で、Unityの場合はSerializeReferenceを使えばいけないことはない、といった感じです。
その場合、あるインターフェースを継承したゲームオブジェクトをそのまま入れられる(クラスを直接参照する)わけではなく、そのフィールドを入れるような使い方になります。
実現したい場合は、ベースクラスを作るか、クラス参照を取得してインターフェースにアップキャストするのが素直だと思います。
Scene(Unity・Godot)・Prefab(Unity)
Unity
// Sceneは不可 [SerializeField] private GameObject hogeScene; // Prefab可能 [SerializeField] private GameObject hogePrefab;
Godot
// Scene可能 [Export] private PackedScene packedScene;
Godot では、Unityでいう「実行用シーン」と「再利用用シーン(Prefab)」を区別せず、 PackedScene として扱っています。
そのためGodotでのSceneは、UnityでいうSceneとPrefab両方に当たります。
GameObject(Unity)・Node(Godot)
Unity
// 可能 [SerializeField] private GameObject hoge = null;
Godot
// ノード可能 [Export] private Node hoge = null; // NodePathも可能 [Export] private NodePath hoge = null;
Asset等
Unity
// テキスト(JSON/CSV/テキスト等)可能 [SerializeField] private TextAsset hoge; // SpriteやTextureも可能 [SerializeField] private Sprite hoge; [SerializeField] private Texture2D texture2D; // MaterialやShaderも可能 [SerializeField] private Material hoge; [SerializeField] private Shader hoge; // ScriptableObjectも可能 [SerializeField] private Hoge hoge; public class Hoge : ScriptableObject { public int hoge = 0; }
Godot
// テキスト(JSON/CSV/テキスト等)はTextAssetがないため不可 // String型で文字列かテキストファイルへのパスを持ち、読み込みに行くか、リソースファイルを作成し利用する [Export] private string hoge; // Textureも可能 Spriteはノードとして扱うためアセットとしては存在しない [Export] private Texture2D hoge = null; // MaterialやShaderも可能 [Export] private Shader hoge = null; [Export] private ShaderMaterial hoge = null;
自作クラス
Unity
// クラスや構造体にSerializable属性を付け、インスタンス変数にSerializeField属性を付けたらクラスもシリアライズ可能 [SerializeField] private Hoge hoge = new Hoge(); [Serializable] public class Hoge { public int foo = 0; }
Godot
// Serializable属性の代わりに、Resourceを継承したクラスを作成、リソースファイルを作成して利用 // ただし、リソースファイルの設定を変更しておかないと複数ノードから共有されることになるため注意(リソースファイルの仕様) // インナークラスは不可 [Export] private Hoge hoge = null; [GlobalClass] public partial class Hoge: Resource { [Export] private int hoge = 0; }
グループ化
Unity
// グループ化不可 代わりにHeader属性やSpace属性を使う、拡張機能を使う等の選択肢はある [Header("hoge")] [SerializeField] private int hoge = 0;
Godot
// この属性を付けることでインスペクターがグループ化されて表示される [ExportGroup("hoge")] [Export] private int hoge = 0; // こちらもグループ化の対象 [Export] private int fuga = 0; // グループのネストも可能 [ExportSubGroup("hoge")] [Export] private int hoge = 0; // インスペクター上で新たなカテゴリが作成される [ExportCategory("hoge")] [Export] private int hoge = 0;
まとめ
| Unity | Godot | 備考 | |
|---|---|---|---|
| プリミティブ型・String型 | ◯ | ◯ | |
| ゲームエンジン組み込み型 | ◯ | ◯ | |
| 配列とコレクション | △ | △ | Unityは配列・リスト、Godotは配列・Godot.Collectionsが可能 |
| プロパティ | × | ◯ | Unityはバッキングフィールドを指定する方法を使えば一応可能 |
| Enum | ◯ | ◯ | |
| インターフェース | × | × | UnityはSerializeReference属性を使えば一応可能 |
| アクセス修飾子 | 関係あり | 関係なし | Unityはpublicをつけるとシリアライズされる |
| static・const・readonly | × | × | |
| Scene(Unity・Godot)・Prefab(Unity) | △ | ◯ | UnityはSceneは不可、Prefabは可 |
| GameObject(Unity)・Node(Godot) | ◯ | ◯ | |
| Asset等 | ◯ | ◯ | |
| 自作クラス | ◯ | ◯ | GodotはResource継承必須、インナークラスは不可 |
| グループ化 | × | ◯ |
所感
エコシステムが大きく異なるため、単純な仕様比較で優劣を付けることはできませんが、
GodotのExportは仕様として「Variant互換なものが可能」と、シンプルで、仕様的にも使い勝手を重視している印象を受けました。
このあたりの設計は後続のゲームエンジンであることの利点かなと思います。
それに対してUnityは、ゲームエンジンとしては長い歴史があるだけに、様々な属性を活用することで柔軟に対応できるようになっているなと感じました。
現状のGodotにおいて、
(リソースクラスを作る際にインナークラス化できないため)UnityのようにSerializable属性を付けたインナークラスを作りインスタンス変数をシリアライズする
シリアライズ状態を保持しながら変数名を変更する(UnityでいうFormerlySerializedAs属性)
といったことができないのが辛いなと感じます。
しかしながら、GodotのGithubなりDev snapshotを見ていただいたら分かる通り、Godotの開発は活発に進んでいます。
例えば、GodotでのFormerlySerializedAs属性にあたる部分を実装しようという議論は既になされています。
新たな仕様が次々と出ており、変更・改善が常に行われているため、今後もGodotの動向に関心を向けていこうと思います。