コンテンツへスキップ

【Unity】Alchemy – Inspector拡張+シリアル化拡張ライブラリ

  • Unity

属性を用いてInspectorを大幅に拡張するライブラリ「Alchemy」をリリースしました!今回もOSSとしてGithubに上がってます。

いつも通り使い方はREADMEを見て…と言いたいところなんですが、ちょっとドキュメントの整備が追いついてないので、詳しくはこの記事と同封のサンプルを参照してください。本当はwiki作って全属性の解説を乗っけたいんですが、なにぶん数が多いもので…

また、以前私は「Lucid Editor」という似たようなライブラリを出していましたが、Alchemyは完全に上位互換なので今後はこちらの使用を推奨します。Lucid Editorにあった属性のほとんどはAlchemyでも用意されているので(名前が変わっているのはいくつかあるけど)、移行は比較的容易にできるようになってます。

属性ベースのInspector拡張

今回作ったAlchemyは属性ベースのInspector拡張を提供するライブラリです。って言ってもわかりづらいので、コードから見せた方が早いでしょう。

using UnityEngine;
using UnityEngine.UIElements;
using Alchemy.Inspector;  // Alchemy.Inspector名前空間をusingに追加

public class BasicAttributesSample : MonoBehaviour
{
    [LabelText("Custom Label")]
    public float foo;

    [HideLabel]
    public Vector3 bar;
    
    [AssetsOnly]
    public GameObject baz;

    [Title("Title")]
    [HelpBox("HelpBox", HelpBoxMessageType.Info)]
    [ReadOnly]
    public string message = "Read Only";
}

こんなふうにMonoBehaviourやScriptableObjectのフィールドに属性をつけてやります。すると…

Inspectorの表示が変わります。あら便利。

と言ったような感じで、属性を付けるだけでInspectorの見た目をカスタマイズすることができるようになります。要するにOdinみたいなやつです。

グループ化

フィールドのグループ化も属性を付けるだけで簡単に行えます。タブなどの実装は自力で書こうとするとかなり大変ですが、Alchemyを使えば属性を付けるだけで実現できます。

using UnityEngine;
using Alchemy.Inspector;

public class GroupAttributesSample : MonoBehaviour
{
    [FoldoutGroup("Foldout")] public int a;
    [FoldoutGroup("Foldout")] public int b;
    [FoldoutGroup("Foldout")] public int c;

    [TabGroup("Tab", "Tab1")] public int x;
    [TabGroup("Tab", "Tab2")] public string y;
    [TabGroup("Tab", "Tab3")] public Vector3 z;

    [HorizontalGroup("Horizontal")][BoxGroup("Horizontal/Box1")] public float foo;
    [HorizontalGroup("Horizontal")][BoxGroup("Horizontal/Box1")] public Vector3 bar;
    [HorizontalGroup("Horizontal")][BoxGroup("Horizontal/Box1")] public GameObject baz;

    [HorizontalGroup("Horizontal")][BoxGroup("Horizontal/Box2")] public float alpha;
    [HorizontalGroup("Horizontal")][BoxGroup("Horizontal/Box2")] public Vector3 beta;
    [HorizontalGroup("Horizontal")][BoxGroup("Horizontal/Box2")] public GameObject gamma;
}

各グループはパスをスラッシュで区切ることでネストすることが可能です。ちょうど上のコードではHorizontalGroup内にBoxGroupを2つ配置しています。

ボタン

さらにメソッドをボタンとして表示させることも可能です。引数付きの場合は入力フィールドもセットでついてきます。

using System.Text;
using UnityEngine;
using Alchemy.Inspector;

[Serializable]
public sealed class SampleClass : ISample
{
    public float foo;
    public Vector3 bar;
    public GameObject baz;
}

public class ButtonSample : MonoBehaviour
{
    [Button]
    public void Foo()
    {
        Debug.Log("Foo");
    }

    [Button]
    public void Foo(int parameter)
    {
        Debug.Log("Foo: " + parameter);
    }

    [Button]
    public void Foo(SampleClass parameter)
    {
        var builder = new StringBuilder();
        builder.AppendLine();
        builder.Append("foo = ").AppendLine(parameter.foo.ToString());
        builder.Append("bar = ").AppendLine(parameter.bar.ToString());
        builder.Append("baz = ").Append(parameter.baz == null ? "Null" : parameter.baz.ToString());
        Debug.Log("Foo: " + builder.ToString());
    }
}

SerializeReference対応

さらにAlchemyはUnityの[SerializeReference]にも対応済みです。こちらはフィールドの参照をシリアル化する機能で、これを利用することでInterfaceや抽象クラスをシリアル化することが可能になっています。詳しい仕様については【Unity】SerializeReferenceをちゃんと理解するという記事が非常にわかりやすいので、そちらを参照してもらえると。

上の文だけ見ると非常に便利そうな機能ですが、SerializeReferenceは肝心のInspectorが対応していません。そのため普通に使おうとするとなんらかのエディタ拡張は必須になります。

そこでAlchemyではサブクラスを選択できるドロップダウンを用意しました。特別な設定は必要なく、Alchemyを導入するだけで手軽にInterfaceや抽象クラスを編集することが可能になります。

using UnityEngine;

public interface ISample { }

[Serializable]
public sealed class SampleA : ISample
{
    public float alpha;
}

[Serializable]
public sealed class SampleB : ISample
{
    public Vector3 beta;
}

[Serializable]
public sealed class SampleC : ISample
{
    public GameObject gamma;
}

public class SerializeReferenceSample : MonoBehaviour
{
    [SerializeReference] public ISample sample;
    [SerializeReference] public ISample[] sampleArray;
}

こんな感じで、Interfaceのフィールドや配列をInspectorから編集できるようになります。これはなかなか便利なのではないでしょうか。

ここでは紹介しきれていませんが、他にも「PlayMode中は編集不能にするDisableInPlayMode」や「条件に応じて表示/非表示を切り替えるShowIf」「不正な値に対してエラーを表示するValidateInput」などなど、様々な属性が用意されています。全ての属性の使い方は実際の動作とともにPackage Managerのサンプルで確認できるようになっているので、そちらも是非。

Source Generatorによるシリアル化拡張

これだけなら以前のLucid Editorというライブラリでも同じことができていたんですが、Alchemyはさらに「シリアル化の拡張」という機能を搭載しています。これは何かというと、Unity.Serializationパッケージと独自のSource Generatorにより、通常ではシリアル化できないような型もシリアル化してInspectorから編集可能になります。

using System;
using System.Collections.Generic;
using UnityEngine;
using Alchemy.Serialization; // Alchemy.Serialization名前空間をusingに追加

// [AlchemySerialize]属性を付加することでAlchemyのシリアル化拡張が有効化されます。
// 任意の基底クラスを持つ型に使用できますが、SourceGeneratorがコード生成を行うため対象型はpartialである必要があります。
[AlchemySerialize]
public partial class AlchemySerializationSample : MonoBehaviour
{
    // 対象のフィールドに[AlchemySerializeField]属性と[NonSerialized]属性を付加します。
    [AlchemySerializeField, NonSerialized]
    public HashSet<GameObject> hashset = new();

    [AlchemySerializeField, NonSerialized]
    public Dictionary<string, GameObject> dictionary = new();

    [AlchemySerializeField, NonSerialized]
    public (int, int) tuple;

    [AlchemySerializeField, NonSerialized]
    public Vector3? nullable = null;
}

こんな感じで、DictionaryだろうがHashSetだろうがなんでもシリアル化してInspectorに表示します。Source Generatorパワーによって必要なコードは全部自動で生成されるので、利用側に要求するのは[AlchemySerialize] [AlchemySerializeField]の2つの属性とpartialキーワードだけです。Odinのように基底クラスをSerializedBehaviourにしなければならない…といった制限も特にありません。

一応シリアル化自体は(Adapterさえ追加すれば)何の型でも可能ですが、Inspector側の表示が追いついてないものもあるので全ての型が編集可能!というわけではありません。とはいえ大体の型には対応しているので問題はないでしょう。

シリアル化の仕組み

「ブラックボックスな仕組みを使うのは怖い!」という方もいると思うので、種明かしをしておきましょう。側から見ると黒魔術っぽいことをやっているように見えますが、実際のトリックは意外と単純です。例えば以下のようなコードがあった場合。

using System;
using System.Collections.Generic;
using UnityEngine;
using Alchemy.Serialization;

[AlchemySerialize]
public partial class AlchemySerializationSample : MonoBehaviour
{
    [AlchemySerializeField, NonSerialized]
    public Dictionary<string, GameObject> dictionary = new();
}

AlchemyのSource Generatorは以下のようなコードを裏で生成します。

partial class AlchemySerializationSample : global::UnityEngine.ISerializationCallbackReceiver
{
    [global::System.Serializable]
    sealed class AlchemySerializationData
    {
        [global::System.Serializable]
        public sealed class Item
        {
            [global::UnityEngine.HideInInspector] public bool isCreated;
            [global::UnityEngine.TextArea] public string data;
        }

        public Item dictionary = new();

        [global::UnityEngine.SerializeField] private global::System.Collections.Generic.List<UnityEngine.Object> unityObjectReferences = new();

        public global::System.Collections.Generic.IList<UnityEngine.Object> UnityObjectReferences => unityObjectReferences;
    }

    [global::UnityEngine.HideInInspector, global::UnityEngine.SerializeField] private AlchemySerializationData alchemySerializationData =  new();

    void global::UnityEngine.ISerializationCallbackReceiver.OnBeforeSerialize()
    {
        if (this is global::Alchemy.Serialization.IAlchemySerializationCallbackReceiver receiver) receiver.OnBeforeSerialize();
        alchemySerializationData.UnityObjectReferences.Clear();
        
        try
        {
            alchemySerializationData.dictionary.data = global::Alchemy.Serialization.Internal.SerializationHelper.ToJson(this.dictionary , alchemySerializationData.UnityObjectReferences);
            alchemySerializationData.dictionary.isCreated = true;
        }
        catch (global::System.Exception ex)
        {
            global::UnityEngine.Debug.LogException(ex);
        }
    }

    void global::UnityEngine.ISerializationCallbackReceiver.OnAfterDeserialize()
    {
        try 
        {
            if (alchemySerializationData.dictionary.isCreated)
            {
                this.dictionary = global::Alchemy.Serialization.Internal.SerializationHelper.FromJson<System.Collections.Generic.Dictionary<string, UnityEngine.GameObject>>(alchemySerializationData.dictionary.data, alchemySerializationData.UnityObjectReferences);
            }
        }
        catch (global::System.Exception ex)
        {
            global::UnityEngine.Debug.LogException(ex);
        }

        if (this is global::Alchemy.Serialization.IAlchemySerializationCallbackReceiver receiver) receiver.OnAfterDeserialize();
    }
}

ちょっと長いですが、実際にやっていることは「ISerializationCallbackReceiverのコールバックでフィールドをJsonにシリアライズ/デシリアライズしているだけ」です。シリアル化可能なDictionaryをISerializationCallbackReceiverと配列(List)を用いて実装したことがある方も多いかと思いますが、仕組み自体はそれとほとんど変わりません。要するにUnityのシリアライザが認識可能な形にパースしてやればいいんですね。

Odinのシリアル化も根本的なアプローチは全く同じで、あちらは専用のOdin Serializerを用いてこのような処理を実装しています。ただし、Odinのシリアル化を適用する際には専用の基底クラスを継承するか、ISerializationCallbackReceiverの実装を自力で行う必要があります。

対してAlchemyはSource Generatorを採用しているため、そのような制限なしにシリアライズを行えます。また独自シリアライザではなくUnity公式のパッケージを使うため安定性も十分だと言えるでしょう。ISerializationCallbackReceiverの代用としてIAlchemySerializationCallbackReceiverというインターフェースも用意されており、追加でシリアル化の処理を差し込むことも可能になっています。

ただしこの手法は、Unity内部のシリアライザと合わせて2度のシリアル化を行っていることになります。パフォーマンスが問題になるような自体は滅多にないと思いますが、可能な限りAlchemySerializeFieldの使用は避けるようにしてください。

UI Toolkitを用いたエディタ拡張

以前のLucid EditorはIMGUIをベースに独自のレイヤーを被せるというややこしい実装になっていましたが、AlchemyはUI Toolkitを基盤として設計されています。

「UI Toolkit、なんかとっつきづらそう…」という理由で避けていたんですが、いざ使ってみるとすっごい便利。各コンポーネントをVisual Elementで構築していく形なので再利用性が半端なく高い。UI Builderもありますがサクッと書くだけならC#上で全て構築できるので、もはやエディタ拡張ならUI Toolkit一択でしょう。IMGUIContainerを使えば部分的にIMGUIを使ったりもできるし。

ランライムの利用は….うーん、どうだろう。使いやすくはあるけど、本格的に使うには機能不足感が半端ないというか…

まあ現状ランタイムで使うのは色々面倒そうだし、エディタ拡張用と思っておくのがいいでしょう。ただ少しづつ改善は進められているので、実用に乗る日が来るのもそう遠くはなさそう…かも….?

まとめ

この辺りのエディタ拡張といえばOdin、みたいな感じではありますし、実際Odinを超える機能を持つアセットは存在しないと言っていいくらい強力ではあります。ただ、本体自体も有料アセットとしてはやや高額だし、Odin 3.0から収益額の制限が追加されたりと、大規模な開発で使うにはライセンス周りが面倒でした。

Alchemyは機能面ではOdinに敵いませんが、OSSであるというのは強みだと思ってます。MITライセンスだから無料だし、後から有料化する予定も全くないので気楽に使えます。Forkして改造してもらうこともできるし、自分でメンテナンスできなくなったら誰かに受け渡すことも全然可能だし。

あと、IssueやPull Requestでどんどん改善していけるのもOSSならではじゃないかなーと。私がライブラリをオープンソース化しているのはそれが一番の理由なんですよね。やっぱり自分だけだと限界があるので、積極的に公開していった方がより良いものになっていくと思うし。

というわけでAlchemy、かなり便利なライブラリになっていると思うので、是非是非使ってみてください!機能追加も行っていきたいので、フィードバックも頂けると嬉しいです!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です