本記事はQualiArts Advent Calendar 2021 12日目の記事となります。
シェーダーのプロパティをC#で制御する
UIの彩度を変更するシェーダーを作りました。
シェーダーの _Saturation
プロパティを変更すると彩度が変わるシンプルなものです。
彩度のシェーダーコード
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
}
}
}
このプロパティをC#スクリプトから制御する方法についてまとめます。
失敗例
Updateの中でGraphic(Imageの親)のマテリアルに SetFloat
してみました。
[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);
}
}
問題点
Updateの中でSetFloat
する方法には問題があります。
graphic.material
は共通のものなので、マテリアルを共有している別のコンポーネントの彩度まで変わってしまいました。
これを回避するためには、値を変更するタイミングでマテリアルを複製して差し替える必要があります。
しかしEditorでも動かすことを前提にすると、どのタイミングでマテリアルを複製するのか、さらに複製したマテリアルをいつ元に戻すのかの制御が難しくなってしまいます。
正攻法
Unityが用意しているMaskコンポーネントでは、 IMaterialModifier
を使ってマテリアルの値を変更してました。
https://docs.unity3d.com/ja/2018.4/ScriptReference/UI.IMaterialModifier.html
Maskを参考にして彩度を変更するスクリプトを正攻法で実装するとこうなります。
[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
を実装した全てのコンポーネントが順番に呼び出されます。
Graphicに設定されたマテリアル
↓
AコンポーネントのGetModifiedMaterial
↓
BコンポーネントのGetModifiedMaterial
↓
CコンポーネントのGetModifiedMaterial
↓
レンダリングされるマテリアル
よってbaseMaterialには前のコンポーネントで変更されたマテリアルの情報が入ります。
GetModifiedMaterialの内容
GetModifiedMaterial
内の処理を切り出して説明します
// 彩度変更に対応していないマテリアルを弾く
if (IsActive() == false || _graphic == null || !baseMaterial.HasProperty(saturationPropertyId))
return baseMaterial;
ここでは以下の状態の時にマテリアルを変更せずに、処理を終わらせます
- 自身のオブジェクトが非アクティブの時
- Graphicコンポーネントが見つからない時
3.. シェーダー が彩度変更に対応していない時
// マテリアル複製
if (_saturationMaterial == null)
{
_saturationMaterial = new Material(baseMaterial);
_saturationMaterial.hideFlags = HideFlags.HideAndDontSave;
}
ここでマテリアルを複製します。これにより個別に彩度を調整できるようになります。
このマテリアルは動的に生成され、保存する必要はないため、HideFlagにHideFlags.HideAndDontSave
を指定して、シーンに対して変更通知が飛ばないようにしています。
// これまでのプロパティを引き継ぐ
_saturationMaterial.CopyPropertiesFromMaterial(baseMaterial);
GetModifiedMaterialはコンポーネント毎に順番に呼び出されます。そのため前のコンポーネントで変更されたプロパティをここで引継ぎます。
_saturationMaterial.SetFloat(saturationPropertyId, _saturation);
最後に彩度のパラメーターを設定します。
値変更時にマテリアルを更新する
GetModifiedMaterialを設定しただけではマテリアルは更新されません。GetModifiedMaterialはGraphicのマテリアルにDirtyフラグが設定されたタイミングで呼ばれるため更新したいタイミングでDirtyフラグを設定します
#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コンポーネントと競合することなくマテリアルの値を変更できます。