この記事について
この記事は Akatsuki Games Advent Calendar 2023 の16日目の記事です。
昨日の記事は、竹下さんの 「RubyでTOTPのクライアントを自前実装してみた」 でした。
普段何気なく利用していた TOTP (Time-based One Time Password) ですが、仕組みについて調べてみることで技術への解像度が上がりますね。
普段グラフィックスやクライアント周りを中心に触っているため、このあたりの知識は疎くなりがちなのですが、身の回りの技術に対してなんとなくで済ませていてはいけないなと再認識しました!
概要
この記事の内容は、先日あった Unity お・と・なのLT大会 2023 で話した内容からいくつかピックアップしたものになります。
スライドは以下から閲覧することができますが、口頭で話した内容がスライドに書かれていないため、資料だけ読んでもよく分からない点はご容赦ください。
そのうち Unity Learning Materials にも動画などが上がると思います!
Shader と GUI
さて、早速本題に入りますが 皆さん Shader 書いてますか?
業務においてグラフィックスを担っている方はもちろん書かれると思いますが、近年は VRChat などの隆盛によって業務以外でも Shader を書かれる方が増えたように思います。
そんな Shader ですが、よく見かけるのが GUI の実装を蔑ろにされている姿です。
自分だけが使うのであればそれでも構わないのですが、本来 Shader というのはアーティストとレンダリングをつなぐツールです。
良いツールには良い GUI があるもので、 Shader も決して例外ではありません。
ShaderGUI
Unity の Shader において GUI を拡張するために使えるのが ShaderGUI
クラスです。
実装の詳細については公式リファレンス以外にも解説されている方が多数いらっしゃいますので、最小コードだけ記載しておきます。
using UnityEngine;
using UnityEditor;
public class Sample1ShaderGUI : ShaderGUI
{
public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties)
{
// Shader の GUI は EditorGUIUtility.labelWidth でフィールドの幅を制御しているため
// これを呼び出さないとフィールドの見た目が崩れてしまう
materialEditor.SetDefaultGUIWidths();
var mainTexProperty = FindProperty("_MainTex", properties);
materialEditor.ShaderProperty(mainTexProperty, mainTexProperty.displayName);
}
}
サンプルのため適当に用意した Shader です。
Shader "Samples/Sample 1"
{
Properties
{
_MainTex("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "Queue"="Geometry" "RenderType"="Opaque" }
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D_X(_MainTex);
SAMPLER(sampler_MainTex);
float4 _MainTex_ST;
struct vertex_in
{
float4 position : Position;
float2 uv : Texcoord0;
};
struct vertex_out
{
float4 positionCS : SV_Position;
float2 texcoord_main : TexcoordMain;
};
vertex_out vert(vertex_in v)
{
VertexPositionInputs position_inputs = GetVertexPositionInputs(v.position.xyz);
vertex_out o = (vertex_out)0;
o.positionCS = position_inputs.positionCS;
o.texcoord_main = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
half4 frag(vertex_out i) : SV_Target
{
half4 maintex = SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, i.texcoord_main);
return maintex;
}
ENDHLSL
}
}
CustomEditor "Sample1ShaderGUI"
}
MaterialPropertyDrawer と Decorator
前述の ShaderGUI
は IMGUI で丸ごと制御できて確かに便利なのですが、プロパティ名が Shader 側と C# 側に文字列で置かれたり、 Shader ごとに毎回クラスを用意することになるなど面倒な点も多いです。
そんなときに使える MaterialPropertyDrawer
というクラスがあります。
同じくフィールド単位の描画制御として MonoBehaviour
などで使える PropertyDrawer
もありますが、これはその Shader 版です。
PropertyDrawer
では対象の型を指定する方法と Attribute
を指定してそれを対象にする2つの利用方法がありましたが、 MaterialPropertyDrawer
は後者に近い利用方法となります。
MaterialPropertyDrawer
は ShaderGUI
と併用することも可能なため、GUI の細かい制御は ShaderGUI
ではなくこちらで行うのが良いでしょう。
細かい利用方法は例のごとく公式リファレンスや他の方に任せるとして、以下にサンプルコードを示しておきます。
Vector プロパティを Vector3 として表示する MaterialPropertyDrawer
です。
using UnityEngine;
using UnityEditor;
public class MaterialVector3Drawer : MaterialPropertyDrawer
{
const float SingleLabelWidth = 12;
const float SingleFieldWidth = 66;
const float ContentMargin = 4;
const float SingleContentWidth = SingleLabelWidth + SingleFieldWidth;
const float ContentWidth = (ContentMargin + SingleContentWidth) * 3;
public override void OnGUI(Rect position, MaterialProperty prop, string label, MaterialEditor editor)
{
if (prop.type != MaterialProperty.PropType.Vector) return;
var value = prop.vectorValue;
var originLabelWidth = EditorGUIUtility.labelWidth;
EditorGUIUtility.labelWidth = 0;
var originFieldWidth = EditorGUIUtility.fieldWidth;
EditorGUIUtility.fieldWidth = SingleFieldWidth;
using (new EditorGUI.MixedValueScope(prop.hasMixedValue))
using (var check = new EditorGUI.ChangeCheckScope())
{
var labelRect = new Rect(position.x, position.y, position.width - ContentWidth, position.height);
EditorGUI.LabelField(labelRect, label);
using var indentScope = new EditorGUI.IndentLevelScope(-EditorGUI.indentLevel);
EditorGUIUtility.labelWidth = SingleLabelWidth;
var xContentRect = new Rect(labelRect.xMax + ContentMargin, position.y, SingleContentWidth, position.height);
value.x = EditorGUI.FloatField(xContentRect, "X", value.x);
var yContentRect = new Rect(xContentRect.xMax + ContentMargin, xContentRect.y, SingleContentWidth, position.height);
value.y = EditorGUI.FloatField(yContentRect, "Y", value.y);
var contentRectZ = new Rect(yContentRect.xMax + ContentMargin, yContentRect.y, SingleContentWidth, position.height);
value.z = EditorGUI.FloatField(contentRectZ, "Z", value.z);
if (check.changed)
{
editor.RegisterPropertyChangeUndo(label);
prop.vectorValue = value;
}
}
EditorGUIUtility.labelWidth = originLabelWidth;
EditorGUIUtility.fieldWidth = originFieldWidth;
}
public override float GetPropertyHeight(MaterialProperty prop, string label, MaterialEditor editor)
=> EditorGUIUtility.singleLineHeight;
}
Shader "Samples/Sample 2"
{
Properties
{
_MainTex("Texture", 2D) = "white" {}
[Vector3]
_GradientDirection("Direction", Vector) = (0.0, 1.0, 0.0, 0.0)
}
SubShader
{
Tags { "Queue"="Geometry" "RenderType"="Opaque" }
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D_X(_MainTex);
SAMPLER(sampler_MainTex);
float4 _MainTex_ST;
float3 _GradientDirection;
const static float Pi = 3.14159265;
const static float GradientWaves = 5;
struct vertex_in
{
float4 position : Position;
float2 uv : Texcoord0;
};
struct vertex_out
{
float3 positionOS : ObjectPosition;
float4 positionCS : SV_Position;
float2 texcoord_main : TexcoordMain;
};
vertex_out vert(vertex_in v)
{
VertexPositionInputs vertex_position_inputs = GetVertexPositionInputs(v.position.xyz);
vertex_out o = (vertex_out)0;
o.positionOS = v.position.xyz;
o.positionCS = vertex_position_inputs.positionCS;
o.texcoord_main = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
half4 frag(vertex_out i) : SV_Target
{
half4 maintex = SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, i.texcoord_main);
float3 gradient_direction = normalize(_GradientDirection);
float gradient_base = dot(i.positionOS, gradient_direction) * 0.5 + 0.5;
float gradient = cos(gradient_base * Pi * 2.0 * GradientWaves) * 0.5 + 0.5;
half3 result = maintex.rgb * gradient;
return half4(result, 1.0);
}
ENDHLSL
}
}
}
実装を隠蔽する
前項までの内容で ShaderGUI
と MaterialPropertyDrawer
を利用して Shader の UI をカスタマイズすることが可能になりました。
では、実際どのような UI を提供すると便利なのか実例を見てみましょう。
Shader に求められる機能の1つとして、ブレンドモードの制御があります。
これは Photoshop などでいうレイヤーのブレンドモードに相当し、最も基本的には以下の式で制御されています。
${RGB}_{result} = {RGB}_{source} \cdot {Factor}_{source} + {RGB}_{destination} \cdot {Factor}_{destination}$
この式中の ${Factor}_{source} と {Factor}_{destination}$ を以下から選ぶことで、様々なブレンド効果を実現することができます。
単純には Shader 側のコードでこれを指定するのですが、それではコード内で指定した1種類のブレンドモードしか使えません。
単一目的の単純な Shader であればそれでも良いのですが、 VFX や UI などで複数のブレンドモードを求められる場合、扱うブレンドモードが増えるごとに Shader を作成していてはきりがありません。
これをコードの外から指定できるようにしてみましょう。
Shader "Samples/Sample 3"
{
Properties
{
// EnumDrawer を使うことでドロップダウンから選択させることができる
[Enum(UnityEngine.Rendering.BlendMode)]
_SrcBlend("Source", Int) = 5
[Enum(UnityEngine.Rendering.BlendMode)]
_DstBlend("Destination", Int) = 10
[Space]
_MainTex("Texture", 2D) = "white" {}
_Color("Color", Color) = (1.0, 1.0, 1.0, 1.0)
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" "PreviewType"="Plane" }
Pass
{
// プロパティから値を流し込む
Blend [_SrcBlend] [_DstBlend]
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D_X(_MainTex);
SAMPLER(sampler_MainTex);
float4 _MainTex_ST;
half4 _Color;
struct vertex_in
{
float4 position : Position;
float2 uv : Texcoord0;
};
struct vertex_out
{
float3 positionOS : ObjectPosition;
float4 positionCS : SV_Position;
float2 texcoord_main : TexcoordMain;
};
vertex_out vert(vertex_in v)
{
VertexPositionInputs vertex_position_inputs = GetVertexPositionInputs(v.position.xyz);
vertex_out o = (vertex_out)0;
o.positionOS = v.position.xyz;
o.positionCS = vertex_position_inputs.positionCS;
o.texcoord_main = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
half4 frag(vertex_out i) : SV_Target
{
half4 maintex = SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, i.texcoord_main);
half4 result = maintex * _Color;
return result;
}
ENDHLSL
}
}
}
コードの外から指定できるようになりました、めでたしめでたし……ではありませんね。
ブレンドモードを指定するために適切な ${Factor}_{source} と {Factor}_{destination}$ を選択させるのは直感的とは言えません。
ユーザーが指定したいのはブレンドモードなので、 UI として選択させるのもブレンドモードであるべきです。
では、これを改修してブレンドモードを選択させるようにしてみましょう。
改修後のコードを以下に示します。
using System.Linq;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEditor;
public enum Sample3Blending
{
Transparent,
Add,
Multiply,
}
public class Sample3ShaderGUI : ShaderGUI
{
const string BlendingPropertyName = "_Blending";
const string SrcBlendPropertyName = "_SrcBlend";
const string DstBlendPropertyName = "_DstBlend";
public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties)
{
materialEditor.SetDefaultGUIWidths();
using var check = new EditorGUI.ChangeCheckScope();
foreach (var property in properties)
{
var hideFlags = MaterialProperty.PropFlags.HideInInspector | MaterialProperty.PropFlags.PerRendererData;
if ((property.flags & hideFlags) == 0)
{
materialEditor.ShaderProperty(property, property.displayName);
}
}
if (check.changed)
{
// 複数の Material を同時に編集する可能性があるため、 targets を対象に処理する
foreach (var material in materialEditor.targets.Cast<Material>())
{
RefreshMaterial(material);
}
}
}
// 本来はこの更新処理を Editor 側から分離してランタイムで呼べるようにすると良い
public static void RefreshMaterial(Material material)
{
RefreshBlending(material);
}
static void RefreshBlending(Material material)
{
var blending = (Sample3Blending)material.GetInt(BlendingPropertyName);
switch (blending)
{
case Sample3Blending.Transparent:
material.SetInt(SrcBlendPropertyName, (int)BlendMode.SrcAlpha);
material.SetInt(DstBlendPropertyName, (int)BlendMode.OneMinusSrcAlpha);
return;
case Sample3Blending.Add:
material.SetInt(SrcBlendPropertyName, (int)BlendMode.SrcAlpha);
material.SetInt(DstBlendPropertyName, (int)BlendMode.One);
return;
case Sample3Blending.Multiply:
material.SetInt(SrcBlendPropertyName, (int)BlendMode.DstColor);
material.SetInt(DstBlendPropertyName, (int)BlendMode.Zero);
return;
}
}
}
Shader "Samples/Sample 3"
{
Properties
{
// UI の表示制御用のプロパティを用意する
[Enum(Sample3Blending)]
_Blending("Blending", Int) = 0
// _SrcBlend と _DstBlend は ShaderGUI から設定されるので非表示にする
[HideInInspector]
_SrcBlend("Source", Int) = 5
[HideInInspector]
_DstBlend("Destination", Int) = 10
[Space]
_MainTex("Texture", 2D) = "white" {}
_Color("Color", Color) = (1.0, 1.0, 1.0, 1.0)
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" "PreviewType"="Plane" }
Pass
{
Blend [_SrcBlend] [_DstBlend]
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
const static int BlendingMultiply = 2;
int _Blending;
TEXTURE2D_X(_MainTex);
SAMPLER(sampler_MainTex);
float4 _MainTex_ST;
half4 _Color;
struct vertex_in
{
float4 position : Position;
float2 uv : Texcoord0;
};
struct vertex_out
{
float3 positionOS : ObjectPosition;
float4 positionCS : SV_Position;
float2 texcoord_main : TexcoordMain;
};
vertex_out vert(vertex_in v)
{
VertexPositionInputs vertex_position_inputs = GetVertexPositionInputs(v.position.xyz);
vertex_out o = (vertex_out)0;
o.positionOS = v.position.xyz;
o.positionCS = vertex_position_inputs.positionCS;
o.texcoord_main = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
half4 frag(vertex_out i) : SV_Target
{
half4 maintex = SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, i.texcoord_main);
half4 result = maintex * _Color;
if (_Blending == BlendingMultiply)
{
result.rgb = lerp(half3(1.0, 1.0, 1.0), result.rgb, result.a);
}
return result;
}
ENDHLSL
}
}
CustomEditor "Sample3ShaderGUI"
}
Blending から適用したいブレンドモードを選択することで内部の _SrcBlend と _DstBlend が更新されるようになりました。
これによりユーザーは内部の実装について気にする必要がなくなり、より簡単にツールを操作することができます。
このように、より抽象度の高い操作をユーザーに提供して内部の実装が外に漏れないようにすることが使いやすい UI への一歩となります。
最後に
今回は Shader の GUI を実装する流れについて簡単に説明しました。
記事の中では説明しませんでしたが、 RGB を HSV に変換したり、内部で利用する Radian を Degree として UI で受け取ったり、値の変換を行うことが内部実装を隠蔽するのに役立つこともあります。
より使いやすいツールを目指して、 Shader の GUI を整えていきましょう。
この記事は Akatsuki Games Advent Calendar 2023 の16日目の記事です。
明日は cllightz さんが「Android Autoの続きかもしくは何かしら」を書いてくださるようです。
楽しみにお待ち下さい!