ハードエッジでも縁崩れしない理想的なアウトラインシェーダを作ってみた

# はじめに

仕事でUnityゲーム開発している[yoship1639](https://twitter.com/yoship1639)です。
ハードエッジでもこの様に縁崩れせずにアウトラインを奇麗に出すシェーダを思いついたので公開します。

![edge003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113595/73d39625-9446-1f1a-9b76-1e3a6168b089.png)


縁取りをするシェーダは多々ありますが、特定のパターンで上手くいかない場合があります。
それぞれ代表的な既存のアウトラインシェーダの特徴と、上手くいかないパターンは以下の通りです。

* **モデルを拡大する手法**
特徴：一般的な手法。1パス目で拡大したモデルをアウトライン色で描画し、2パス目で通常描画する
ダメなパターン：ハードエッジモデルの場合やモデルの中心(各頂点の中心)が０じゃない場合に破綻する

* **ステンシルバッファを使う手法**
特徴：これも一般的な手法。1パス目で拡大したモデルをステンシルバッファに描画し、2パス目で通常描画する。ポストプロセス等でアウトラインに色を付ける
ダメなパターン：モデルを拡大する手法と同様

* **法線を使う手法**
特徴：カメラ視点から見てモデルの法線がほぼ垂直になる（内積がほぼ0になる）ピクセルをアウトライン色にする手法
ダメなパターン：アウトラインが超絶汚い

* **深度バッファを使う手法**
特徴：深度バッファを使って深度が急変する箇所をアウトラインとみなす手法。
ダメなパターン：シーン全体にアウトラインがかかってしまう

各手法の細かい説明等はこちらを参考にしてください
 [【Unity】【シェーダ】4種のアウトライン描画方法とその特徴](http://light11.hatenadiary.com/entry/2018/05/13/183314)

と、上記の様に一長一短があります。

理想的なのは、特定のハードエッジなモデルでもきれいなアウトラインを表示することです。
そこで、上記のダメなパターンを払拭したアウトラインシェーダを思いついたので解説したいと思います。

# アルゴリズム解説

今回思いついたのは、**GrabPass**を用いたアウトラインシェーダです。
GrabPassは簡単に説明すると、直前までのパスの描画結果を背景含めてテクスチャとして出力することです。Grabテクスチャはその描画結果のテクスチャを指します。

アルゴリズム概要は以下の通りです。

1. 【1パス目】モデルをシーン上で絶対使わないような色(ダミー色)でソリッド描画する
2. 1の結果をGrabPassとして2パス目に渡す
3. 【2パス目】ピクセルシェーダでGrabテクスチャのUV値付近をアウトライン幅で一定数サンプリングする
4. サンプリングの結果ダミー色が含まれていなかった場合、アウトライン付近と判断しアウトライン色で描画する。それ以外は通常描画する。

上記のアルゴリズムでモデルを描画すると、ハードエッジであってもきれいにアウトラインを描画することができます。

それぞれ説明します。

**1. 【1パス目】でモデルをシーン上で絶対使わないような色(ダミー色)でソリッド描画する**

まず、1パス目でアウトライン描画したいモデルを**シーン上で絶対使わないような色**でモデルを膨らませずに塗りつぶし描画します。なぜシーン上で絶対使わないような色でないといけないかというと、Grabテクスチャの背景と被る色だとアウトラインが破綻する可能性があるからです。この色をダミー色と呼んでおきます。アウトライン色ではありませんのでご注意。

使いやすいダミー色は`rgb(255,0,255)`なので、この色を使います。

```glsl
Pass
{
	CGPROGRAM
	#pragma vertex vert
	#pragma fragment frag

	#include "UnityCG.cginc"

	struct appdata
	{
		half4 vertex : POSITION;
	};

	struct v2f
	{
		half4 vertex : SV_POSITION;
	};

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

	fixed4 frag(v2f i) : SV_Target
	{
		return fixed4(1.0, 0.0, 1.0, 0);
	}
	ENDCG
}
```

**2. 1の結果をGrabPassとして2パス目に渡す**

これは特に何も考えずにシェーダコード内で```GrabPass {}```と書けば大丈夫です。これで1パス目の結果をテクスチャとして2パス目に渡すことができます。

**3. 【2パス目】ピクセルシェーダでGrabテクスチャのUV値付近をアウトライン幅で一定数サンプリングする**

ここが今回の手法のミソです。
頂点シェーダはいつもの感じに行いますが、ピクセルシェーダは少し違います。

まず、GrabPassで入手したGrabテクスチャのピクセルのUV値を算出します。これは頂点シェーダで```ComputeGrabScreenPos```を呼べばUnityが勝手に算出してくれます。このUV値のサンプリング結果はモデルのピクセル値と一致します。（つまり`rgb(255,0,255)`です）

UV値を算出したら、**そのUV値の付近をアウトライン幅分ずらして一定数サンプリング**します。一定数というのはアウトラインと判定するのに十分な数です。今回の手法は**6方向分**サンプリングすれば十分です。

```glsl
#define SAMPLE_NUM 6
#define SAMPLE_INV 0.16666666
#define PI2 6.2831852
#define EPSILON 0.001
#define DUMMY_COLOR fixed3(1.0, 0.0, 1.0)

sampler2D _GrabTexture;
half _OutlineWidth;

v2f vert(appdata v)
{
	v2f o;
	o.pos = UnityObjectToClipPos(v.vertex);
	o.grabPos = ComputeGrabScreenPos(o.pos);

	return o;
}

fixed4 frag(v2f i) : SV_Target
{
	// サンプリングのオフセット（アウトラインの幅）
	half2 delta = (1 / _ScreenParams.xy) * _OutlineWidth;

	int edge = 0;
	[unroll]
	for (int j = 0; j < SAMPLE_NUM && edge == 0; j++)
	{
		// オフセット分ずらしてサンプリング
		fixed4 tex = tex2D(_GrabTexture, i.grabPos.xy / i.grabPos.w + half2(sin(SAMPLE_INV * j * PI2) * delta.x, cos(SAMPLE_INV * j * PI2) * delta.y));
		// ダミー色と同でないならアウトラインであると判定
		edge += distance(tex.rgb, DUMMY_COLOR) < EPSILON ? 0 : 1;
	}
	...		
```

**4. サンプリングの結果ダミー色が含まれていなかった場合、アウトライン付近と判断しアウトライン色で描画する。それ以外は通常描画する。**

![edge002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113595/14e9aa9d-2881-86de-82cb-c15ab5175345.png)

サンプリングした結果、ダミー色しか含まれていない場合はアウトライン付近ではないと判断し通常のモデル描画を行います。
ダミー色以外が１つ以上含まれていた場合は、アウトライン付近であると判断できるので、アウトライン色で塗りつぶします。
これを実現すると、ハードエッジであっても関係なく、かつ特定のモデルのみアウトラインを適用させることができます。

# シェーダコード

今回作成したアウトラインシェーダのシェーダコード全文です。コピペすれば動きます。
必要最小限のコードしか書いていないので、ライト処理やシャドー処理などは行いません。

```glsl
Shader "Custom/Outline"
{
	Properties
	{
		_MainColor("Main Color", Color) = (1, 1, 1, 1)
		_MainTex("Texture", 2D) = "white" {}
		_OutlineColor("Outline Color", Color) = (0.0, 0.0, 0.0, 1)
		[Slider(0.1)] _OutlineWidth("Outline Width", Range(0.0, 10.0)) = 3
	}
	SubShader
	{
		Tags { "RenderType" = "Opaque" }
		LOD 100

		// 【1パス目】ダミー色で塗りつぶし
		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag

			#include "UnityCG.cginc"

			struct appdata
			{
				half4 vertex : POSITION;
			};

			struct v2f
			{
				half4 vertex : SV_POSITION;
			};

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

			fixed4 frag(v2f i) : SV_Target
			{
				return fixed4(1.0, 0.0, 1.0, 0);
			}
			ENDCG
		}

		GrabPass {}

		// 【2パス目】Grabテクスチャを使ってアウトライン＋通常描画
		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag

			#include "UnityCG.cginc"

			#define SAMPLE_NUM 6
			#define SAMPLE_INV 0.16666666
			#define PI2 6.2831852
			#define EPSILON 0.001
			#define DUMMY_COLOR fixed3(1.0, 0.0, 1.0)

			struct appdata
			{
				half4 vertex : POSITION;
				half2 uv : TEXCOORD0;
			};

			struct v2f
			{
				half4 pos : SV_POSITION;
				half2 uv : TEXCOORD0;
				half4 grabPos : TEXCOORD1;
			};

			sampler2D _GrabTexture;
			fixed4 _MainColor;
			sampler2D _MainTex;
			half4 _MainTex_ST;
			fixed4 _OutlineColor;
			half _OutlineWidth;

			v2f vert(appdata v)
			{
				v2f o;
				o.pos = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex);
				o.grabPos = ComputeGrabScreenPos(o.pos);

				return o;
			}

			fixed4 frag(v2f i) : SV_Target
			{
				half2 delta = (1 / _ScreenParams.xy) * _OutlineWidth;

				int edge = 0;
				[unroll]
				for (int j = 0; j < SAMPLE_NUM && edge == 0; j++)
				{
					fixed4 tex = tex2D(_GrabTexture, i.grabPos.xy / i.grabPos.w + half2(sin(SAMPLE_INV * j * PI2) * delta.x, cos(SAMPLE_INV * j * PI2) * delta.y));
					edge += distance(tex.rgb, DUMMY_COLOR) < EPSILON ? 0 : 1;
				}
				
				fixed4 col = lerp(tex2D(_MainTex, i.uv) * _MainColor, _OutlineColor, edge);
				return col;
			}
			ENDCG
		}
	}
}
```

下図の様にハードエッジモデルと通常のモデル両方で扱えます。
![038.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113595/88ecb1a7-75cb-610d-5693-27995fff3979.png)


# おわりに

上記のシェーダコードはアウトラインを表示するための必要最小限の機能しか実装していません。しかし、アウトライン部分以外はいくらでも追加実装することができるので汎用性は高いのではないかと思います。

また、良い感じに描画してくれるアウトラインシェーダですが弱点があります。それはアウトラインシェーダを適用させたモデルを重ねた時です。この場合、GrabPassがモデルと背景の境界を正しく判定できなくなるので、アウトラインが多少破綻してしまいます。そのため、ダミー色をマテリアルごとに変えたりといった工夫が必要になるかもしれません。それ以外の場面では現状問題なく描画できます。

その他不具合があったらご連絡ください。改良版を考えます。
良きUnityライフを。