長い、3行で
ShaderGraphは便利だけど機能が不足している、ので改造して機能拡張出来るようにした。
ステンシルバッファにアクセス出来るようにしたり、ライティング結果を加工したり。
2年前にも似たような仕組みを作ったけど、今回のは実装が遥かにシンプル。
はじめに
2022年末のアドカレにて、"ShaderGraphの実用的活用法"というエントリを書きました。
これは前段にて「ShaderGraphの立ち位置」について、後段で「ShaderGraphに足りていない機能を拡張する手法」についての解説をしています。公開から2年ほど経った2024年末でもたまーにいいねがもらえているので、そこそこ役に立つ情報を提供出来たのかなと認識しています。
今回のエントリはその延長上にあります。ShaderGraphの立ち位置、というのはこの2年で特に変化していませんので、可能であればまずは先のエントリの前段部分だけでも読んでおいて欲しいのですが、時間が勿体ないという方のためにざっくり箇条書きで要約します。
-
ShaderGraphは便利。Unityゲーム開発の現場であれば活用する価値はある
-
アーティストがプログラマに頼らずシェーダを量産出来る
→ 分かりやすい最大のメリット -
プログラマであっても面倒なShaderLab記述をバイパス出来てラク
→ コードをCustomFunction内に記述すればGUI操作は最小限で済ませられる
-
アーティストがプログラマに頼らずシェーダを量産出来る
-
でもShaderGraphにはいくつか懸念点がある
-
Diffがまともに機能しないなど、コードレビューや品質の担保が大変
→ これは運用次第でカバーできる - (コードベースでのシェーダ記述と比べて) 機能が色々と足りていない
→ こちらは素のShaderGraphではどうにもならない
-
Diffがまともに機能しないなど、コードレビューや品質の担保が大変
最後の「機能不足」という問題が特に厳しく、ShaderGraphを実際にタイトル開発で運用する上での大きな障壁となっています。
例えばステンシルバッファへのアクセス手段はまるっとオミットされていますし、ライティングやシャドウイングの計算式を書き変えるなんてことも出来ません。tintColorをプロパティとして用意しライティング結果カラーに対して乗算する程度の極一般的な実装も、標準のShaderGraphでは実現不可能です。
ShaderGraphの機能を拡張したい!
そこで2年前の先のエントリでは、URP/ShaderGraphの内部実装である"Target"というinternalな機構を複製しそれを書き換える、というだいぶ無理やりな手順を踏むことで、機能拡張を可能とする仕組みについて解説を行いました。(詳細に興味があればエントリをお読みください)
しかしこの「Target複製」手法によるShaderGraph改造は、致命的な問題を抱えていました。
手間が掛かりすぎるんです。
血反吐を吐いて一度きり改造手術を頑張ればいいだけ、ならば許容範囲かとも考えるのですが、Unityは(特にモバイル環境においては顕著に)定期的なバージョンアップを要求してきます。当然URP/ShaderGraphの内部コードも書き換わっており、そのキャッチアップ及び対応に追われることになります。アップデートの度に賽の河原で石を積むような手間暇を掛けてURP/ShaderGraph内部の改造をやり直さなければなりません。
自分でそんな工数の必要となる解説記事を書いておいてアレですけれども、私はもう二度とやりたくありません。
ShaderGraphの機能を「もっと簡単に」拡張したい!
そんな状況の中、新規ゲームタイトル開発の業務が私に降ってきました。このShaderGraph改造の問題はどう解決しよう?以前の手法はもうやりたくないぞ?と面倒を回避する為の思案を巡らしているうち、そんな手間の掛かるTarget複製手法とほぼ同等の機能拡張を、桁違いに簡単に実現する手法を思いつきました。
実際に組み込んでみたところ、想像以上に実装が楽で、気軽に思いつくままShaderGraphの機能追加/拡張が出来るようになりました。
今回はその手法、私は「拡張プロパティ」と呼んでいますが、について解説します。
拡張プロパティ
執筆時点でのUnity2022最新環境 ( Unity2022.3.53f1 + URP14.0.11 ) を想定しています。
他バージョンでは細かい差異が発生するかと思いますが、適宜読み替えを行ってください。
読み進むために必要な前提知識
まずUnity/URPには、直接ShaderGraphをロードする機構は存在していません。URPは事前にShaderGraphのノードや各種設定をパースし、裏でコードベースの.shaderなシェーダを自動的に作成し、そのシェーダコードがUnity内部で実際にロードされているのです。
つまりShaderGraphはコードベースなシェーダを半自動生成するツール、なんですね。ここテストに出ますよ。覚えておいてください。
実装概説
さて実装についての具体的な解説に移りますが、発想は非常に単純です。
まず、ShaderGraphには"Blackboard"と呼ばれる固有のウインドウがあります。アセットのパスを記述するフィールドと、プロパティやキーワードを列挙するリストが含まれています。このリスト欄が今回の主役です。
好き勝手にプロパティ/キーワードを定義することが出来るわけですが、ここで、特定文字列なプロパティ/キーワードが定義されたら内部のパース挙動を変える仕組みを用意します。
ちょーっと分かりにくいと思いますので具体例を挙げます。
例えば上の画像のように"_ExtraTintColor"という名称のColorプロパティを一つ追加したとします。このプロパティはtintColorとして、出力されるシェーダの最終結果カラーにこのColor値が乗算されるように機能させます。
プロパティ/キーワードのリスト欄を、機能拡張のインターフェースとしてハック的に利用するわけですね。
もちろん、そのように動くよう今からURP/ShaderGraphを書き換えていく訳です。
1.URPをカスタムパッケージ化する
何はなくともURPは弄らないと話になりません。
com.unity.render-pipelines.core@14.0.11
com.unity.render-pipelines.universal@14.0.11
com.unity.shadergraph@14.0.11
この辺りを"Library\PackageCache"から"Packages"にまるっと移動させるだけで、後はよしなにUnityがやってくれます。これでURP/ShaderGraphを自由に書き換えることが出来るようになります。
2.ShaderGraphサイドのインターフェース書き換え
ShaderGraphが.shaderを出力するコア部分のソースコードは
com.unity.shadergraph@14.0.11\Editor\Generation\Processors\Generator.cs
com.unity.shadergraph@14.0.11\Editor\Generation\Processors\PropertyCollector.cs
辺りになります。まずはこれらを書き換えます。
Generator.cs
のBuildShader
関数中でshaderProperties.properties
を漁ると、ShaderGraphにて定義されているプロパティ一覧を拾うことが出来ます。ここで予約語となる特定文字列があるかを先んじてチェックします。
//-------------------------------------
var isUseTintColor = false;
...
//-------------------------------------
var propertyInputs = shaderProperties.properties.ToList();
foreach (var t in propertyInputs)
{
// "_ExtraTintColor"というプロパティが存在する?
if (t.referenceName == "_ExtraTintColor")
{
isUseTintColor = true;
}
}
次にGenerateShaderPass
関数内の// Pass Code
の辺りが.shaderのdefineやらを出力する箇所になりますので、こちらに追加でdefineを捩じ込むコードを追加します。
//-------------------------------------
if ((!string.IsNullOrEmpty(pass.displayName)) &&
(pass.displayName == "Universal Forward"))
{
// 拡張プロパティ経由なdefineの出力.
passPragmaBuilder.AppendLine("");
passPragmaBuilder.AppendLine("// ExtraPropertys");
if (isUseTintColor)
{
passPragmaBuilder.AppendLine("#define _USE_EXTRA_TINT_COLOR");
}
}
これで、"_ExtraTintColor"という名称のプロパティがあれば_USE_EXTRA_TINT_COLOR
というdefineが勝手に定義されるシェーダが吐き出されるようになります。
不要なパスにdefine定義がされないように上のサンプルコードでは"Universal Forward"パスでのみ出力されるようにしていますが、この辺りは必要に応じて書き換えを行ってください。
3.URPサイドのシェーダ実体書き換え
define定義が出来たので、次に実際のシェーダコード側を書き換えていきます。
"Universal Forward"パスでのLit/Unlitマテリアルのシェーダは、エントリポイントが以下のhlslになります。
com.unity.render-pipelines.universal@14.0.11\Editor\ShaderGraph\Includes\PBRForwardPass.hlsl
com.unity.render-pipelines.universal@14.0.11\Editor\ShaderGraph\Includes\UnlitPass.hlsl
これらを追っていって中身を書き換えればShaderGraphから出力されるシェーダを改変することが可能になるわけですが、そのままだと無関係なシェーダまで含めて全てが改変されてしまうので、ifdefを用いてインジェクションを行います。
half4 color = UniversalFragmentPBR(inputData, surface);
#if defined(_USE_EXTRA_TINT_COLOR)
// tintColorの反映.
color.rgb = (color.rgb * _ExtraTintColor.rgb) * _ExtraTintColor.a;
#endif // _USE_EXTRA_TINT_COLOR
color.rgb = MixFog(color.rgb, inputData.fogCoord);
これでライティング結果のカラーに対して、プロパティで指定したtintColorが乗算されるようになります。プロパティが定義されていなければ何も起きませんので、当然、無関係なシェーダには一切副作用はありません。
こんな感じで、「何か特定文字列なプロパティが定義されたら出力されるシェーダコードを改変させる」ことで機能拡張を実現できます。数十行程度の小規模なコード追加で、アイディア次第で割となんでも実現可能です。
拡張プロパティを利用した機能拡張の例
ここまで具体例の一つとしてtintColorを追加する仕組みについて解説しましたが、同じような流れで好みの機能を拡張することが出来るようになります。その凡例をいくつか紹介したいと思います。
ライティングやシャドウイングを調整する
例えば、URPデフォルトのPBRライティング計算式を書き換えたり新調したい場合には、先ほどと同じように他と被らない適当な特定文字列なプロパティを定義した上で、PBRForwardPass.hlslなどからInclude参照されている
com.unity.render-pipelines.universal@14.0.11\ShaderLibrary\Lighting.hlsl
この辺りを同じようにifdefを利用して(define定義されていないシェーダはそのままの挙動になるように)ゴリゴリ書き換えればOKです。シャドウイングを書き換えたいなら
com.unity.render-pipelines.universal@14.0.11\ShaderLibrary\Shadows.hlsl
この辺りですね。
例えば影にうっすら色を付けたいとかありますよね?思いつくままやっちゃいましょう。
ステンシルバッファへのアクセス
繰り返しになりますが、ShaderGraphの割と大きな不満点はステンシルバッファ操作が何故か塞がれていることです。これも今回の拡張プロパティを利用すれば簡単に利用できるように拡張可能です。
例によって先ほどと同じように適当な名前でプロパティを追加して、同じようにシェーダコードの出力を書き換えます。
//-------------------------------------
if (t.referenceName == "_StencilRef") { isUseStencilRef = true; isUseStencil = true; } else
if (t.referenceName == "_StencilReadMask") { isUseStencilReadMask = true; isUseStencil = true; } else
if (t.referenceName == "_StencilWriteMask") { isUseStencilWriteMask = true; isUseStencil = true; } else
if (t.referenceName == "_StencilComp") { isUseStencilComp = true; isUseStencil = true; } else
if (t.referenceName == "_StencilPass") { isUseStencilPass = true; isUseStencil = true; } else
if (t.referenceName == "_StencilFail") { isUseStencilFail = true; isUseStencil = true; } else
if (t.referenceName == "_StencilZFail") { isUseStencilZFail = true; isUseStencil = true; }
...
//-------------------------------------
// ステンシルバッファアクセス用プロパティが存在している?
if (isUseStencil)
{
renderStateBuilder.AppendLine("");
renderStateBuilder.AppendLine("Stencil");
renderStateBuilder.AppendLine("{");
if (isUseStencilRef) { renderStateBuilder.AppendLine("Ref [_StencilRef]"); }
if (isUseStencilReadMask) { renderStateBuilder.AppendLine("ReadMask [_StencilReadMask]"); }
if (isUseStencilWriteMask) { renderStateBuilder.AppendLine("WriteMask [_StencilWriteMask]"); }
if (isUseStencilComp) { renderStateBuilder.AppendLine("Comp [_StencilComp]"); }
if (isUseStencilPass) { renderStateBuilder.AppendLine("Pass [_StencilPass]"); }
if (isUseStencilFail) { renderStateBuilder.AppendLine("Fail [_StencilFail]"); }
if (isUseStencilZFail) { renderStateBuilder.AppendLine("ZFail [_StencilZFail]"); }
renderStateBuilder.AppendLine("}");
}
これだけで、各種プロパティが定義されていたらステンシルバッファへのアクセスを行うShaderLabスクリプトが出力されます。
ただ一つ問題があります。
今回用意したステンシルアクセス用プロパティは、プロパティの実体としてコンスタントバッファ(CBUFFER)に含まれてしまいます。これはシェーダ内で参照されることはなく、そのままだとただの無駄になってしまうので、コンスタントバッファの出力を行っているPropertyCollector.cs
のGetPropertiesDeclaration
関数を書き換えて出力から弾くようにします。
foreach (var h in hlslProps)
{
// modified.
// プロパティ名のprefixに"_Stencil"が付いていたらコンスタントバッファへの出力から省く.
if (!h.name.StartsWith("_Stencil"))
// modified.
if (h.declaration == HLSLDeclaration.UnityPerMaterial ||
h.declaration == HLSLDeclaration.HybridPerInstance)
h.AppendTo(builder);
例えばこのように書き換えることで、特定の文字列が含まれたプロパティ(今回で言えばprefixに"_Stencil")をコンスタントバッファ出力から弾くことが出来ます。
そんな感じで、ShaderGraphであっても普通にステンシルバッファへのアクセスが可能になりました。また同じような作業を繰り返せば、例えばColorMaskやOffsetといった、ShaderGraphから操作することが出来ないShaderLabコマンドも自由に追加することが出来ます。便利ですね。
汎用ディザ抜き(疑似半透明)のノーコード/ノーノード追加
機能追加とは少しズレますが、ShaderGraphにてノードを振り回して記述する定型的な処理を省略する、なんてパターンにも対応可能です。
例えばですが3Dでカメラがある程度自由に動かせるゲームを想定して、カメラ近くに寄ったオブジェクトをディザ抜きで疑似半透明フェードアウトさせる、なんて実装は定番中の定番ですよね。
このディザ抜き処理はひとまず、ShaderGraphのGUI内でDitherノードを用いてアルファテストに繋げることで実装することは出来ます。しかし、ディザ抜きを行うオブジェクト全てのそれぞれのシェーダにひとつひとつDitherノードを追加して線を繋げて...とまあ想像するだに面倒な手間が発生してしまいます。管理も大変ですし、ノードが複雑になることでEditor動作が重たくなってしまいます。定型パターン動作はノードGUI操作から省略したいですよね。
そんなときもこのプロパティ拡張の出番です。
例によってプロパティを一つ定義し、このプロパティが存在したらディザ抜き処理を(仮にLitであれば)PBRForwardPass.hlsl
に追加するように改造します。
#if defined(_USE_DITHER_PARAM)
{
#if UNITY_UV_STARTS_AT_TOP
float2 PixelPosition = float2(packedInput.positionCS.x, (_ProjectionParams.x < 0) ? (_ScaledScreenParams.y - packedInput.positionCS.y) : packedInput.positionCS.y);
#else
float2 PixelPosition = float2(packedInput.positionCS.x, (_ProjectionParams.x > 0) ? (_ScaledScreenParams.y - packedInput.positionCS.y) : packedInput.positionCS.y);
#endif
float2 NDCPosition = PixelPosition.xy / _ScaledScreenParams.xy;
NDCPosition.y = 1.0f - NDCPosition.y;
float2 _uv = NDCPosition.xy * _ScreenParams.xy;
float DITHER_THRESHOLDS[16] = {
1.0 / 17.0, 9.0 / 17.0, 3.0 / 17.0, 11.0 / 17.0,
13.0 / 17.0, 5.0 / 17.0, 15.0 / 17.0, 7.0 / 17.0,
4.0 / 17.0, 12.0 / 17.0, 2.0 / 17.0, 10.0 / 17.0,
16.0 / 17.0, 8.0 / 17.0, 14.0 / 17.0, 6.0 / 17.0
} ;
const uint index = (uint(_uv.x) % 4) * 4 + uint(_uv.y) % 4 ;
const float v = _DitherParameter ;
clip( v - DITHER_THRESHOLDS[ index ] ) ;
}
#endif // defined(_USE_DITHER_PARAM)
このように機能拡張してやれば、何かシェーダを新しく作った際に該当のプロパティを一つ追加するだけで勝手にディザ抜きが動くようになるとなるわけです。便利じゃないですか?シェーダを作るアーティストさん、喜びそうじゃないですか?そうですか...
ちょっとしたTips
Blackboardに並ぶプロパティ/キーワードは、実はCtrl+C/Vのコピペに対応しています。複数選択/コピペも可能です。
コピペ元となるテンプレートShaderGraphを一つ作っておいて、アーティストさんには「この機能が使いたかったらこのシェーダのこのプロパティをコピペすればOKだよー」と指示するだけで、今回のような拡張機能を使いこなすことが出来ます。
「プロパティ拡張」手法の問題点
以上のように、特定のプロパティをトリガとしたシェーダ改変を行うことで、ShaderGraphの機能拡張をシンプルに実現することが出来ました。
ただし、プロパティ/キーワードリスト欄を機能拡張のインターフェースとして利用していることから、この欄に大量のプロパティが並んでゴチャゴチャして見辛くなってしまいます。一応カテゴリでグルーピングしたりといった見やすくする方策もなくはないですが、とはいえここはマイナス点とは言えます。
まあ我慢するしかないので我慢してください。悪いのはUnityなので
簡単ではあるけど、簡単ではないよ(哲学)
ね、簡単だったでしょう?え、言うほど簡単じゃない?いやいや、旧手法と比べたら2桁くらいのオーダーで簡単になっているんですよ本当に。少なくとも私は、Unityバージョンアップに(この件では)怯えることはなくなりました。
というだけで終わらせると危険なので、一応ですが忠告。
今回の新しい手法は、旧手法と比べて圧倒的に改造/機能追加の手間は減ります。とはいえ、URP/ShaderGraphのコードに直接手を入れることには違いはありません。グラフィックス全般に関する基礎知識、URP/ShaderGraphの内部実装についてのある程度の知識は必須となります。下手な弄り方をしてしまったら、絵が壊れる程度ならともかく、クラッシュ率の増加など致命的な問題を引き起こすリスクがあります。流石にこんなニッチなエントリを読んでいる方々であれば問題ないでしょうとは思いますが、ハードルはそれなりには高いことに留意してください。URPのソースコードなんて一度も読んだことないよ、なんて方にはお勧めしません。
おわりに
ShaderGraphは便利ですが、残念ながら実用とするには様々な工夫(K.U.F.U.)がどうしても必要となります。
今後のUnityアップデートのロードマップに「ShaderGraph2」というものがあるそうです。詳しいことは把握していないのですが、今回のようなハック的な手法を用いずとも自由な機能拡張が出来るとか、あるいはそんな発想が不要なほど機能が揃っている、みたいな世界が待っている、と...いいですね...
それではまた、いつか。