【Unity】【シェーダ】【エディタ拡張】KeywordEnumがスクリプトから扱いづらい問題とその解決案

KeywordEnumがスクリプトから扱いづらい問題とその解決案です。

Unity2018.3.1

問題点

下記のようにKeywordEnumアトリビュートを使うとキーワードが簡単に扱えます。

Shader "KeywordEnumExample"
{
    Properties
    {
        // KeywordEnumでプロパティを定義
        [KeywordEnum(RED, GREEN, BLUE)]_Example("Example", Int) = 0  
    }
    SubShader
    {
        Pass
        {
            CGPROGRAM
           #pragma vertex vert
           #pragma fragment frag
            // KeywordEnumによりキーワードが使えるようになる
           #pragma multi_compile _EXAMPLE_RED _EXAMPLE_GREEN _EXAMPLE_BLUE

           #include "UnityCG.cginc"

            float4 vert (float4 vertex : POSITION) : SV_POSITION
            {
                return UnityObjectToClipPos(vertex);
            }

            fixed4 frag () : SV_Target
            {
               #ifdef _EXAMPLE_RED
                    return fixed4(1, 0, 0, 1);
               #elif _EXAMPLE_GREEN
                    return fixed4(0, 1, 0, 1);
               #elif _EXAMPLE_BLUE
                    return fixed4(0, 0, 1, 1);
               #endif
                return 1;
            }

            ENDCG
        }
    }
}

Materialのインスペクタはこんな感じになります。便利。

f:id:halya_11:20190115234701p:plain

ただこれ、Materialを外側から編集するときにちょっと困ります。
次のようにMaterial.SetFloat()で値を変えると、値は変わるもののKeywordが変更されません。

using UnityEngine;
using UnityEditor;

public class Example
{
    [MenuItem("Example/Example")]
    private static void ChangeColor()
    {
        var material = Selection.activeObject as Material;
        if (material == null)
            return;

        // floatの値は更新されるがKeywordが更新されない
        var current = material.GetFloat("_Example");
        current = current >= 2 ? 0 : current + 1;
        material.SetFloat("_Example", current);
    }
}

したがってKeywordを変える処理も一緒に書かないといけないわけですが、
KeywordEnumアトリビュートを持つプロパティ全てにそんなことをやっていたら大変だし
メンテナンス性が非常に低下してバグの温床になります。

そこで次節からのような解決案を考えてみました。

拡張メソッドを定義する

まずマテリアルにEnumの値を渡すとプロパティとキーワードを更新してくれるメソッドを作ります。

たとえばColor.RedというEnumを渡すと Colorプロパティを更新し、COLOR_REDというキーワードを有効化します。

public static class MaterialExtensions
{
    /// <summary>
    /// 値を変更してキーワードも切り替える
    /// 対象のプロパティは「_(Enumの名前)」
    /// 対象のキーワード名は「_(Enumの名前)_(Enum要素の名前)」
    /// </summary>
    public static void SetEnumPropertyWithKeyword(this Material material, System.Enum enumValue)
    {
        var propertyName = "_" + enumValue.GetType().Name;
        var intValue = System.Convert.ToInt32(enumValue);
        material.SetFloat(propertyName, intValue);

        foreach (System.Enum item in System.Enum.GetValues(enumValue.GetType())) {
            var keyword = propertyName + "_" + item.ToString();
            keyword = keyword.ToUpper();
            if (System.Convert.ToInt32(item) == intValue) {
                material.EnableKeyword(keyword);
            }
            else {
                material.DisableKeyword(keyword);
            }
        }
    }


}

MaterialPropertyDrawerを定義する

次にシェーダ用のPropertyDrawerを作ります。 Enum名で指定可能なKeywordEnum、というイメージです。

このポップアップをインスペクタから変更すると「(Enumの名前)(Enum要素の名前)」で定義されたキーワードが更新されます。

using UnityEngine;
using UnityEditor;

/// <summary>
/// Enumの名前を指定するとPopup表示をする
/// 更新時には値と一緒にKeywordをEnable/Disableする
/// Keyword名は「_(Enumの名前)_(Enum要素の名前)」で定義される
/// </summary>
public class EnumWithKeywordDrawer : MaterialPropertyDrawer
{
    private string _enumTypeName;

    public EnumWithKeywordDrawer(string enumTypeName){
        _enumTypeName = enumTypeName;
    }

    public override void OnGUI(Rect position, MaterialProperty prop, GUIContent label, MaterialEditor editor)
    {
        var value = prop.floatValue;
        var valueString = ((int)prop.floatValue).ToString();
        var enumType = System.Type.GetType(_enumTypeName);
        var enumValueObj = System.Enum.Parse(enumType, valueString) as System.Enum;


        using (var ccs = new EditorGUI.ChangeCheckScope()) {
            
            // Enumの値を選択するPopupを表示
            enumValueObj = EditorGUI.EnumPopup(position, label, enumValueObj);
            
            if (ccs.changed) {
                // プロパティを更新
                prop.floatValue = System.Convert.ToInt32(enumValueObj);
                // Keywordを更新
                editor.RegisterPropertyChangeUndo("Refresh Material Keyword");
                foreach (Material material in editor.targets) {
                    material.SetEnumPropertyWithKeyword(enumValueObj);
                }
            }
        }
    }
}

実装方針

これらを使って、ドロップダウンでキーワードを切り替えるプロパティは下記の方針で実装することにします。

  • Enumはcsファイルに定義
  • シェーダプロパティはたとえばMaskModeという名前のEnumなら以下のような書き方で実装
[EnumWithKeyword(MaskMode)]_MaskMode("Mask Mode", float) = 0
  • shader_feature/multi_compileを定義
  • プログラムから更新する際にはMaterial.SetEnumPropertyWithKeyword()を使う

使ってみる

それではこの方針で最初のシェーダを書き換えてみます。

まずはEnumを定義します。

public enum ExampleColor
{
    Red,
    Blue,
    Green
}

次にEnumWithKeywordを使ってシェーダを書き換えます。

Shader "KeywordEnumExample"
{
    Properties
    {
        // EnumWithKeywordとEnum名を使う
        [EnumWithKeyword(ExampleColor)]_ExampleColor("Color", Int) = 0  
    }
    SubShader
    {
        Pass
        {
            CGPROGRAM
           #pragma vertex vert
           #pragma fragment frag
            // 「_(Enumの名前)_(Enum要素の名前)」でキーワードを定義
           #pragma multi_compile _EXAMPLECOLOR_RED _EXAMPLECOLOR_GREEN _EXAMPLECOLOR_BLUE

           #include "UnityCG.cginc"

            float4 vert (float4 vertex : POSITION) : SV_POSITION
            {
                return UnityObjectToClipPos(vertex);
            }

            fixed4 frag () : SV_Target
            {
               #ifdef _EXAMPLECOLOR_RED
                    return fixed4(1, 0, 0, 1);
               #elif _EXAMPLECOLOR_GREEN
                    return fixed4(0, 1, 0, 1);
               #elif _EXAMPLECOLOR_BLUE
                    return fixed4(0, 0, 1, 1);
               #endif
                return 1;
            }

            ENDCG
        }
    }
}

これだけです。インスペクタはこんな感じ。

f:id:halya_11:20190116001021p:plain

Materialを外側から編集する場合には拡張メソッドを使えばプロパティと一緒にキーワードも更新されます。

using UnityEngine;
using UnityEditor;

public class Example
{
    [MenuItem("Example/Example")]
    private static void ChangeColor()
    {
        var material = Selection.activeObject as Material;
        if (material == null)
            return;

        var current = material.GetFloat("_ExampleColor");
        current = current >= 2 ? 0 : current + 1;
        // 拡張メソッドを使えばプロパティもキーワードも更新される
        material.SetEnumPropertyWithKeyword((ExampleColor)current);
    }
}

これでキーワード付きのEnumが扱いやすくなりました。