本文主要来讲几种描边的实现方法
1.法线外扩
一般期望的描边效果,就是在模型外面有一圈选边,因此我们可以把模型扩大一点点,利用这个扩大的边缘来实现描边效果。可以看出,扩大的方向其实就是法线的方向,边缘的法线和视线夹角基本成90度。所以我们可以在第一个pass 朝法线方向扩大模型。 要扩大模型,自然是在vertexshader阶段处理,我们实际仅需要扩大边缘,因此我们只需要渲染背面,因为我们第二个pass就会盖在第一个pass的结果上,仅留下扩大的边缘。
//把法线转换到视图空间
float3 vnormal = mul((float3x3)UNITY_MATRIX_IT_MV,v.normal);
//把法线转换到投影空间
float2 pnormal_xy = mul((float2x2)UNITY_MATRIX_P,vnormal.xy);
//朝法线方向外扩
o.vertex.xy = o.vertex.xy + pnormal_xy * _Outline;
整个代码实现:
Shader "xjm/outline"
{
//法线外扩实现描边
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Outline("Outline",float) = 0.1
_OutlineColor("OutlineColor",Color) = (0,0,0,1)
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
//描边阶段,法线外扩,渲染背面
Pass
{
//只需要边缘外扩
Cull Front
ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
};
float _Outline;
float4 _OutlineColor;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
//把法线转换到视图空间
float3 vnormal = mul((float3x3)UNITY_MATRIX_IT_MV,v.normal);
//把法线转换到投影空间
float2 pnormal_xy = mul((float2x2)UNITY_MATRIX_P,vnormal.xy);
//朝法线方向外扩
o.vertex.xy = o.vertex.xy + pnormal_xy * _Outline;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return _OutlineColor;
}
ENDCG
}
//正常阶段
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
struct appdata
{
float4 vertex : POSITION;
float2 uv :TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv:TEXCOORD0;
};
sampler2D _MainTex;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return tex2D(_MainTex,i.uv);
}
ENDCG
}
}
}
实现的效果:
可以看得出,除了边缘有被描边之外,里面(模型的前面)也有描边的线条。如何去掉内部的线条呢?可以在第一个pass里关闭深度写入,关闭了深度写入之后,第二个pass就会完全盖住这个模型(除了外扩部分)
但是你再看看在Game视图下描边不起作用了!
看看FrameDebuger:
看到Unity在渲染完模型之后,再渲染了天空盒,因为描边pass没有写入深度,所以被天空盒覆盖了。
注:Unity的geometry类型的渲染顺序是从前往后的,而Transparent类型是从后往前的。天空盒的渲染顺序位于geometry之后,Transparent之前。因此我们把描边pass放在Transparent的渲染顺序就不会被geometry类型遮挡了。
因此把渲染队列改成Transparent就可以了。
后处理实现描边效果
法线外扩的方法有一个缺点,就是当模型边缘比较菱角分明的时候,法线外扩后会出现不连续的现象。因此我们还可以用后处理来实现描边或者外发光的效果。
想象一下,我们要实现描边,按法线外扩的做法,是在渲染正常画面之前先渲染了一张比模型边缘扩大了一点点的纯色图片。再用正常的渲染模型盖住纯色模型,只让纯色图片的外扩的边缘显示出来。
在后处理阶段要怎么处理呢?
处理流程我们可以在OnPreRender函数里先渲染一张纯色照片。OnPreRender函数在渲染之间执行。在正常渲染之间,我们先用一个纯色shader 替换要描边物体的shader,把渲染出来的纯色图片渲染到纹理上。
怎么把这张纯色照片的边缘外扩的呢? 可以选择模糊的方法。高斯模糊的具体实现在这里就不多说了。我们模糊了之后的照片,边缘就会往外扩散。
得到模糊的照片后,我们用模糊照片的颜色减去纯色照片的颜色,就会得到一个边缘照片。
用正常渲染的图,叠加边缘图片就可以得到一个外发光或者描边的效果了。
2.1 普通后处理实现
上面的4个流程中,在Unity5之前,我们可以额外增加一个摄像机,摄像机只渲染要描边的物体。而这个摄像机渲染到RenderTexture上。这个摄像机用来渲染纯色图片。 仅渲染Player 层,创建一个RenderTexture
m_outlineCamera.cullingMask = 1 << LayerMask.NameToLayer("Player");
int width = m_outlineCamera.pixelWidth >> m_downSampler;
int height = m_outlineCamera.pixelHeight >> m_downSampler;
m_renderTexture = RenderTexture.GetTemporary(width, height, 0);
在渲染之间,用RenderWithShader函数替换shader。这里的意思是在此刻,OutlineCamera摄像机下的所有物体都用outlineShader渲染。
private void OnPreRender()
{
//先渲染到RT if (m_outlineCamera.enabled)
{
m_outlineCamera.targetTexture = m_renderTexture;
m_outlineCamera.RenderWithShader(m_outlinePreShader, "");//渲染了一张纯色RT }
}
渲染完后,在OnRenderImage阶段:
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
int rtW = source.width >> m_downSampler;
int rtH = source.height >> m_downSampler;
var temp1 = RenderTexture.GetTemporary(rtW, rtH, 0);
var temp2 = RenderTexture.GetTemporary(rtW, rtH, 0);
// 先模糊纯色的图片 Graphics.Blit(m_renderTexture, temp1);
// 模糊迭代 for (int i = 0; i < blurIterator; ++i)
{
Graphics.Blit(temp1, temp2, outlineMaterial, 0);
Graphics.Blit(temp2, temp1, outlineMaterial, 1);
}
if (hardSide)
{
outlineMaterial.EnableKeyword("_Hard_Side");
}
else
{
outlineMaterial.DisableKeyword("_Hard_Side");
}
outlineMaterial.SetTexture("_BlurTex", temp2);
outlineMaterial.SetTexture("_SrcTex", m_renderTexture);
outlineMaterial.SetColor("_OutlineColor", outlineColor);
Graphics.Blit(source, destination, outlineMaterial, 2);
RenderTexture.ReleaseTemporary(temp1);
RenderTexture.ReleaseTemporary(temp2);
}
来看看shader部分,纯色shader, 就返回一个边缘色就行了。非常简单:
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
float4 _OutlineColor;
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 vertex : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return _OutlineColor;
}
ENDCG
描边shader:
/// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'
Shader "xjm/outline_postEffect"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_BlurSize("Blur Size",float) = 1
_BlurTex("Blur Tex",2D) = ""{}
_SrcTex("SrcTex", 2D) = "white"{}
_OutlineColor("OutLine Color",Color) = (1,1,1,1)
}
CGINCLUDE
#include "UnityCG.cginc"
uniform half4 _MainTex_TexelSize;
// 边缘分成了硬边和软边两种。
#pragma shader_feature _Hard_Side
float _BlurSize;
sampler2D _MainTex;
sampler2D _BlurTex;
sampler2D _SrcTex;
float4 _OutlineColor;
// 高斯模糊部分 ---{
//高斯模糊权重
static const half4 GaussWeight[7] =
{
half4(0.0205,0.0205,0.0205,0),
half4(0.0855,0.0855,0.0855,0),
half4(0.232,0.232,0.232,0),
half4(0.324,0.324,0.324,1),
half4(0.232,0.232,0.232,0),
half4(0.0855,0.0855,0.0855,0),
half4(0.0205,0.0205,0.0205,0)
};
struct v2f_Blur
{
float4 pos:SV_POSITION;
half2 uv:TEXCOORD0;
half2 offset:TEXCOORD1;
};
// 水平方向的高斯模糊
v2f_Blur vert_blur_Horizonal(appdata_img v)
{
v2f_Blur o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.offset = _MainTex_TexelSize.xy * half2(1,0)*_BlurSize;
return o;
}
// 垂直方向的高斯模糊
v2f_Blur vert_blur_Vertical(appdata_img v)
{
v2f_Blur o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.offset = _MainTex_TexelSize.xy * half2(0,1)*_BlurSize;
return o;
}
half4 frag_blur(v2f_Blur i):COLOR
{
half2 uv_withOffset = i.uv - i.offset * 3;
half4 color = 0;
for (int j = 0; j < 7; ++j)
{
half4 texcol = tex2D(_MainTex,uv_withOffset);
color += texcol * GaussWeight[j];
uv_withOffset += i.offset;
}
return color;
}
// }--- 高斯模糊部分
//add
struct v2f_add
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float2 uv1 : TEXCOORD1;
};
v2f_add vert_add(appdata_img v)
{
v2f_add o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.uv1 = o.uv;
#if UNITY_UV_STARTS_AT_TOP
o.uv.y = 1- o.uv.y;
#endif
return o;
}
half4 frag_add(v2f_add i):COLOR
{
//获取屏幕
fixed4 scene = tex2D(_MainTex, i.uv1);
fixed4 blurCol = tex2D(_BlurTex,i.uv1);
fixed4 srcCol = tex2D(_SrcTex,i.uv1);
#if _Hard_Side
// 如果是硬边描边,就用模糊纹理-原来的纹理得到边缘
fixed4 outlineColor = saturate(blurCol - srcCol);
// all(outlineColor.rgb) 三个分量都不等于0,返回1,否则返回0.类似&&运算
// any(outlineColor.rgb);rgb 任意不为 0,则返回 true。类似||运算
// 如果rgb都不为0(硬边部分)就显示硬边,否则都显示scene部分。
return scene * (1 - all(outlineColor.rgb)) + _OutlineColor * any(outlineColor.rgb);
#else
return saturate(blurCol - srcCol) + scene;
#endif
}
//add
ENDCG
SubShader
{
ZTest Always
ZWrite Off
Fog{ Mode Off }
//0
Pass
{
ZTest Always
Cull Off
CGPROGRAM
#pragma vertex vert_blur_Horizonal
#pragma fragment frag_blur
ENDCG
}
//1
Pass
{
ZTest Always
Cull Off
CGPROGRAM
#pragma vertex vert_blur_Vertical
#pragma fragment frag_blur
ENDCG
}
//2
Pass
{
ZTest Off
Cull Off
CGPROGRAM
#pragma vertex vert_add
#pragma fragment frag_add
ENDCG
}
}
}
软边(外发光):
Pass0 和Pass1 分别是水平和垂直方向的模糊,在Pass 2 里进行相减。
blurCol - srcCol 后的图像是这样子的
再叠加正常图像,可以得到:
得到外发光的描边。
saturate(blurCol - srcCol) + scene;
要实现硬边的效果,还在再处理一下,由上面那张相减之后的图片可以看到边缘是一个渐变的过程。边缘色越外越接近黑色RGB(0,0,0)。因此我们只选取RGB都大于0的部分像素就可以剔除渐变部分,只留下硬边部分。
saturate(blurCol - srcCol); 把结果限制为[0,1],因为相减可能会导致负值,从而描边在模型内部染色。
// 如果是硬边描边,就用模糊纹理-原来的纹理得到边缘 fixed4 outlineColor = saturate(blurCol - srcCol);
// all(outlineColor.rgb) 三个分量都不等于0,返回1,否则返回0.类似&&运算 // any(outlineColor.rgb);rgb 任意不为 0,则返回 true。类似||运算 // 如果rgb都大于0(硬边部分)就显示硬边,否则都显示scene部分。 return scene * (1 - all(outlineColor.rgb)) + _OutlineColor * any(outlineColor.rgb);
结果:
2.2 Command Buffer 实现
除了用额外的摄像机之外,在Unity5 还可以用Command Buffer来实现,代码会更简单。
因为Command Buffer 可以在RenderImage 里执行。所以可以在初始化的时候,设置Command Buffer的renderTexture,设置要描边物体的shader。然后在后处理的时候再执行这个Command。就可以得到纯色图像了。
初始化部分:
m_commandBuffer = new CommandBuffer();
if (m_renderTexture == null)
m_renderTexture = RenderTexture.GetTemporary(Screen.width >> m_downSampler, Screen.height >> m_downSampler, 0);
m_commandBuffer.SetRenderTarget(m_renderTexture);
m_commandBuffer.ClearRenderTarget(true, true, Color.black);
foreach (var renderer in Renderers)
{
m_commandBuffer.DrawRenderer(renderer, outlinePreMaterial)
}
在后处理阶段:
int rtW = source.width >> m_downSampler;
int rtH = source.height >> m_downSampler;
// 插入commandbuffer,绘制出纯色的图片 m_outlinePreMat.SetColor("_OutlineColor", outlineColor);
Graphics.ExecuteCommandBuffer(m_commandBuffer);
var temp1 = RenderTexture.GetTemporary(rtW, rtH, 16);
var temp2 = RenderTexture.GetTemporary(rtW, rtH, 16);
Graphics.Blit(m_renderTexture, temp1, outlineMaterial, 0);
Graphics.Blit(temp1, temp2, outlineMaterial, 1);
for (int i = 0; i < blurIterator; ++i)
{
Graphics.Blit(temp2, temp1, outlineMaterial, 0);
Graphics.Blit(temp1, temp2, outlineMaterial, 1);
}
if (onlyShowBlur)
{
Graphics.Blit(temp2, destination);
RenderTexture.ReleaseTemporary(temp1);
RenderTexture.ReleaseTemporary(temp2);
return;
}
//add //把模糊的边框加到原来的照片中 if (hardSide)
{
outlineMaterial.EnableKeyword("_Hard_Side");
outlineMaterial.SetColor("_OutlineColor", outlineColor);
}
else
{
outlineMaterial.DisableKeyword("_Hard_Side");
}
outlineMaterial.SetTexture("_BlurTex", temp2);
outlineMaterial.SetTexture("_SrcTex", m_renderTexture);
outlineMaterial.SetFloat("_BlurSize", blurSize);
Graphics.Blit(source, destination, outlineMaterial, 2);
RenderTexture.ReleaseTemporary(temp1);
RenderTexture.ReleaseTemporary(temp2);
}
不用额外增加camera,非常方便。