[Unityシェーダー基礎] URP環境でuGUI用マルチパスシェーダーの作り方。
CanvasRendererの描画をハックする方法。
関連ページ
URP環境においては、uGUIの描画ルールがより厳しくなり、Passを複数書いても一番最初のPassしか実行してくれなくなりました。
しかし複雑なシェーダーを作りたい場合、どうしても
マルチパス
を作りたいことがあります。
2日くらいAIと問答を繰り返したところ、ようやく実用的なコードを吐き出してくれたので共有します。

マルチパスについて
マルチパスを実装することで例えば以下のような複雑な処理を1フレーム内で順に実行させることが可能です。
uGUIにおいて、昔のビルトインRP環境であれば、ただPassを複数書くだけで上から順に実行してくれました。
しかしURP環境下では
CanvasRenderer
によってプロセスが厳密に管理され、実行されるのは基本一番上のPassのみです。
3Dモデル用のMeshRendererみたいに、LightModeを指定したマルチパスの実装も不可能です。
ではuGUIではマルチパスシェーダーは作れないかというとそんな事はありません。
少しトリッキーですが、C#から明示的にPassを指定しつつ、CanvasRendererの挙動をハックするとマルチパスの実装が可能です。

十字切り抜き2Passシェーダー
今回テストとして比較的シンプルな2Passシェーダーを作りました。
内容はC#のインスペクターから
IgnoreRange
を操作すると、指定した画像が十字型に切り抜かれていくという実装です。
本来はこの程度の実装であれば2Passではなく1Passでも実装できますし、描画効率も良いです。
マルチパスの説明のために、敢えてこれを2Passで実装してみます。

シェーダー側の実装
uGUIではマルチパスを実装するには、核となるシェーダー側の実装と、シェーダーの挙動を管理するC#側の実装の2つが必要になります。
以下シェーダー側のコード全文です。

Shader
"UI/Test2Pass"
{
//以下は全部インスペクター上には表示しないけど必要な処理
//ここで宣言しておかないとSceneセーブした時などに値が維持されない
Properties
{
[HideInInspector]
_MainTex("-",
2D)
=
"white"{}
//uGUI及びGraphics.Blitが標準で扱うテクスチャ
[HideInInspector]
_CustomTex("-",
2D)="white"{}
[HideInInspector]
_IgnoreRange("-",
float)
=
0
}
SubShader
{
Tags
{
"RenderPipeline"
=
"UniversalPipeline"
"Queue"
=
"Transparent"
}
Cull
Off
ZWrite
Off
HLSLINCLUDE
#pragma
vertex
vert
#pragma
fragment
frag
#include
"Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
//uGUI Image > Source Imageにアタッチされた画像を参照できるようにする
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
//最終的に画面に出力されるテクスチャを宣言、中身のデータはC#側から渡される
TEXTURE2D(_CustomTex);
SAMPLER(sampler_CustomTex);
//SRP Batcherを適用、動的に変更される数値はこの中に定義
CBUFFER_START(UnityPerMaterial)
half
_IgnoreRange;
CBUFFER_END
struct
Attributes
{
half2
uv
:
TEXCOORD0;
float4
positionOS
:
POSITION;
};
struct
Varyings
{
half2
uv
:
TEXCOORD0;
float4
positionCS
:
SV_POSITION;
};
Varyings
vert
(Attributes
input)
{
Varyings
output;
output.positionCS
=
TransformObjectToHClip(input.positionOS.xyz);
output.uv
=
input.uv;
return
output;
}
ENDHLSL
//Pass0: 一番上のPassはuGUIのCanvasRendererから自動的に呼び出される、処理的には下のPass1より後に実行される
Pass
{
//最終出力は背景と合成させる
Blend
SrcAlpha
OneMinusSrcAlpha
HLSLPROGRAM
//画像x軸の真ん中あたりの描画を飛ばす
half4
frag
(Varyings
input)
:
SV_Target
{
//_MainTexではなく、C#側から書き込みを行った_CustomTexを最終的な描画対象とする
//これによりuGUIの標準の描画ロジックを回避して、Pass1の内容を引き継いで出力できる
//まずこの_CustomTexから、指定のUV座標のピクセル色を抜き出す
half4
original
=
SAMPLE_TEXTURE2D(_CustomTex,
sampler_CustomTex,
input.uv);
half
isRightInUV
=
step(0.5
+
_IgnoreRange,
input.uv.x);
//渡されたUV座標が画像の右側に存在するか
half
isLeftInUV
=
step(input.uv.x,
0.5
-
_IgnoreRange);
//渡されたUV座標が画像の左側に存在するか
half
isCenter
=
1
-
max(isRightInUV,
isLeftInUV);
//渡されたUV座標がx軸の真ん中あたりかどうか
return
lerp(original,
half4(0,
0,
0,
0),
isCenter);
//真ん中辺りだったら透明色を渡して描画させない
}
ENDHLSL
}
//Pass1: C#から明示的に呼び出す
Pass
{
//この段階では背景との合成が必要ないのでBlend Off
Blend
One
Zero
HLSLPROGRAM
//画像y軸の真ん中あたりの描画を飛ばす
half4
frag
(Varyings
input)
:
SV_Target
{
//SourceImageにアタッチされた画像の、指定UV座標のピクセル色を取得
half4
original
=
SAMPLE_TEXTURE2D(_MainTex,
sampler_MainTex,
input.uv);
half
isUpInUV
=
step(0.5
+
_IgnoreRange,
input.uv.y);
//渡されたUV座標が画像の上側に存在するか
half
isDownInUV
=
step(input.uv.y,
0.5
-
_IgnoreRange);
//渡されたUV座標が画像の下側に存在するか
half
isCenter
=
1
-
max(isUpInUV,
isDownInUV);
//渡されたUV座標がy軸の真ん中あたりかどうか
return
lerp(original,
half4(0,
0,
0,
0),
isCenter);
//真ん中辺りだったら透明色を渡して描画させない
}
ENDHLSL
}
}
}

後ろの方にPassが2つ実装されています。
//Pass0: 一番上のPassはuGUIのCanvasRendererから自動的に呼び出される、処理的には下のPass1より後に実行される
Pass
{
//最終出力は背景と合成させる
Blend
SrcAlpha
OneMinusSrcAlpha
HLSLPROGRAM
//画像x軸の真ん中あたりの描画を飛ばす
half4
frag
(Varyings
input)
:
SV_Target
//Pass1: C#から明示的に呼び出す
Pass
{
//この段階では背景との合成が必要ないのでBlend Off
Blend
One
Zero
HLSLPROGRAM
//画像y軸の真ん中あたりの描画を飛ばす
half4
frag
(Varyings
input)
:
SV_Target
このコードで特徴的なのは、コメントにも書いてある通り、Passが上から順に実行される訳ではないことです。
uGUIの描画はCanvasRendererで厳しめに管理されていて、C#側から描画順の全てを管理することが困難です。
普通に実装すると、最終的にはCanvasRendererがC#側の実装を全て塗り替えてしまいます。
このため、
CanvasRendererの「カメラ描画工程の後ろの方で、一番上のPassを暗黙的に呼ぶ」という仕様
をそのまま利用し、かつハックします。
//_MainTexではなく、C#側から書き込みを行った_CustomTexを最終的な描画対象とする
//これによりuGUIの標準の描画ロジックを回避して、Pass1の内容を引き継いで出力できる
//まずこの_CustomTexから、指定のUV座標のピクセル色を抜き出す
half4
original
=
SAMPLE_TEXTURE2D(_CustomTex,
sampler_CustomTex,
input.uv);
uGUIが標準で使う
_MainTex
ではなく、C#から流し込んだ
_CustomTex
を、CanvasRendererに描画させます。

C#側の実装
次にC#側の実装になります。以下コード全文です。

using
UnityEngine;
using
UnityEngine.UI;
[ExecuteAlways]
/// <summary>
/// uGUIの2Passを実装するために、RenderTextureを自前で流し込むクラス
/// </summary>
public
class
Test2PassController
:
MonoBehaviour
{
[SerializeField]
private
Image
targetImage;
[SerializeField]
private
Shader
targetShader;
[SerializeField,
Range(0f,
0.5f)]
private
float
ignoreRange;
//画像描画の無視範囲を設定する
private
Texture2D
sourceTexture;
private
Material
createdMaterial;
private
RenderTexture
tmpRenderTexture;
//変更があったときだけ更新するためのキャッシュ
private
float
_lastIgnoreRange;
/// <summary>
/// 初期化処理の必要性がある場合trueを返す
/// </summary>
private
bool
NeedsInitialization
=>
sourceTexture
==
null
||
sourceTexture
!=
targetImage.sprite.texture
||
createdMaterial
==
null
||
targetImage.material
!=
createdMaterial
||
tmpRenderTexture
==
null;
/// <summary>
/// ignoreRangeのパラメータに変化があったかチェック
/// </summary>
private
bool
CahngedParameter
=>
!Mathf.Approximately(ignoreRange,
_lastIgnoreRange);
/// <summary>
/// 初期化処理
/// </summary>
private
void
Init()
{
Release();
sourceTexture
=
targetImage.sprite.texture;
createdMaterial
=
new
(targetShader);
createdMaterial.hideFlags
=
HideFlags.DontSave;
targetImage.material
=
createdMaterial;
//Imageとアタッチされた画像と同じサイズでRenderTextureを作成
tmpRenderTexture
=
new
RenderTexture(sourceTexture.width,
sourceTexture.height,
0);
//事前にBlitで更新される予定のtmpRenderTextureを、_CustomTexの名前でシェーダー側と紐づけておく
//後はCanvasRender内の処理で自動的にPass0を実行させて、その_CustomTexを画像に反映させる
targetImage.material.SetTexture("_CustomTex",
tmpRenderTexture);
//初期化後は問答無用でUpdateTextureを呼ぶ
UpdateTexture();
}
/// <summary>
/// 最初にUnityが1回だけ呼ぶ処理
/// </summary>
private
void
Start()
{
if
(NeedsInitialization)
{
Init();
}
}
/// <summary>
/// Unityから毎フレーム呼ばれる処理
/// </summary>
private
void
Update()
{
if
(targetImage
==
null
||
targetImage.sprite
==
null
||
targetShader
==
null)
{
return;
}
if
(NeedsInitialization)
{
Init();
}
else
if(CahngedParameter)
{
UpdateTexture();
}
}
/// <summary>
/// 対象のシェーダーの2Passを実行させる
/// </summary>
private
void
UpdateTexture()
{
_lastIgnoreRange
=
ignoreRange;
//非表示領域をセット
createdMaterial.SetFloat("_IgnoreRange",
ignoreRange);
//第1引数の画像を元に、Blitでシェーダー内のPass1を実行させ、結果を第2引数に書き込む
//Blitの仕様上、この書き込み処理は、第3引数のMaterial内の_MainTexを通して行われる
Graphics.Blit(sourceTexture,
tmpRenderTexture,
createdMaterial,
1);
}
/// <summary>
/// 作成したRenderTextureやMaterialのメモリを解放する
/// </summary>
private
void
Release()
{
if
(tmpRenderTexture
!=
null)
{
tmpRenderTexture.Release();
}
if
(createdMaterial
!=
null)
{
DestroyImmediate(createdMaterial);
}
}
/// <summary>
/// このGameObjectを非表示にした時の処理
/// </summary>
private
void
OnDisable()
{
Release();
}
}

以下のコードで、シェーダー内の2つ目のPassを明示的に呼び出し、その結果を
tmpRenderTexture
に代入しています。
//第1引数の画像を元に、Blitでシェーダー内のPass1を実行させ、結果を第2引数に書き込む
//Blitの仕様上、この書き込み処理は、第3引数のMaterial内の_MainTexを通して行われる
Graphics.Blit(sourceTexture,
tmpRenderTexture,
createdMaterial,
1);
1つ目のPass(シェーダー内の一番上のPass)の呼び出しは、CanvasRendererに任せます。
このため描画順はPass1 > Pass0という、通常のシェーダーの流れとは違うトリッキーなものになります。
Pass1の結果が代入されたtmpRenderTextureと、シェーダー内の_CustomTexとの紐づけは、
Init()
関数内でやっています。
//事前にBlitで更新される予定のtmpRenderTextureを、_CustomTexの名前でシェーダー側と紐づけておく
//後はCanvasRender内の処理で自動的にPass0を実行させて、その_CustomTexを画像に反映させる
targetImage.material.SetTexture("_CustomTex",
tmpRenderTexture);
後はHierarchyから、シェーダーを適用させたいImageと、作成したシェーダーをアタッチさせれば実装完了です。

_CustomTexを使わない場合
では_CustomTexを使わず、Pass0で標準の_MainTexを使った、どういう挙動になるでしょうか?
コードを次のように修正してみます。
//_MainTexではなく、C#側から書き込みを行った_CustomTexを最終的な描画対象とする
//これによりuGUIの標準の描画ロジックを回避して、Pass1の内容を引き継いで出力できる
//まずこの_CustomTexから、指定のUV座標のピクセル色を抜き出す
//half4 original = SAMPLE_TEXTURE2D(_CustomTex, sampler_CustomTex, input.uv);
half4
original
=
SAMPLE_TEXTURE2D(_MainTex,
sampler_MainTex,
input.uv);
結果は以下の通りで、Pass1の結果をPass0に引き継げずに、x軸の処理だけ行われます。
0
0