初めに
身も蓋もないけど、これが使えるならそれが一番。
https://docs.unity3d.com/jp/460/Manual/script-EdgeDetectEffectNormals.html
やろうとしてるのは、↑これと同様の手法です。
経緯
アウトライン、すなわちオブジェクトの輪郭を描くための Unity シェーダーを探していましたが、この記事を書いた時点ではネット検索の上位に引っかかるのは、「オブジェクトを膨らませて二度描きする」方法でした。
例えばこの記事の最初に紹介されてる方法です。
http://light11.hatenadiary.com/entry/2018/05/13/183314
しかし、実際やってみるとこの方法は問題があって、ちょっと複雑なシーンになるとうまく行かなくなることがわかりました。
それで、同記事にある3・4番目のポストエフェクトを使う方法を調べて、そこそこ満足のいく結果が出来たのでご紹介しようと思います。
問題点
その前に、まず「オブジェクトを膨らませて二度描きする」方法の何が問題かを説明しておきます。問題は主に二つあります。
綺麗なアウトラインにならない理由1
検索上位に引っかかる方法は、物体の頂点を法線方向に移動させて膨らませています。しかし、これで上手く行くのは球体、カプセル、ドーナツのように全て滑らかにつながった表面で構成されるメッシュだけです。
実際には隣接する頂点の法線ベクトルが同一とは限りません。立方体が良い例です。立方体の頂点を法線方向に移動させると、下の図のように面が切り離されて浮き上がったようになってしまいます。
この問題は、まじめに利用しようとすれば比較的容易に気づくため、解決方法を提案してる方もいます。
参考1、参考2
綺麗なアウトラインにならない理由2
検索上位に引っかかる方法は、2パスで先に深度バッファを埋めずにアウトライン用シルエットを描画していますが、このシルエットの描画自身は既存の深度バッファの影響を受けてしまいます。
つまり、オブジェクトが重なりあうような場所、凹面ができるような場所では、アウトライン自体が切り取られて線が浮いたような表現になってしまいます。
例えば下の図で、手前の球のアウトラインは、奥の球が存在する部分を上書きできず、スキマが出来てしまっています。
こうなってくると、描画順序を緻密にコントロールするとかしないと難しいんじゃないかと思います。
ポストエフェクトでやる
私が選んだ解決方法はポストエフェクトです。ポストエフェクトは乱暴に言えばシーン画像のフォトレタッチみたいなものです。
ただし、ここではサーフェースカラー画像ではなく、深度バッファと法線バッファに対してエッジ抽出します。
イメージはこちらの記事の冒頭部分を見てもらえればわかるかと。
実際のコードはこちらの記事を参考に改良しました。
https://www.ronja-tutorials.com/2018/07/15/postprocessing-outlines.html
PostEffectOutline.shader
Shader "Tutorial/019_OutlinesPostprocessed"
{
//show values to edit in inspector
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
_OutlineColor ("Outline Color", Color) = (0,0,0,1)
_Depth1 ("Depth dist 1 multiplier", Range(0,1)) = 0.6
_Depth1_1 ("Depth dist 1.4 multiplier", Range(0,1)) = 0.6
_Depth2 ("Depth dist 2 multiplier", Range(0,1)) = 0.5
_Depth2_1 ("Depth dist 2.2 multiplier", Range(0,1)) = 0.5
_Depth2_2 ("Depth dist 2.8 multiplier", Range(0,1)) = 0.5
_Depth3 ("Depth dist 3 multiplier", Range(0,1)) = 0.5
_Depth3_1 ("Depth dist 3.2 multiplier", Range(0,1)) = 0.5
_Depth3_2 ("Depth dist 3.6 multiplier", Range(0,1)) = 0.4
_Normal1 ("Normal dist 1 multiplier", Range(0,1)) = 0.8
_Normal1_1 ("Normal dist 1.4 multiplier", Range(0,1)) = 0.7
_Normal2 ("Normal dist 2 multiplier", Range(0,1)) = 0.5
_Normal2_1 ("Normal dist 2.2 multiplier", Range(0,1)) = 0.1
_Normal2_2 ("Normal dist 2.8 multiplier", Range(0,1)) = 0.1
_NormalCutOff ("Normal diff Cut-off", Range(0,1)) = 0.04
}
SubShader{
// markers that specify that we don't need culling
// or comparing/writing to the depth buffer
Cull Off
ZWrite Off
ZTest Always
Pass{
CGPROGRAM
//include useful shader functions
#include "UnityCG.cginc"
//define vertex and fragment shader
#pragma vertex vert
#pragma fragment frag
//the rendered screen so far
sampler2D _MainTex;
//the depth normals texture
sampler2D _CameraDepthNormalsTexture;
//texelsize of the depthnormals texture
float4 _CameraDepthNormalsTexture_TexelSize;
//variables for customising the effect
float4 _OutlineColor;
float _Depth1;
float _Depth1_1;
float _Depth2;
float _Depth2_1;
float _Depth2_2;
float _Depth3;
float _Depth3_1;
float _Depth3_2;
float _Normal1;
float _Normal1_1;
float _Normal2;
float _Normal2_1;
float _Normal2_2;
float _NormalCutOff;
//the object data that's put into the vertex shader
struct appdata{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
//the data that's used to generate fragments and can be read by the fragment shader
struct v2f{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};
//the vertex shader
v2f vert(appdata v){
v2f o;
//convert the vertex positions from object space to clip space so they can be rendered
o.position = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
float2 uvOffset(float2 uv, float x, float y){
return uv + _CameraDepthNormalsTexture_TexelSize.xy * float2(x, y);
}
void CompareNormal1(inout float normalOutline, float3 baseNormal, float2 uv1, float2 uv2, float mulNormal){
//read neighbor pixel
float4 neighbor = tex2D(_CameraDepthNormalsTexture, uv1);
float neighborDepth;
float3 neighborNormal1;
DecodeDepthNormal(neighbor, neighborDepth, neighborNormal1);
neighbor = tex2D(_CameraDepthNormalsTexture, uv2);
float3 neighborNormal2;
DecodeDepthNormal(neighbor, neighborDepth, neighborNormal2);
float3 diff1 = distance(baseNormal, neighborNormal1);
float3 diff2 = distance(baseNormal, neighborNormal2);
float3 neighborNormal = diff1 > diff2 ? neighborNormal1 : neighborNormal2;
float normalDifference = dot(neighborNormal1, neighborNormal2) < dot(baseNormal, neighborNormal) ? max(diff1, diff2) : 0;
normalDifference = smoothstep(_NormalCutOff, 1, normalDifference * mulNormal);
normalOutline = normalOutline + normalDifference;
}
void CompareNormal2(inout float normalOutline, float3 baseNormal, float2 uv1, float2 uv2, float mulNormal){
//read neighbor pixel
float4 neighbor = tex2D(_CameraDepthNormalsTexture, uv1);
float neighborDepth;
float3 neighborNormal1;
DecodeDepthNormal(neighbor, neighborDepth, neighborNormal1);
neighbor = tex2D(_CameraDepthNormalsTexture, uv2);
float3 neighborNormal2;
DecodeDepthNormal(neighbor, neighborDepth, neighborNormal2);
float3 diff = distance(neighborNormal1, neighborNormal2);
float normalDifference = dot(baseNormal, neighborNormal1) > dot(baseNormal, neighborNormal2) ? diff : 0;
normalDifference = smoothstep(_NormalCutOff, 1, normalDifference * mulNormal);
normalOutline = normalOutline + normalDifference;
}
void CompareDepth(inout float depthOutline, float baseDepth, float2 uv, float mulDepth){
//read neighbor pixel
float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture, uv);
float3 neighborNormal;
float neighborDepth;
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
//neighborDepth = neighborDepth * _ProjectionParams.z;
float depthDifference = (baseDepth - neighborDepth) / neighborDepth * mulDepth;
depthOutline = depthOutline + max(depthDifference, 0);
}
//the fragment shader
fixed4 frag(v2f i) : SV_TARGET{
//read depthnormal
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);
//decode depthnormal
float3 normal;
float depth;
DecodeDepthNormal(depthnormal, depth, normal);
//get depth as distance from camera in units
//depth = depth * _ProjectionParams.z;
float depthDifference = 0;
float normalDifference = 0;
// Distance 0-1 Normal
CompareNormal1(normalDifference, normal, uvOffset(i.uv, 1, 0), uvOffset(i.uv, -1, 0), _Normal1);
CompareNormal1(normalDifference, normal, uvOffset(i.uv, 0, 1), uvOffset(i.uv, 0, -1), _Normal1);
// Distance 1-1 Normal
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 1, 0), uvOffset(i.uv, -1, 0), _Normal1_1);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 0, 1), uvOffset(i.uv, 0, -1), _Normal1_1);
// Distance 1-1.4 Normal
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 1, 0), uvOffset(i.uv, -1, 1), _Normal2);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 1, 0), uvOffset(i.uv, -1, -1), _Normal2);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 0, 1), uvOffset(i.uv, 1, -1), _Normal2);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 0, 1), uvOffset(i.uv, -1, -1), _Normal2);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 1, 1), uvOffset(i.uv, -1, 0), _Normal2);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 1, -1), uvOffset(i.uv, -1, 0), _Normal2);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 1, 1), uvOffset(i.uv, 0, -1), _Normal2);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, -1, 1), uvOffset(i.uv, 0, -1), _Normal2);
// Distance 1.4-1.4 Normal
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 1, 1), uvOffset(i.uv, -1, -1), _Normal2_1);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, -1, 1), uvOffset(i.uv, 1, -1), _Normal2_1);
// Distance 1-2 Normal
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 1, 0), uvOffset(i.uv, -2, 0), _Normal2_2);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 0, 1), uvOffset(i.uv, 0, -2), _Normal2_2);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, -1, 0), uvOffset(i.uv, 2, 0), _Normal2_2);
CompareNormal2(normalDifference, normal, uvOffset(i.uv, 0, -1), uvOffset(i.uv, 0, 2), _Normal2_2);
// Distance 1
CompareDepth(depthDifference, depth, uvOffset(i.uv, 1, 0), _Depth1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 0, 1), _Depth1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 0, -1), _Depth1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -1, 0), _Depth1);
// Distance SQRT(2)
CompareDepth(depthDifference, depth, uvOffset(i.uv, 1, 1), _Depth1_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 1, -1), _Depth1_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -1, 1), _Depth1_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -1, -1), _Depth1_1);
// Distance 2
CompareDepth(depthDifference, depth, uvOffset(i.uv, 2, 0), _Depth2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 0, 2), _Depth2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 0, -2), _Depth2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -2, 0), _Depth2);
// Distance SQRT(4 + 1)
CompareDepth(depthDifference, depth, uvOffset(i.uv, 2, 1), _Depth2_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 2, -1), _Depth2_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -2, 1), _Depth2_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -2, -1), _Depth2_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 1, 2), _Depth2_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -1, 2), _Depth2_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 1, -2), _Depth2_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -1, -2), _Depth2_1);
// Distance SQRT(4 + 4)
CompareDepth(depthDifference, depth, uvOffset(i.uv, 2, 2), _Depth2_2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 2, -2), _Depth2_2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -2, 2), _Depth2_2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -2, -2), _Depth2_2);
// Distance 3
CompareDepth(depthDifference, depth, uvOffset(i.uv, 0, 3), _Depth3);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 3, 0), _Depth3);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 0, -3), _Depth3);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -3, 0), _Depth3);
// Distance SQRT(9 + 1)
CompareDepth(depthDifference, depth, uvOffset(i.uv, 1, 3), _Depth3_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -1, 3), _Depth3_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 1, -3), _Depth3_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -1, -3), _Depth3_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 3, 1), _Depth3_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 3, -1), _Depth3_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -3, 1), _Depth3_1);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -3, -1), _Depth3_1);
// Distance SQRT(9 + 4)
CompareDepth(depthDifference, depth, uvOffset(i.uv, 2, 3), _Depth3_2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -2, 3), _Depth3_2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 2, -3), _Depth3_2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -2, -3), _Depth3_2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 3, 2), _Depth3_2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, 3, -2), _Depth3_2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -3, 2), _Depth3_2);
CompareDepth(depthDifference, depth, uvOffset(i.uv, -3, -2), _Depth3_2);
float outline = saturate(max(normalDifference, depthDifference)) ;
float4 sourceColor = tex2D(_MainTex, i.uv);
float4 color = lerp(sourceColor, _OutlineColor, outline);
return color;
}
ENDCG
}
}
}
長くてすみません。しかも似たようなメソッドコールがいっぱいですね。
これは太いアウトラインのためより遠くのピクセルと比較しているせいです。
距離グループごとに適用量を変えられるようになってるので、いい値が決まったら使わない部分は削除して最適化してもらったらよいかと思います。
結果
ちなみに、深度エッジだけだとこうです。まさに「輪郭」だけで立方体の面の境目などは出ません。
また、左の立方体を見ていただくと、前後関係に差が大きいほど線が太く濃くなっているのがお分かりいただけるかと。
一方、法線エッジだけだとこう。立方体の面の境界などにもエッジが出ていますが、一方で地面と水平な面は法線方向が同じため境界線が出ません。深度エッジと合成して初めていい感じになりますね。
法線エッジは、モデルが細かすぎるとキレイに出ないのが難点ですね。無理に強度を上げてもモアレっぽくなったり黒カビみたいになったりします。まだまだ調整のしどころがありますね。
コードの説明
元のコードから改良した箇所に絞って解説します。
深度エッジ
オリジナルはこれです。深度と法線の両方を含むテクスチャから neighborDepth に深度情報を取り出します。_ProjectionParams.z はカメラのFar Planeです。
実質的には単に二点の深度を引き算してるだけです。
float neighborDepth;
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
neighborDepth = neighborDepth * _ProjectionParams.z;
return baseDepth - neighborDepth;
それに対して、このように改変しました。
float depthDifference = (baseDepth - neighborDepth) / neighborDepth * mulDepth;
depthOutline = depthOutline + max(depthDifference, 0);
まず、単なる引き算ではなく、neighborDepth で割るようにしました。これによって遠方の深度の差が過剰に大きく評価されることを防ぎます。言い換えれば、遠方にある物体のエッジは控えめにするということです。(_ProjectionParams.z は要らないと思ったので削除しました。)
それから max を使って負の値を排除しています。absではないのは物体の外側にエッジを出すためです。absにするとエッジが物体の内側にも侵食します。
法線エッジ
オリジナルはこれです。二点のベクトルの差ベクトルを求めて、rgb値を合計しています。
float3 neighborNormal;
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
float3 normalDifference = baseNormal - neighborNormal;
normalDifference = normalDifference.r + normalDifference.g + normalDifference.b;
normalOutline = normalOutline + normalDifference;
それに対して、このように改変しました。
float3 diff = distance(neighborNormal1, neighborNormal2);
float normalDifference = dot(baseNormal, neighborNormal1) > dot(baseNormal, neighborNormal2) ? diff : 0;
normalDifference = smoothstep(_NormalCutOff, 1, normalDifference * mulNormal);
normalOutline = normalOutline + normalDifference;
法線エッジはより精細さを求めて、現在の点と近隣点を比較するのではなく、現在の点を挟んだ対称関係にある二つの近隣点を比較するようにしました。
なぜなら、緻密なメッシュの場合、法線境界が平均化されてわかりづらくなると思ったからです。(現在の点を挟んだ二点同士なら平均化されていないはず!)
それから、差分ベクトルを作ってrgbを合計する代わりにdistance関数を使いました。差分がマイナスかどうか気にしなくて済みます。
そして、相対する二点と現在の点との内積をそれぞれ求め、neighborNormal1との内積が大きい場合にのみ差分を適用しています。3点以上離れた点で比較しているので、こうしないと差分の出る点が増えて線が太くなってしまうためです。
使い方
ポストエフェクトのシェーダーはオブジェクトにつけるのではなく、カメラに付けます。
こちらの記事を参考にしてみてください。
http://nn-hokuson.hatenablog.com/entry/2016/11/17/204831