[Unity]正攻法でUIのマテリアルをいじる


本記事は[QualiArts Advent Calendar 2021](https://qiita.com/advent-calendar/2021/qualiarts) 12日目の記事となります。


# シェーダーのプロパティをC#で制御する
UIの彩度を変更するシェーダーを作りました。
シェーダーの `_Saturation`プロパティを変更すると彩度が変わるシンプルなものです。


<details>
<summary>彩度のシェーダーコード</summary>
<div>

``` GLSL
Shader "UI/UISaturation"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Saturation("Saturation", Range (0, 2)) = 1
    }
    SubShader
    {
        Cull Off ZWrite Off ZTest Always
        Tags {"Queue" = "Transparent" "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            sampler2D _MainTex;
            float _Saturation;

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                float3 grayXfer = float3(0.3, 0.59, 0.11);
                float grayf = dot(grayXfer, col);
                float3 gray = float3(grayf, grayf, grayf);
                return float4(lerp(gray, col, _Saturation), 1.0);
            }
            ENDCG
        }
    }
}
```
</div>
</details>

このプロパティをC#スクリプトから制御する方法についてまとめます。
![画面収録 2021-12-07 17.32.26.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213392/c9ff9830-a6a6-c811-c7ab-038e89237799.gif)


# 失敗例
Updateの中でGraphic(Imageの親)のマテリアルに `SetFloat`してみました。

``` C#
    [RequireComponent(typeof(Graphic))]
    [ExecuteAlways]
    public class UISaturation2 : MonoBehaviour
    {
        [SerializeField, Range(0, 2)]
        private float _saturation = 1;

        private Graphic _graphic;
        public Graphic graphic => _graphic ? _graphic : _graphic = GetComponent<Graphic>();
        public readonly int saturationPropertyId = Shader.PropertyToID("_Saturation");

        private void Update()
        {
            graphic.material.SetFloat(saturationPropertyId, _saturation);
        }
    }
```

C# 側から彩度をコントロール出来てるように見えます。
![画面収録 2021-12-07 17.50.24-lossy.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213392/83b2eeaa-5617-236a-a375-179703b66d4c.gif)


# 問題点
Updateの中で`SetFloat`する方法には問題があります。
`graphic.material`は共通のものなので、マテリアルを共有している別のコンポーネントの彩度まで変わってしまいました。
![画面収録 2021-12-07 17.52.30-lossy.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213392/bd50fe86-d79e-8eef-8c3e-02599fdd5f22.gif)


これを回避するためには、値を変更するタイミングでマテリアルを複製して差し替える必要があります。
しかしEditorでも動かすことを前提にすると、どのタイミングでマテリアルを複製するのか、さらに複製したマテリアルをいつ元に戻すのかの制御が難しくなってしまいます。

# 正攻法
Unityが用意しているMaskコンポーネントでは、 `IMaterialModifier`を使ってマテリアルの値を変更してました。
https://docs.unity3d.com/ja/2018.4/ScriptReference/UI.IMaterialModifier.html

Maskを参考にして彩度を変更するスクリプトを正攻法で実装するとこうなります。

```C#
    [RequireComponent(typeof(Graphic))]
    [ExecuteAlways]
    [DisallowMultipleComponent]
    [AddComponentMenu("UI/Effects/Saturation", 15)]
    public class UISaturation : UIBehaviour, IMaterialModifier
    {
        [SerializeField]
        private float _saturation = 1;

        [NonSerialized]
        private Graphic _graphic;
        public Graphic graphic => _graphic ? _graphic : _graphic = GetComponent<Graphic>();

        /// <summary>
        /// 彩度変更用のマテリアル
        /// </summary>
        [NonSerialized]
        private Material _saturationMaterial;

        public readonly int saturationPropertyId = Shader.PropertyToID("_Saturation");

        protected override void OnEnable()
        {
            base.OnEnable();
            if(graphic == null) return;
            _graphic.SetMaterialDirty();
        }

        protected override void OnDisable()
        {
            base.OnDisable();
            if (_saturationMaterial != null) DestroyImmediate(_saturationMaterial);
            _saturationMaterial = null;

            if (graphic != null) _graphic.SetMaterialDirty();

        }

#if UNITY_EDITOR
        protected override void OnValidate()
        {
            base.OnValidate();
            if (!IsActive() || graphic == null) return;
            graphic.SetMaterialDirty();
        }
#endif

        protected override void OnDidApplyAnimationProperties()
        {
            base.OnDidApplyAnimationProperties();
            if (!IsActive() || graphic == null) return;
            graphic.SetMaterialDirty();
        }

        public Material GetModifiedMaterial(Material baseMaterial)
        {
            // 彩度変更に対応していないマテリアルを弾く
            if (IsActive() == false || _graphic == null || !baseMaterial.HasProperty(saturationPropertyId))
                return baseMaterial;

            // マテリアル複製
            if (_saturationMaterial == null)
            {
                _saturationMaterial = new Material(baseMaterial);
                _saturationMaterial.hideFlags = HideFlags.HideAndDontSave;
            }

            // これまでのプロパティを引き継ぐ
            _saturationMaterial.CopyPropertiesFromMaterial(baseMaterial);

            _saturationMaterial.SetFloat(saturationPropertyId, _saturation);

            return _saturationMaterial;
        }
    }
```

ポイントは `IMaterialModifier` を実装する `GetModifiedMaterial` メソッドで、このメソッドはGraphicのマテリアルにDirtyフラグが設定されたタイミングで呼ばれます。
`GetModifiedMaterial`は以下のように `IMaterialModifier`を実装した全てのコンポーネントが順番に呼び出されます。

``` :GetModifiedMaterialの仕組み
Graphicに設定されたマテリアル
↓
AコンポーネントのGetModifiedMaterial
↓
BコンポーネントのGetModifiedMaterial
↓
CコンポーネントのGetModifiedMaterial
↓
レンダリングされるマテリアル
```
よってbaseMaterialには前のコンポーネントで変更されたマテリアルの情報が入ります。

## GetModifiedMaterialの内容
`GetModifiedMaterial` 内の処理を切り出して説明します

``` C#
            // 彩度変更に対応していないマテリアルを弾く
            if (IsActive() == false || _graphic == null || !baseMaterial.HasProperty(saturationPropertyId))
                return baseMaterial;
```
ここでは以下の状態の時にマテリアルを変更せずに、処理を終わらせます
1. 自身のオブジェクトが非アクティブの時
2. Graphicコンポーネントが見つからない時
3.. シェーダー が彩度変更に対応していない時

``` C#
            // マテリアル複製
            if (_saturationMaterial == null)
            {
                _saturationMaterial = new Material(baseMaterial);
                _saturationMaterial.hideFlags = HideFlags.HideAndDontSave;
            }
```
ここでマテリアルを複製します。これにより個別に彩度を調整できるようになります。
このマテリアルは動的に生成され、保存する必要はないため、HideFlagに`HideFlags.HideAndDontSave` を指定して、シーンに対して変更通知が飛ばないようにしています。

``` C#
            // これまでのプロパティを引き継ぐ
            _saturationMaterial.CopyPropertiesFromMaterial(baseMaterial);
```
GetModifiedMaterialはコンポーネント毎に順番に呼び出されます。そのため前のコンポーネントで変更されたプロパティをここで引継ぎます。

``` C#
            _saturationMaterial.SetFloat(saturationPropertyId, _saturation);
```
最後に彩度のパラメーターを設定します。

## 値変更時にマテリアルを更新する
GetModifiedMaterialを設定しただけではマテリアルは更新されません。GetModifiedMaterialはGraphicのマテリアルにDirtyフラグが設定されたタイミングで呼ばれるため更新したいタイミングでDirtyフラグを設定します

``` C#
#if UNITY_EDITOR
        protected override void OnValidate()
        {
            base.OnValidate();
            if (!IsActive() || graphic == null) return;
            graphic.SetMaterialDirty();
        }
#endif

        protected override void OnDidApplyAnimationProperties()
        {
            base.OnDidApplyAnimationProperties();
            if (!IsActive() || graphic == null) return;
            graphic.SetMaterialDirty();
        }
````
`OnValidate` と `OnDidApplyAnimationProperties` メソッドをオーバーライドして、Editorから値を変更した時と、アニメーションカーブで値を変更した時にGraphicのマテリアルにDirtyフラグを設定します。


# 結果
これで、コンポーネント毎に彩度を変えられるようになりました。
また `GetModifiedMaterial`を使った正攻法でマテリアルを変更する事で、Unityが用意しているMaskコンポーネントと競合することなくマテリアルの値を変更できます。
![画面収録 2021-12-07 17.55.53.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213392/7051fd7d-0b68-9911-060e-e13c7e919ae6.gif)



#### 参考
* [うにばな　（シェーダのカラーコレクト・ブレンドに関する）](https://akinow.livedoor.blog/archives/52373780.html)