【Unity】アウトラインを再考察してみた【車輪の再発明】

はじめに

モデルのアウトライン表現は各所で考案されてきました。最近のトレンドはポストエフェクトで表現するものですが、オブジェクトに個別に設定しにくいことから、オブジェクト単位で設定できるものが必要でした。

結果

アウトラインレンダリング
こんなんできました。

特徴

 昔ながらの、頂点法線に膨張させ、アウトラインを描画するものです。ただ、シェーダーで面法線に膨張させるとエッジが分解してしまうので、頂点法線をシェーダーにわたす部分に ComputeBuffer を利用しています。
 頂点カラーを使って綺麗にアウトラインを描画する で紹介されている方法はモデルに加工を施すため ProBuilder や 標準のプリミティブでは利用できないので、見送りました。

解説

スクリプトサイドの全コード

/*****************************************************************************
*	$Id: $
*	アウトライン
*****************************************************************************/

using System;
using System.Linq;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

namespace outlinetest {

using PlaneListPointMap = Dictionary<Vector3,List<Plane>>;

[RequireComponent( typeof( MeshFilter))]
[RequireComponent( typeof( MeshRenderer))]
public class OutlineTest : MonoBehaviour
{
	// アウトラインの色
	[SerializeField]
	private Color outlineColor = Color.red;

	// アウトラインの太さ
	[SerializeField]
	[Range(0.01f, 1.0f)]
	private float outlineWidth = 0.1f;

	// コンピュートバッファ
	struct VertexExpansion
	{
		public Vector3 normal;
	} // struct VertexExpansion
	private VertexExpansion[] vertexExpansionArray;
	private ComputeBuffer computeBuffer;

	// コマンドバッファ
	private CommandBuffer commandBuffer;
	private Material outlineMaterial;

	//----------------------------------------------------------------------------
	private System.Collections.IEnumerator Start()
	{
		var mesh = GetComponent<MeshFilter>().mesh;

		// インデックス取得
		List<int> indices = new List<int>();
		mesh.GetIndices( indices, 0);
		int indexCount = indices.Count;

		// 頂点取得
		var vertices = new List<Vector3>();
		mesh.GetVertices( vertices);

		const float ZERO_TOLERANCE = 1e-08f;
		// 共面がなければ追加する
		System.Action<PlaneListPointMap,Vector3,Plane> mappingPlane = ( map, pos, plane) =>
		{
			List<Plane> planeList;
			if( !map.TryGetValue( pos, out planeList))
			{
				planeList = new List<Plane>();
				map[pos] = planeList;
			}
			// 共面がなければ平面を追加
			if( !planeList.Any( dest => {
				float dot = Vector3.Dot( plane.normal, dest.normal);
				if( dot >= 1.0f-ZERO_TOLERANCE)
				{
					if( Mathf.Abs( ( dot >= 0) ? ( plane.distance - dest.distance ) : ( plane.distance + dest.distance) ) < ZERO_TOLERANCE)
					{
						// 共面である
						return true;
					}
				}
				return false;
			}))
			{
				planeList.Add( plane);
			}
		};

		// ポイントが含まれる平面をリストアップする
		var planeListPointMap = new PlaneListPointMap();
		for( int i = 0; i < indexCount; i += 3)
		{
			int index0 = indices[i+0];
			int index1 = indices[i+1];
			int index2 = indices[i+2];

			var pos0 = vertices[index0];
			var pos1 = vertices[index1];
			var pos2 = vertices[index2];

			var plane = new Plane( pos0, pos1, pos2);

			mappingPlane( planeListPointMap, pos0, plane);
			mappingPlane( planeListPointMap, pos1, plane);
			mappingPlane( planeListPointMap, pos2, plane);
		}

		// 平面リストから頂点法線を求める
		var normalMap = new Dictionary<Vector3,Vector3>();
		foreach( var it in planeListPointMap)
		{
			var planeList = it.Value;
			var normal = Vector3.zero;
			foreach( var plane in planeList)
			{
				normal += plane.normal;
			}
			normalMap[ it.Key ] = normal.normalized;
		}

		// コンピュートバッファにコピーする
		vertexExpansionArray = new VertexExpansion[ vertices.Count ];
		for( int i = 0; i < vertices.Count; ++i)
		{
			var pos = vertices[i];
			vertexExpansionArray[i].normal = normalMap[pos];
		}

		computeBuffer = new ComputeBuffer( vertexExpansionArray.Length, System.Runtime.InteropServices.Marshal.SizeOf( typeof( VertexExpansion)) );
		computeBuffer.SetData( vertexExpansionArray);

		// アウトラインシェーダー作成
		outlineMaterial = new Material( Shader.Find( "Custom/OutlineTest"));
		outlineMaterial.SetBuffer( "_VertexExpansionArray", computeBuffer);
		outlineMaterial.SetColor( "_Color", outlineColor);
		outlineMaterial.SetFloat( "_Thickness", outlineWidth);

		// カメラにアウトライン用フローを追加
		var camera = Camera.main;
		commandBuffer = new CommandBuffer();
		camera.AddCommandBuffer( CameraEvent.BeforeImageEffectsOpaque, commandBuffer);
		commandBuffer.name = "Outline test";
		commandBuffer.DrawRenderer(
			GetComponent<Renderer>(),
			outlineMaterial,
			0
		);

		yield break;
	}
	//----------------------------------------------------------------------------
	// 開放
	private void OnDestroy()
	{
		computeBuffer?.Release();
		computeBuffer = null;
		if( outlineMaterial != null)
		{
			Destroy( outlineMaterial);
			outlineMaterial = null;
		}
		commandBuffer?.Clear();
		commandBuffer = null;
	}

} // class OutlineTest

} // outlinetest

面を列挙して頂点法線を算出する

頂点をキーにして平面辞書を作成します。

 モデルが三角形で構成されてるときだけ使えるコードです。
 PlaneListPointMapが頂点がキーで、その頂点が属している無限平面の配列の辞書の別名になります。
 まず構成される三角形の頂点から平面を作り、平面の配列を作っていきます。

// ポイントが含まれる平面をリストアップする
var planeListPointMap = new PlaneListPointMap();
for( int i = 0; i < indexCount; i += 3)
{
	int index0 = indices[i+0];
	int index1 = indices[i+1];
	int index2 = indices[i+2];

	var pos0 = vertices[index0];
	var pos1 = vertices[index1];
	var pos2 = vertices[index2];

	var plane = new Plane( pos0, pos1, pos2);

	mappingPlane( planeListPointMap, pos0, plane);
	mappingPlane( planeListPointMap, pos1, plane);
	mappingPlane( planeListPointMap, pos2, plane);
}

 デリゲートの mappingPlane() は共面でない平面であれば、辞書に追加する処理をしています。単純に追加していくと法線が歪むことがあります。頂点が構成している三角形の数がそれぞれの面で違うためです。

平面辞書から頂点の法線を求める

// 平面リストから頂点法線を求める
var normalMap = new Dictionary<Vector3,Vector3>();
foreach( var it in planeListPointMap)
{
	var planeList = it.Value;
	var normal = Vector3.zero;
	foreach( var plane in planeList)
	{
		normal += plane.normal;
	}
	normalMap[ it.Key ] = normal.normalized;
}

単純に平面リストから法線を取り出し、合成して最後に正規化してます。共面が無いので単純な処理になっています。問題は後ほど書きますが、内積が0より小さい鋭角が来たときに破綻します。

コンピュートバッファを作成し、法線情報を書き込む

// コンピュートバッファにコピーする
vertexExpansionArray = new VertexExpansion[ vertices.Count ];
for( int i = 0; i < vertices.Count; ++i)
{
	var pos = vertices[i];
	vertexExpansionArray[i].normal = normalMap[pos];
}
computeBuffer = new ComputeBuffer( vertexExpansionArray.Length, System.Runtime.InteropServices.Marshal.SizeOf( typeof( VertexExpansion)) );
computeBuffer.SetData( vertexExpansionArray);

 コンピュートバッファとはユーザーが定義した構造体で配列を作り、スクリプトとシェーダー間で任意のデータの受け渡しができるバッファです。バッファはスクリプトで確保して SetData で設定します。これに頂点法線データを突っ込んで Shader に渡します。

アウトラインシェーダーの生成と、カメラにコマンドバッファを追加

// アウトラインシェーダー作成
outlineMaterial = new Material( Shader.Find( "Custom/OutlineTest"));
outlineMaterial.SetBuffer( "_VertexExpansionArray", computeBuffer);
outlineMaterial.SetColor( "_Color", outlineColor);
outlineMaterial.SetFloat( "_Thickness", outlineWidth);

// カメラにアウトライン用フローを追加
var camera = Camera.main;
commandBuffer = new CommandBuffer();
camera.AddCommandBuffer( CameraEvent.BeforeImageEffectsOpaque, commandBuffer);
commandBuffer.name = "Outline test";
commandBuffer.DrawRenderer(
	GetComponent<Renderer>(),
	outlineMaterial,
	0
);

シェーダーを作成して、カメラにコマンドバッファで登録します。マテリアルを別途用意してそちらを参照するも問題ないかと思います。
 肝はDrawRenderer で既存の Renderer をアウトライン用に再表示しています。
 悩んだのは挿入のタイミングで、当初は AfterSkybox にしていましたが、Skybox を使わない場合に走らないようで、必ず発生していた BeforeImageEffectsOpaque に設定してます。

スクリプトサイドは以上になります

シェーダーサイドのコード

Shader "Custom/OutlineTest"
{
	Properties
	{
		_Color ("color", Color) = (0,0,0,1)
		_Thickness ("thickness", Range (0.0, 1.0)) = 0.1
	}

	CGINCLUDE
	#include "UnityCG.cginc"

	ENDCG

	SubShader
	{
		Tags
		{
			"Queue" = "Transparent"
			"IgnoreProjector" = "True"
		}

		Cull Off

		// くりぬく部分をマスク
		Pass
		{
			ColorMask 0
			ZTest Off
			ZWrite Off
			Stencil
			{
				Ref 1
				Comp Always
				Pass Replace
				ZFail Zero
			}

			CGPROGRAM

			#pragma vertex vert
			#pragma fragment frag

			float4 vert( float4 vertex : POSITION) : SV_POSITION
			{
				return UnityObjectToClipPos( vertex);
			}

			half4 frag() : COLOR
			{
				return half4( 0, 0, 0, 1);
			}

			ENDCG
		}

		// シルエットを描く
		Pass
		{
			ColorMask RGB
			Cull Back
			ZTest LEqual
			ZWrite On

			Stencil
			{
				Ref 1
				Comp NotEqual
				Fail Keep
				Pass Keep
				ZFail Keep
			}

			CGPROGRAM

			#pragma vertex vert
			#pragma fragment frag
			#pragma target 3.5

			struct VertexExpansion
			{
				float3 normal;
			};

			struct appdata
			{
				float4 vertex : POSITION;
			};

			struct v2f
			{
				float4 vertex : POSITION;
			};

			uniform StructuredBuffer<VertexExpansion> _VertexExpansionArray;
			uniform float _Thickness;
			uniform float4 _Color;

			v2f vert( appdata v, uint index : SV_VertexID)
			{
				v2f o = (v2f)0;

				float3 vnormal = _VertexExpansionArray[index].normal;

				float3 normal = mul( UNITY_MATRIX_IT_MV, vnormal);
				float2 basis = TransformViewToProjection( normalize( normal.xy));

				o.vertex = UnityObjectToClipPos( v.vertex);
				o.vertex.xy += basis * _Thickness;

				return o;
			}

			half4 frag() : SV_TARGET
			{
				return _Color;
			}

			ENDCG
		}

		// ステンシルクリア
		Pass
		{
			ColorMask 0
			ZWrite Off
			ZTest Off
			Stencil
			{
				Ref 0
				Comp Always
				Pass Replace
			}

			CGPROGRAM

			#pragma vertex vert
			#pragma fragment frag

			float4 vert( float4 vertex : POSITION) : SV_POSITION
			{
				return UnityObjectToClipPos( vertex);
			}

			half4 frag() : COLOR
			{
				return half4( 0, 0, 0, 1);
			}

			ENDCG
		}
	}
	Fallback "Diffuse"
}

1pass目で 現物オブジェクトを ステンシルバッファに書き込んでます。
ステンシルのみに書き込むため
ColorMask 0
でカラーは更新しません。

2pass目で アウトラインを描き込みます。
uint index : SV_VertexID
で頂点番号が取得できるので設定したコンピュートバッファをフェッチし法線を取得します。

その後法線方向に膨張させます。

float2 basis = TransformViewToProjection( normalize( normal.xy));
o.vertex = UnityObjectToClipPos( v.vertex);
o.vertex.xy += basis * _Thickness;

クリップスペースに変換後に膨張させることで、線の太さを均一にしています。
normalize( normal.xy)
の部分で正規化してなかったため、頂点が歪んで小一時間悩んでました。

3pass目は ステンシルをクリアしています。
CommandBuffer でコマンドが見つからなかったので、こちらでクリアしてます。もしかしたらあるかもしれません。

オブジェクトに設定して実行する

あとは MeshRendererコンポーネントを持つオブジェクトに OutlineTest.cs を Add Component で追加すると利用可能です。

コンポーネント追加する

表示/非表示が必要な場合は CommandBuffer の破棄と再生成で可能かと思います。

問題点

深度エラー

膨張方式でつきものの 深度の不具合は解消されていません。

pass を増やして 深度テストとステンシルテストを工夫すれば解決できそうです。

スキニングメッシュに非対応

法線方向が変化するので、現状では非対応です。アニメーション時にコンピュートバッファの法線もシェーダーで更新すれば対応できると思います。

極端な鋭角だと破綻する

こちらは対応方法が思いつきませんでした。法線計算の時点でうまい具合に頂点を計算すればうまくいきそうですが、きれいな公式が導き出せず放置にしました。
 下の画像では底辺と側面の接点を底面付近まで持っていけばきれいに出るかと思います。

鋭角がきつい円錐などはひどい

追記:ポストプロセッシングのアウトライン作成追加しました。

One Comment

Add a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です