简介
描边效果是游戏里面非常常用的一种效果,一般是为了凸显游戏中的某个对象,会给对象增加一个描边效果。最近又跑回去玩了玩《剑灵》,虽然出了三年了,感觉在现在的网游里面画面仍然算很好的了,剑灵里面走近或者选中NPC的一瞬间,NPC就会出现描边效果,不过这个描边效果是渐变的,会很快减弱最后消失(抓了好久才抓住一张图....)
还有就是最常见的LOL中的塔,我们把鼠标移动到塔上,就会有很明显的描边效果:
简单描边效果的原理
描边效果有几种实现方式。其实边缘光效果与描边效果有些类似,适当调整边缘光效果,其实也可以达到凸显要表达的对象的意思。边缘光的实现最为简单,只是在计算的时候增加了一次计算法线方向与视线方向的夹角计算,用1减去结果作为系数乘以一个边缘光颜色就达到了边缘光的效果,是性能最好的一种方法,关于边缘光效果,可以参考一下之前的一篇文章: 边缘光效果。边缘光的效果如下图所示:
原始模型渲染:
使用了边缘光的效果:
边缘光效果虽然简单,但是有很大的局限性,边缘光效果只是在当前模型本身的光照计算时调整了边缘位置的颜色值,并没有达到真正的“描边”(当然,有时候我们就是想要这种边缘光的效果),而我们希望的描边效果,一般都是在正常模型的渲染状态下,在模型外面扩展出一个描边的效果。既然要让模型的形状有所改变(向外拓一点),那么肯定就和vertex shader有关系了。而我们的描边效果,肯定就是要让模型更“胖”一点,能够把我们原来的大小包裹住;微观一点来看,一个面,如果我们让它向外拓展,而我们指的外,也就是这个面的法线所指向的方向,那么就让这个面朝着法线的方向平移一点;再微观一点来看,对于顶点来说,也就是我们的vertex shader真正要写的内容了,我们正常计算顶点的时候,传入的vertex会经过MVP变换,最终传递给fragment shader,那么我们就可以在这一步让顶点沿着法线的方向稍微平移一些。我们在描边后,描边这一次渲染的边缘其实是没有办法和我们正常的模型进行区分的,为了解决这个问题,就需要用两个Pass来渲染,第一个Pass渲染描边的效果,进行外拓,而第二个Pass进行原本效果的渲染,这样,后面显示的就是稍微“胖”一点的模型,然后正常的模型贴在上面,把中间的部分挡住,边缘挡不住就露出了描边的部分了。
开启深度写入,剔除正面的描边效果
知道了原理,我们来考虑一下外拓的实现,我们可以在vertex阶段获得顶点的坐标,并且有法线的坐标,最直接的方式就是直接用顶点坐标+法线方向*描边粗细参数,然后用这个偏移的坐标值再进行MVP变换;但是这样做有一个弊端,其实就是我们透视的近大远小的问题,模型上离相机近的地方描边效果较粗,而远的地方描边效果较细。一种解决的方案是先进行MPV变换,变换完之后再去按照法线方向调整外拓。代码如下:
//描边Shader
//by:puppet_master
//2017.1.5Shader "ApcShader/Outline"
{//属性Properties{_Diffuse("Diffuse", Color) = (1,1,1,1)_OutlineCol("OutlineCol", Color) = (1,0,0,1)_OutlineFactor("OutlineFactor", Range(0,1)) = 0.1_MainTex("Base 2D", 2D) = "white"{}}//子着色器 SubShader{//描边使用两个Pass,第一个pass沿法线挤出一点,只输出描边的颜色Pass{//剔除正面,只渲染背面,对于大多数模型适用,不过如果需要背面的,就有问题了Cull FrontCGPROGRAM#include "UnityCG.cginc"fixed4 _OutlineCol;float _OutlineFactor;struct v2f{float4 pos : SV_POSITION;};v2f vert(appdata_full v){v2f o;//在vertex阶段,每个顶点按照法线的方向偏移一部分,不过这种会造成近大远小的透视问题//v.vertex.xyz += v.normal * _OutlineFactor;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);//将法线方向转换到视空间float3 vnormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);//将视空间法线xy坐标转化到投影空间,只有xy需要,z深度不需要了float2 offset = TransformViewToProjection(vnormal.xy);//在最终投影阶段输出进行偏移操作o.pos.xy += offset * _OutlineFactor;return o;}fixed4 frag(v2f i) : SV_Target{//这个Pass直接输出描边颜色return _OutlineCol;}//使用vert函数和frag函数#pragma vertex vert#pragma fragment fragENDCG}//正常着色的PassPass{CGPROGRAM //引入头文件#include "Lighting.cginc"//定义Properties中的变量fixed4 _Diffuse;sampler2D _MainTex;//使用了TRANSFROM_TEX宏就需要定义XXX_STfloat4 _MainTex_ST;//定义结构体:vertex shader阶段输出的内容struct v2f{float4 pos : SV_POSITION;float3 worldNormal : TEXCOORD0;float2 uv : TEXCOORD1;};//定义顶点shader,参数直接使用appdata_base(包含position, noramal, texcoord)v2f vert(appdata_base v){v2f o;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);//通过TRANSFORM_TEX宏转化纹理坐标,主要处理了Offset和Tiling的改变,默认时等同于o.uv = v.texcoord.xy;o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);o.worldNormal = mul(v.normal, (float3x3)_World2Object);return o;}//定义片元shaderfixed4 frag(v2f i) : SV_Target{//unity自身的diffuse也是带了环境光,这里我们也增加一下环境光fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;//归一化法线,即使在vert归一化也不行,从vert到frag阶段有差值处理,传入的法线方向并不是vertex shader直接传出的fixed3 worldNormal = normalize(i.worldNormal);//把光照方向归一化fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);//根据半兰伯特模型计算像素的光照信息fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;//最终输出颜色为lambert光强*材质diffuse颜色*光颜色fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;//进行纹理采样fixed4 color = tex2D(_MainTex, i.uv);color.rgb = color.rgb* diffuse;return fixed4(color);}//使用vert函数和frag函数#pragma vertex vert#pragma fragment frag ENDCG}}//前面的Shader失效的话,使用默认的DiffuseFallBack "Diffuse"
}
开启了描边效果:
附上一张进行了Cull Front操作的效果,只渲染了我们正常看不到的面,效果比较惊悚:
然后再来看看转换的部分,我们通过UNITY_MATRIX_IT_MV矩阵将法线转换到视空间,这里可能会比较好奇,为什么不用正常的顶点转化矩阵来转化法线,其实主要原因是如果按照顶点的转换方式,对于非均匀缩放(scalex, scaley,scalez不一致)时,会导致变换的法线归一化后与面不垂直。如下图所示,左边是变化前的,而中间是沿x轴缩放了0.5倍的情况,显然变化后就不满足法线的性质了,而最右边的才是我们希望的结果。造成这一现象的主要原因是法线只能保证方向的一致性,而不能保证位置的一致性;顶点可以经过坐标变换变换到正确的位置,但是法线是一个向量,我们不能直接使用顶点的变换矩阵进行变换。
我们可以推导一个法线的变换矩阵,就能够保证转化后的法线与面垂直,法线的变换矩阵为模型变换矩阵的逆转置矩阵。具体推导过程可以参考 这篇文章。
在把法线变换到了视空间后,就可以取出其中只与xy面有关的部分,视空间的z轴近似于深度,我们只需要法线在x,y轴的方向,再通过TransformViewToProjection方法,将这个方向转化到投影空间,最后用这个方向加上经过MVP变换的坐标,实现轻微外拓的效果。(从网上和书上看到了不少在这一步计算的时候,又乘上了pos.z的操作,个人感觉没有太大的用处,而且会导致描边效果越远,线条越粗的情况,离远了就会出现一团黑的问题,所以把这个去掉了)
上面说过,一般情况下背面是在我们看到的后面的部分,但是理想很美好,现实很残酷,具体情况千差万别,比如我之前常用的一个模型,模型的袖子里面,其实用的就是背面,如果想要渲染,就需要关闭背面剔除(Cull Off),这种情况下,使用Cull Front只渲染背面,就有可能和第二次正常渲染的时候的背面穿插,造成效果不对的情况,比如:
不过,解决问题的方法肯定要比问题多,我们可以用深度操作神器Offset指令,控制深度测试,比如我们可以让渲染描边的Pass深度远离相机一点,这样就不会与正常的Pass穿插了,修改一下描边的Pass,其实只多了一句话Offset 1,1:
//描边使用两个Pass,第一个pass沿法线挤出一点,只输出描边的颜色Pass{//剔除正面,只渲染背面,对于大多数模型适用,不过如果需要背面的,就有问题了Cull Front//控制深度偏移,描边pass远离相机一些,防止与正常pass穿插Offset 1,1CGPROGRAM#include "UnityCG.cginc"fixed4 _OutlineCol;float _OutlineFactor;struct v2f{float4 pos : SV_POSITION;};v2f vert(appdata_full v){v2f o;//在vertex阶段,每个顶点按照法线的方向偏移一部分,不过这种会造成近大远小的透视问题//v.vertex.xyz += v.normal * _OutlineFactor;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);//将法线方向转换到视空间float3 vnormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);//将视空间法线xy坐标转化到投影空间float2 offset = TransformViewToProjection(vnormal.xy);//在最终投影阶段输出进行偏移操作o.pos.xy += offset * _OutlineFactor;return o;}fixed4 frag(v2f i) : SV_Target{//这个Pass直接输出描边颜色return _OutlineCol;}//使用vert函数和frag函数#pragma vertex vert#pragma fragment fragENDCG}
这样,我们的描边效果也可以支持不能背面剔除的模型了:
Offset指令
写到这里强行插一波基础知识。上面的描边效果,我们用了一个Offset指令,很好地解决了穿插的问题。其实Offset就是解决Stiching和Z-Fighting的最佳途径之一。当然,也可以用模板测试,但是Offset操作更快一点。关于Stiching和Z-Fighting,引用一下 这篇文章:
在OpenGL中,如果想绘制一个多边形同时绘制其边界,可是先使用多边形模式GL_FILL绘制物体,然后使用多边形模式GL_LINE和不同的颜色再次绘制这个多边形。但是由于直线和多边形的光栅化方式不同,导致位于同一位置的多边形和直线的深度值并不相同,进而导致直线有时在多边形的里面,有时在多边形的外面,这种现象就是"Stiching"。而Z-fighting主要是指当两个面共面时,二者的深度值一样,深度缓冲就不能清楚的将它们两者分离开来,位于后面的图元上的一些像素就会被渲染到前面的图元上,最终导致图象在帧与帧之间产生微弱的闪光。
比如我们要绘制两个面完全共面时,两者深度值完全相同,那么我们在进行深度测试的时候,就不能分辨到底哪个在前,哪个在后了。类似我们上面的例子,当我们需要渲染背面时,通过背面进行外拓的Pass渲染的结果就和正常的Pass有穿插了。那么,要解决这个问题,很明显,我们就可以强行设置某个pass的深度偏移,推测这个offset的偏移值是针对ZTest阶段,在进行深度测试的时候,将当前pass的深度用offset进行调整再与深度缓冲区中的值进行比对。附上一张官方文档中关于Offset的部分:
Offset指令有两个参数,一个是Factor,主要影响我们绘制多边形的深度斜率slope的最大值;另一个是Units,主要影响的是能产生在窗口坐标系的深度值中可变分辨率差异的最小值r,这个r一般是OpenGL平台给定的常量。最终的Offset = slope * Factor + r * Units。Units我们一般在有使用Offset指令的地方给一个统一的值就可以了,主要起作用的就是Factor。Offset操作的层面是像素级别的,多边形光栅化之后对应的每个Fragment都有一个偏移值,我们调整Factor,其实相当于沿着当前多边形的斜率深度前进或者后退了一段距离,默认的深度方向是向Z正方向,如果我们给一个大于0的Factor,那么偏移值就会指向Z正方向,深度测试的时候相当于更远了一点;而如果给了个小于0的Factor,相当于原理Z正方向,深度测试时就更近了一点。
总结一句话就是:Offset大于0,Pass对应的模型离摄像机更远;Offset小于0,Pass对应的模型离摄像机更近。
有一种描边效果的实现,其实是利用Offset强行导致Z-Fighting达到描边的目的,不过效果很差,这里简单实验了一版:
//描边Shader
//by:puppet_master
//2017.1.10Shader "ApcShader/OutlineZOffset"
{//属性Properties{_Diffuse("Diffuse", Color) = (1,1,1,1)_OutlineCol("OutlineCol", Color) = (1,0,0,1)_MainTex("Base 2D", 2D) = "white"{}}//子着色器 SubShader{//描边使用两个Pass,第一个Pass渲染背面,但是拉近一点Pass{//剔除正面,只渲染背面Cull Front//拉近一点,为了与后面的Pass重叠Offset -1,-1CGPROGRAM#include "UnityCG.cginc"fixed4 _OutlineCol;float _OutlineFactor;struct v2f{float4 pos : SV_POSITION;};v2f vert(appdata_full v){v2f o;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);return o;}fixed4 frag(v2f i) : SV_Target{//这个Pass直接输出描边颜色return _OutlineCol;}//使用vert函数和frag函数#pragma vertex vert#pragma fragment fragENDCG}//正常着色的Pass,拉远一点Pass{//拉远一点,强行导致上一个Pass渲染的背面与此处发生Z-FightingOffset 3,-1CGPROGRAM //引入头文件#include "Lighting.cginc"//定义Properties中的变量fixed4 _Diffuse;sampler2D _MainTex;//使用了TRANSFROM_TEX宏就需要定义XXX_STfloat4 _MainTex_ST;//定义结构体:vertex shader阶段输出的内容struct v2f{float4 pos : SV_POSITION;float3 worldNormal : TEXCOORD0;float2 uv : TEXCOORD1;};//定义顶点shader,参数直接使用appdata_base(包含position, noramal, texcoord)v2f vert(appdata_base v){v2f o;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);//通过TRANSFORM_TEX宏转化纹理坐标,主要处理了Offset和Tiling的改变,默认时等同于o.uv = v.texcoord.xy;o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);o.worldNormal = mul(v.normal, (float3x3)_World2Object);return o;}//定义片元shaderfixed4 frag(v2f i) : SV_Target{//unity自身的diffuse也是带了环境光,这里我们也增加一下环境光fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;//归一化法线,即使在vert归一化也不行,从vert到frag阶段有差值处理,传入的法线方向并不是vertex shader直接传出的fixed3 worldNormal = normalize(i.worldNormal);//把光照方向归一化fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);//根据半兰伯特模型计算像素的光照信息fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;//最终输出颜色为lambert光强*材质diffuse颜色*光颜色fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;//进行纹理采样fixed4 color = tex2D(_MainTex, i.uv);color.rgb = color.rgb* diffuse;return fixed4(color);}//使用vert函数和frag函数#pragma vertex vert#pragma fragment frag ENDCG}}//前面的Shader失效的话,使用默认的DiffuseFallBack "Diffuse"
}
效果如下:
效果确实不怎么样,圆球的描边很明显会看出Z-Fighting的痕迹,而人物的渲染,帽子直接就不对了。不过这种实现的描边效果计算最为简单,而且不存在边缘不连续时会出现描边的断裂的问题。这种方式,主要是通过把后面的描边Pass向前提前,由于描边Pass只渲染了背面,正常情况下是不可见的,而正常的Pass又向后推了一点,导致重合的部分发生了Z-Fighting。
关于Offset指令,再附上一篇 参考文章。
关闭深度写入的描边效果实现
个人不是很喜欢这种方式,关了深度写入麻烦事太多。还是硬着头皮练习一下吧。上面的描边shader,如果注意观察的话,其实并不仅仅是描物体的外轮廓边,在模型内部(模型面前,不是边缘的部分)也被描上了边,不过并不影响表现。而我们通过关闭深度写入实现的描边效果,则仅仅会描模型的外轮廓。代码如下:
//描边Shader
//by:puppet_master
//2017.1.9Shader "ApcShader/OutlineZWriteOff"
{//属性Properties{_Diffuse("Diffuse", Color) = (1,1,1,1)_OutlineCol("OutlineCol", Color) = (1,0,0,1)_OutlineFactor("OutlineFactor", Range(0,1)) = 0.1_MainTex("Base 2D", 2D) = "white"{}}//子着色器 SubShader{//描边使用两个Pass,第一个pass沿法线挤出一点,只输出描边的颜色Pass{//剔除正面,只渲染背面Cull Front//关闭深度写入ZWrite OffCGPROGRAM#include "UnityCG.cginc"fixed4 _OutlineCol;float _OutlineFactor;struct v2f{float4 pos : SV_POSITION;};v2f vert(appdata_full v){v2f o;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);//将法线方向转换到视空间float3 vnormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);//将视空间法线xy坐标转化到投影空间float2 offset = TransformViewToProjection(vnormal.xy);//在最终投影阶段输出进行偏移操作o.pos.xy += offset * _OutlineFactor;return o;}fixed4 frag(v2f i) : SV_Target{//这个Pass直接输出描边颜色return _OutlineCol;}//使用vert函数和frag函数#pragma vertex vert#pragma fragment fragENDCG}//正常着色的PassPass{CGPROGRAM //引入头文件#include "Lighting.cginc"//定义Properties中的变量fixed4 _Diffuse;sampler2D _MainTex;//使用了TRANSFROM_TEX宏就需要定义XXX_STfloat4 _MainTex_ST;//定义结构体:vertex shader阶段输出的内容struct v2f{float4 pos : SV_POSITION;float3 worldNormal : TEXCOORD0;float2 uv : TEXCOORD1;};//定义顶点shader,参数直接使用appdata_base(包含position, noramal, texcoord)v2f vert(appdata_base v){v2f o;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);//通过TRANSFORM_TEX宏转化纹理坐标,主要处理了Offset和Tiling的改变,默认时等同于o.uv = v.texcoord.xy;o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);o.worldNormal = mul(v.normal, (float3x3)_World2Object);return o;}//定义片元shaderfixed4 frag(v2f i) : SV_Target{//unity自身的diffuse也是带了环境光,这里我们也增加一下环境光fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;//归一化法线,即使在vert归一化也不行,从vert到frag阶段有差值处理,传入的法线方向并不是vertex shader直接传出的fixed3 worldNormal = normalize(i.worldNormal);//把光照方向归一化fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);//根据半兰伯特模型计算像素的光照信息fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;//最终输出颜色为lambert光强*材质diffuse颜色*光颜色fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;//进行纹理采样fixed4 color = tex2D(_MainTex, i.uv);color.rgb = color.rgb* diffuse;return fixed4(color);}//使用vert函数和frag函数#pragma vertex vert#pragma fragment frag ENDCG}}//前面的Shader失效的话,使用默认的DiffuseFallBack "Diffuse"
}
结果如下:
看着效果不错,而且只有最外边有黑色轮廓。然而事情没有这么简单....比如我们加一个天空盒,描边效果就不见鸟!
万恶的ZWrite Off,一定要慎用啊!其实这个问题在上一篇文章中遇到过,简单解释一下,默认的渲染队列是Geometry,而天空盒渲染在Geometry之后,描边部分没有写深度,那么当渲染天空盒的时候,深度小于无穷,深度测试通过,就会把描边的部分覆盖了。如下图,在画完模型本身时描边还是可见的,再画天空盒就覆盖了描边。
通过上一篇文章我们可以知道,调整渲染队列就可以解决这个问题。但是对于同一个渲染队列,又会有别的问题,我们复制一个一样的对象,有一部分重合,重合的部分描边效果又不见鸟!!!
出现这个情况的原因也是没写深度造成描边被覆盖了:对于不透明类型的物体,unity的渲染顺序是从前到后。前面的描边渲染之后,渲染后面的模型,后面的模型在描边部分深度测试仍然通过,就覆盖了。
怎么解决这个问题呢?首先我们需要找到一个靠后渲染的渲染队列,保证我们的描边效果不被其他geomerty类型的对象遮挡;而对于同一渲染队列,我们也希望最前面的物体描边效果不被遮挡,也就是说渲染顺序最好是从后向前。那么,答案已经有了,把渲染队列改成Transparent,unity对于透明类型的物体渲染顺序是从后到前,这就符合我们的需求了。修改后的shader如下,只加了一句话,把队列改成Transparent。
//描边Shader
//by:puppet_master
//2017.1.9Shader "ApcShader/OutlineZWriteOff"
{//属性Properties{_Diffuse("Diffuse", Color) = (1,1,1,1)_OutlineCol("OutlineCol", Color) = (1,0,0,1)_OutlineFactor("OutlineFactor", Range(0,1)) = 0.1_MainTex("Base 2D", 2D) = "white"{}}//子着色器 SubShader{//让渲染队列靠后,并且渲染顺序为从后向前,保证描边效果不被其他对象遮挡。Tags{"Queue" = "Transparent"}//描边使用两个Pass,第一个pass沿法线挤出一点,只输出描边的颜色Pass{//剔除正面,只渲染背面Cull Front//关闭深度写入ZWrite OffCGPROGRAM#include "UnityCG.cginc"fixed4 _OutlineCol;float _OutlineFactor;struct v2f{float4 pos : SV_POSITION;};v2f vert(appdata_full v){v2f o;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);//将法线方向转换到视空间float3 vnormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);//将视空间法线xy坐标转化到投影空间float2 offset = TransformViewToProjection(vnormal.xy);//在最终投影阶段输出进行偏移操作o.pos.xy += offset * _OutlineFactor;return o;}fixed4 frag(v2f i) : SV_Target{//这个Pass直接输出描边颜色return _OutlineCol;}//使用vert函数和frag函数#pragma vertex vert#pragma fragment fragENDCG}//正常着色的PassPass{CGPROGRAM //引入头文件#include "Lighting.cginc"//定义Properties中的变量fixed4 _Diffuse;sampler2D _MainTex;//使用了TRANSFROM_TEX宏就需要定义XXX_STfloat4 _MainTex_ST;//定义结构体:vertex shader阶段输出的内容struct v2f{float4 pos : SV_POSITION;float3 worldNormal : TEXCOORD0;float2 uv : TEXCOORD1;};//定义顶点shader,参数直接使用appdata_base(包含position, noramal, texcoord)v2f vert(appdata_base v){v2f o;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);//通过TRANSFORM_TEX宏转化纹理坐标,主要处理了Offset和Tiling的改变,默认时等同于o.uv = v.texcoord.xy;o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);o.worldNormal = mul(v.normal, (float3x3)_World2Object);return o;}//定义片元shaderfixed4 frag(v2f i) : SV_Target{//unity自身的diffuse也是带了环境光,这里我们也增加一下环境光fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;//归一化法线,即使在vert归一化也不行,从vert到frag阶段有差值处理,传入的法线方向并不是vertex shader直接传出的fixed3 worldNormal = normalize(i.worldNormal);//把光照方向归一化fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);//根据半兰伯特模型计算像素的光照信息fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;//最终输出颜色为lambert光强*材质diffuse颜色*光颜色fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;//进行纹理采样fixed4 color = tex2D(_MainTex, i.uv);color.rgb = color.rgb* diffuse;return fixed4(color);}//使用vert函数和frag函数#pragma vertex vert#pragma fragment frag ENDCG}}//前面的Shader失效的话,使用默认的DiffuseFallBack "Diffuse"
}
这样,我们的ZWrite Off版本的描边效果也OK了。效果如下: 仔细观察一下,虽然腿的部分描边效果正常了,但是手的部分,由于穿插过于密集,还是有一些穿帮的地方。总之,没事不要关闭深度写入...
基于后处理的描边效果
除了Cull Front+法线外拓+Offset实现的一版描边效果还不错,其他的描边效果弊端都比较明显,而法线外拓实现的描边都存在一个问题,如果相邻面的法线方向彼此分离,比如一个正方体,相邻面的法线方向互相垂直,就会造成轮廓间断,如下图所示:
有一种解决方案,就是使用模型空间的顶点方向和法线方向插值得到的值进行外拓,并且需要判断顶点的指向(可以参考《Shader Lab开发实战详解》)。不过个人感觉一般描边效果用于的模型面数较高,法线方向过渡较为平缓,也就不会出现这种断裂的情况。
要放大招啦,当普通shader搞不定的时候,那就用后处理吧!用后处理进行描边的原理,就是把物体的轮廓渲染到一张RenderTexture上,然后把RT按照某种方式再贴回原始场景图。那么,我们要怎么样得到物体的轮廓呢?首先,我们可以渲染一个物体到RT上,可以通过RenderCommond进行处理,不过RenderCommond是Unity5才提供的特性,加上这篇文章实在拖太久了,决定还是先用比较土的办法实现吧。
用一个额外的摄像机:通过增加一个和Main Camera一样的摄像机,通过设置摄像机的LayerMask,将要渲染的对象设置到这个层中,然后将摄像机的Render Target设置为我们设定好的一张Render Texture上,就实现了渲染到RT上的部分,而这张RT由于我们需要在后处理的时候使用,所以我们在之前获得这张RT,Unity为我们提供了一个 OnPreRender函数,这个函数是在渲染之前的回调,我们就可以在这个地方完成RT的渲染。但是还有一个问题,就是我们默认的shader是模型自身设置的shader,而不是纯色的shader,我们要怎么临时换成一个纯色的shader呢?其实Unity也为我们准备好了一个函数:Camera.RenderWithShader,可以让摄像机的本次渲染采用我们设置的shader,这个函数接受两个参数,第一个是需要用的shader,第二个是一个字符串,还记得shader里面经常写的RenderType吗,其实主要就是为了RenderWithShader服务的,如果我们没给RenderType,那么摄像机需要渲染的所有物体都会被替换shader渲染,如果我们给了RenderType,Unity就会去对比目前使用的shader中的RenderType,有的话才去渲染,不匹配的不会被替换shader渲染(关于RenderWithShader,可以参考 这篇文章)。到了这里,我们就能够得到渲染到RT上的纯色的渲染RT了,如下图:
下一步,为了让轮廓出现,我们需要考虑的是怎么让这个轮廓图“胖一点”,回想一下之前的几篇文章,通过模糊效果,就可以让轮廓图胖一些,所谓模糊,就是让当前像素的颜色值从当前像素以及像素周围的几个采样点按照加权平均重新计算,很明显,上面的这张图进行计算时,人边缘部分的颜色肯定会和周围的黑色平均,导致颜色溢出,进而达到发胖的效果。关于模糊,可以参考之前的两篇文章: 简单均值模糊和 高斯模糊,这里就不多做解释了,经过模糊后的结果如下:
然后呢,我们就可以让两者相减一下,让胖的扣去瘦的部分,就留下了轮廓部分:
最后,再把这张RT和我们正常渲染的场景图进行结合,就可以得到基于后处理的描边效果了。最后的结合方式有很多种,最简单的方式是直接叠加,附上后处理的C#及Shader代码,为了更清晰,此处把每个步骤拆成单独的Pass实现了。
C#脚本部分(PostEffect为后处理基类,见 简单屏幕较色):
/********************************************************************FileName: OutlinePostEffect.csDescription: 后处理描边效果Created: 2017/01/12history: 12:1:2017 0:42 by puppet_master
*********************************************************************/
using UnityEngine;
using System.Collections;public class OutlinePostEffect : PostEffectBase
{private Camera mainCam = null;private Camera additionalCam = null;private RenderTexture renderTexture = null;public Shader outlineShader = null;//采样率public float samplerScale = 1;public int downSample = 1;public int iteration = 2;void Awake(){//创建一个和当前相机一致的相机InitAdditionalCam();}private void InitAdditionalCam(){mainCam = GetComponent<Camera>();if (mainCam == null)return;Transform addCamTransform = transform.FindChild("additionalCam");if (addCamTransform != null)DestroyImmediate(addCamTransform.gameObject);GameObject additionalCamObj = new GameObject("additionalCam");additionalCam = additionalCamObj.AddComponent<Camera>();SetAdditionalCam();}private void SetAdditionalCam(){if (additionalCam){additionalCam.transform.parent = mainCam.transform;additionalCam.transform.localPosition = Vector3.zero;additionalCam.transform.localRotation = Quaternion.identity;additionalCam.transform.localScale = Vector3.one;additionalCam.farClipPlane = mainCam.farClipPlane;additionalCam.nearClipPlane = mainCam.nearClipPlane;additionalCam.fieldOfView = mainCam.fieldOfView;additionalCam.backgroundColor = Color.clear;additionalCam.clearFlags = CameraClearFlags.Color;additionalCam.cullingMask = 1 << LayerMask.NameToLayer("Additional");additionalCam.depth = -999; if (renderTexture == null)renderTexture = RenderTexture.GetTemporary(additionalCam.pixelWidth >> downSample, additionalCam.pixelHeight >> downSample, 0);}}void OnEnable(){SetAdditionalCam();additionalCam.enabled = true;}void OnDisable(){additionalCam.enabled = false;}void OnDestroy(){if (renderTexture){RenderTexture.ReleaseTemporary(renderTexture);}DestroyImmediate(additionalCam.gameObject);}//unity提供的在渲染之前的接口,在这一步渲染描边到RTvoid OnPreRender(){//使用OutlinePrepass进行渲染,得到RTif(additionalCam.enabled){//渲染到RT上//首先检查是否需要重设RT,比如屏幕分辨率变化了if (renderTexture != null && (renderTexture.width != Screen.width >> downSample || renderTexture.height != Screen.height >> downSample)){RenderTexture.ReleaseTemporary(renderTexture);renderTexture = RenderTexture.GetTemporary(Screen.width >> downSample, Screen.height >> downSample, 0);}additionalCam.targetTexture = renderTexture;additionalCam.RenderWithShader(outlineShader, "");}}void OnRenderImage(RenderTexture source, RenderTexture destination){if (_Material && renderTexture){//renderTexture.width = 111;//对RT进行Blur处理RenderTexture temp1 = RenderTexture.GetTemporary(source.width >> downSample, source.height >> downSample, 0);RenderTexture temp2 = RenderTexture.GetTemporary(source.width >> downSample, source.height >> downSample, 0);//高斯模糊,两次模糊,横向纵向,使用pass0进行高斯模糊_Material.SetVector("_offsets", new Vector4(0, samplerScale, 0, 0));Graphics.Blit(renderTexture, temp1, _Material, 0);_Material.SetVector("_offsets", new Vector4(samplerScale, 0, 0, 0));Graphics.Blit(temp1, temp2, _Material, 0);//如果有叠加再进行迭代模糊处理for(int i = 0; i < iteration; i++){_Material.SetVector("_offsets", new Vector4(0, samplerScale, 0, 0));Graphics.Blit(temp2, temp1, _Material, 0);_Material.SetVector("_offsets", new Vector4(samplerScale, 0, 0, 0));Graphics.Blit(temp1, temp2, _Material, 0);}//用模糊图和原始图计算出轮廓图_Material.SetTexture("_BlurTex", temp2);Graphics.Blit(renderTexture, temp1, _Material, 1);//轮廓图和场景图叠加_Material.SetTexture("_BlurTex", temp1);Graphics.Blit(source, destination, _Material, 2);RenderTexture.ReleaseTemporary(temp1);RenderTexture.ReleaseTemporary(temp2);}else{Graphics.Blit(source, destination);}}}
Prepass Shader(用于把模型渲染到RT的shader):
//描边Shader(输出纯色)
//by:puppet_master
//2017.1.12Shader "ApcShader/OutlinePrePass"
{//子着色器 SubShader{//描边使用两个Pass,第一个pass沿法线挤出一点,只输出描边的颜色Pass{ CGPROGRAM#include "UnityCG.cginc"fixed4 _OutlineCol;struct v2f{float4 pos : SV_POSITION;};v2f vert(appdata_full v){v2f o;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);return o;}fixed4 frag(v2f i) : SV_Target{//这个Pass直接输出描边颜色return fixed4(1,0,0,1);}//使用vert函数和frag函数#pragma vertex vert#pragma fragment fragENDCG}}
}
后处理shader(三个Pass,模糊处理,抠出轮廓,最终混合):
//后处理描边Shader
//by:puppet_master
//2017.1.12Shader "Custom/OutLinePostEffect" {Properties{_MainTex("Base (RGB)", 2D) = "white" {}_BlurTex("Blur", 2D) = "white"{}}CGINCLUDE#include "UnityCG.cginc"//用于剔除中心留下轮廓struct v2f_cull{float4 pos : SV_POSITION;float2 uv : TEXCOORD0;};//用于模糊struct v2f_blur{float4 pos : SV_POSITION;float2 uv : TEXCOORD0;float4 uv01 : TEXCOORD1;float4 uv23 : TEXCOORD2;float4 uv45 : TEXCOORD3;};//用于最后叠加struct v2f_add{float4 pos : SV_POSITION;float2 uv : TEXCOORD0;float2 uv1 : TEXCOORD1;};sampler2D _MainTex;float4 _MainTex_TexelSize;sampler2D _BlurTex;float4 _BlurTex_TexelSize;float4 _offsets;//Blur图和原图进行相减获得轮廓v2f_cull vert_cull(appdata_img v){v2f_cull o;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);o.uv = v.texcoord.xy;//dx中纹理从左上角为初始坐标,需要反向
#if UNITY_UV_STARTS_AT_TOPif (_MainTex_TexelSize.y < 0)o.uv.y = 1 - o.uv.y;
#endif return o;}fixed4 frag_cull(v2f_cull i) : SV_Target{fixed4 colorMain = tex2D(_MainTex, i.uv);fixed4 colorBlur = tex2D(_BlurTex, i.uv);//最后的颜色是_BlurTex - _MainTex,周围0-0=0,黑色;边框部分为描边颜色-0=描边颜色;中间部分为描边颜色-描边颜色=0。最终输出只有边框//return fixed4((colorBlur - colorMain).rgb, 1);return colorBlur - colorMain;}//高斯模糊 vert shader(之前的文章有详细注释,此处也可以用BoxBlur,更省一点)v2f_blur vert_blur(appdata_img v){v2f_blur o;_offsets *= _MainTex_TexelSize.xyxy;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);o.uv = v.texcoord.xy;o.uv01 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1);o.uv23 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1) * 2.0;o.uv45 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1) * 3.0;return o;}//高斯模糊 pixel shaderfixed4 frag_blur(v2f_blur i) : SV_Target{fixed4 color = fixed4(0,0,0,0);color += 0.40 * tex2D(_MainTex, i.uv);color += 0.15 * tex2D(_MainTex, i.uv01.xy);color += 0.15 * tex2D(_MainTex, i.uv01.zw);color += 0.10 * tex2D(_MainTex, i.uv23.xy);color += 0.10 * tex2D(_MainTex, i.uv23.zw);color += 0.05 * tex2D(_MainTex, i.uv45.xy);color += 0.05 * tex2D(_MainTex, i.uv45.zw);return color;}//最终叠加 vertex shaderv2f_add vert_add(appdata_img v){v2f_add o;//mvp矩阵变换o.pos = mul(UNITY_MATRIX_MVP, v.vertex);//uv坐标传递o.uv.xy = v.texcoord.xy;o.uv1.xy = o.uv.xy;
#if UNITY_UV_STARTS_AT_TOPif (_MainTex_TexelSize.y < 0)o.uv.y = 1 - o.uv.y;
#endif return o;}fixed4 frag_add(v2f_add i) : SV_Target{//取原始场景图片进行采样fixed4 ori = tex2D(_MainTex, i.uv1);//取得到的轮廓图片进行采样fixed4 blur = tex2D(_BlurTex, i.uv);//输出:直接叠加fixed4 final = ori + blur;return final;}ENDCGSubShader{//pass 0: 高斯模糊Pass{ZTest OffCull OffZWrite OffFog{ Mode Off }CGPROGRAM#pragma vertex vert_blur#pragma fragment frag_blurENDCG}//pass 1: 剔除中心部分 Pass{ZTest OffCull OffZWrite OffFog{ Mode Off }CGPROGRAM#pragma vertex vert_cull#pragma fragment frag_cullENDCG}//pass 2: 最终叠加Pass{ZTest OffCull OffZWrite OffFog{ Mode Off }CGPROGRAM#pragma vertex vert_add#pragma fragment frag_addENDCG}}
}
描边结果(把要描边的对象放到Additional层中):
换个颜色,加大一下模糊程度:
这种类型的shader其实跟最上面的剑灵中的描边效果很像,尤其是第一张图,描边并不是一个硬边,而是一个柔和的,渐变的边缘效果,在最靠近模型的部分颜色最强,越向外,描边效果逐渐减弱。个人最喜欢这个描边效果,不过这个后处理是真的费啊,强烈不推荐移动上使用,一般带模糊的效果,都要慎用,超级费(然而本人超级喜欢的效果基本都是需要模糊来实现的,比如景深,Bloom,毛玻璃等等,效果永远是跟性能成反比的)。这个后处理还有一个问题,就是不能遮挡,因为渲染到RT之后,再通过模糊减去原图,只会留下整体的边界,而不会把中间重叠的部分留下。暂时没想到什么好办法,如果哪位热心人有好点子,还望不吝赐教。
下面再调整一下这个shader,首先,我们把这个描边效果换成一个硬边,跟我们最早通过增加个外拓Pass达到一样的效果;然后就是让我们输出的颜色是我们自己想要的颜色,因为上面的实现实际上是一种叠加,并不是我们原始的写在Prepass那个shader里面输出的颜色,而且那个是写死在shader里的,不能调整。我们希望给一个可调整的参数;最后,由于上面shader中最后的两个Pass其实是可以合并成一个Pass来实现的,通过增加一个贴图槽,这样就可以省下一次全屏Pass。
C#部分:
C#部分:
/********************************************************************FileName: OutlinePostEffect.csDescription: 后处理描边效果Created: 2017/01/12history: 12:1:2017 0:42 by puppet_master
*********************************************************************/
using UnityEngine;
using System.Collections;public class OutlinePostEffectX : PostEffectBase
{private Camera mainCam = null;private Camera additionalCam = null;private RenderTexture renderTexture = null;public Shader outlineShader = null;//采样率public float samplerScale = 0.01f;public int downSample = 0;public int iteration = 0;public Color outlineColor = Color.green;void Awake(){//创建一个和当前相机一致的相机InitAdditionalCam();}private void InitAdditionalCam(){mainCam = GetComponent<Camera>();if (mainCam == null)return;Transform addCamTransform = transform.FindChild("additionalCam");if (addCamTransform != null)DestroyImmediate(addCamTransform.gameObject);GameObject additionalCamObj = new GameObject("additionalCam");additionalCam = additionalCamObj.AddComponent<Camera>();SetAdditionalCam();}private void SetAdditionalCam(){if (additionalCam){additionalCam.transform.parent = mainCam.transform;additionalCam.transform.localPosition = Vector3.zero;additionalCam.transform.localRotation = Quaternion.identity;additionalCam.transform.localScale = Vector3.one;additionalCam.farClipPlane = mainCam.farClipPlane;additionalCam.nearClipPlane = mainCam.nearClipPlane;additionalCam.fieldOfView = mainCam.fieldOfView;additionalCam.backgroundColor = Color.clear;additionalCam.clearFlags = CameraClearFlags.Color;additionalCam.cullingMask = 1 << LayerMask.NameToLayer("Additional");additionalCam.depth = -999;if (renderTexture == null)renderTexture = RenderTexture.GetTemporary(additionalCam.pixelWidth >> downSample, additionalCam.pixelHeight >> downSample, 0);}}void OnEnable(){SetAdditionalCam();additionalCam.enabled = true;}void OnDisable(){additionalCam.enabled = false;}void OnDestroy(){if (renderTexture){RenderTexture.ReleaseTemporary(renderTexture);}DestroyImmediate(additionalCam.gameObject);}//unity提供的在渲染之前的接口,在这一步渲染描边到RTvoid OnPreRender(){//使用OutlinePrepass进行渲染,得到RTif (additionalCam.enabled){//渲染到RT上//首先检查是否需要重设RT,比如屏幕分辨率变化了if (renderTexture != null && (renderTexture.width != Screen.width >> downSample || renderTexture.height != Screen.height >> downSample)){RenderTexture.ReleaseTemporary(renderTexture);renderTexture = RenderTexture.GetTemporary(Screen.width >> downSample, Screen.height >> downSample, 0);}additionalCam.targetTexture = renderTexture;additionalCam.RenderWithShader(outlineShader, "");}}void OnRenderImage(RenderTexture source, RenderTexture destination){if (_Material && renderTexture){//renderTexture.width = 111;//对RT进行Blur处理RenderTexture temp1 = RenderTexture.GetTemporary(source.width >> downSample, source.height >> downSample, 0);RenderTexture temp2 = RenderTexture.GetTemporary(source.width >> downSample, source.height >> downSample, 0);//高斯模糊,两次模糊,横向纵向,使用pass0进行高斯模糊_Material.SetVector("_offsets", new Vector4(0, samplerScale, 0, 0));Graphics.Blit(renderTexture, temp1, _Material, 0);_Material.SetVector("_offsets", new Vector4(samplerScale, 0, 0, 0));Graphics.Blit(temp1, temp2, _Material, 0);//如果有叠加再进行迭代模糊处理for (int i = 0; i < iteration; i++){_Material.SetVector("_offsets", new Vector4(0, samplerScale, 0, 0));Graphics.Blit(temp2, temp1, _Material, 0);_Material.SetVector("_offsets", new Vector4(samplerScale, 0, 0, 0));Graphics.Blit(temp1, temp2, _Material, 0);}//用模糊图和原始图计算出轮廓图,并和场景图叠加,节省一个Pass_Material.SetTexture("_OriTex", renderTexture);_Material.SetTexture("_BlurTex", temp2);_Material.SetColor("_OutlineColor", outlineColor);Graphics.Blit(source, destination, _Material, 1);RenderTexture.ReleaseTemporary(temp1);RenderTexture.ReleaseTemporary(temp2);}else{Graphics.Blit(source, destination);}}
}
描边Shader部分:
//后处理描边Shader
//by:puppet_master
//2017.1.12Shader "Custom/OutLinePostEffectX" {Properties{_MainTex("Base (RGB)", 2D) = "white" {}_BlurTex("Blur", 2D) = "white"{}_OriTex("Ori", 2D) = "white"{}}CGINCLUDE#include "UnityCG.cginc"//用于模糊struct v2f_blur{float4 pos : SV_POSITION;float2 uv : TEXCOORD0;float4 uv01 : TEXCOORD1;float4 uv23 : TEXCOORD2;float4 uv45 : TEXCOORD3;};//用于最后叠加struct v2f_add{float4 pos : SV_POSITION;float2 uv : TEXCOORD0;float2 uv1 : TEXCOORD1;float2 uv2 : TEXCOORD2;};sampler2D _MainTex;float4 _MainTex_TexelSize;sampler2D _BlurTex;float4 _BlurTex_TexelSize;sampler2D _OriTex;float4 _OriTex_TexelSize;float4 _offsets;fixed4 _OutlineColor;//高斯模糊 vert shader(之前的文章有详细注释,此处也可以用BoxBlur,更省一点)v2f_blur vert_blur(appdata_img v){v2f_blur o;_offsets *= _MainTex_TexelSize.xyxy;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);o.uv = v.texcoord.xy;o.uv01 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1);o.uv23 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1) * 2.0;o.uv45 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1) * 3.0;return o;}//高斯模糊 pixel shaderfixed4 frag_blur(v2f_blur i) : SV_Target{fixed4 color = fixed4(0,0,0,0);color += 0.40 * tex2D(_MainTex, i.uv);color += 0.15 * tex2D(_MainTex, i.uv01.xy);color += 0.15 * tex2D(_MainTex, i.uv01.zw);color += 0.10 * tex2D(_MainTex, i.uv23.xy);color += 0.10 * tex2D(_MainTex, i.uv23.zw);color += 0.05 * tex2D(_MainTex, i.uv45.xy);color += 0.05 * tex2D(_MainTex, i.uv45.zw);return color;}//最终叠加 vertex shaderv2f_add vert_add(appdata_img v){v2f_add o;//mvp矩阵变换o.pos = mul(UNITY_MATRIX_MVP, v.vertex);//uv坐标传递o.uv.xy = v.texcoord.xy;o.uv1.xy = o.uv.xy;o.uv2.xy = o.uv.xy;
#if UNITY_UV_STARTS_AT_TOP//if (_OriTex_TexelSize.y < 0)o.uv.y = 1 - o.uv.y;o.uv2.y = 1 - o.uv2.y;
#endif return o;}fixed4 frag_add(v2f_add i) : SV_Target{//取原始场景纹理进行采样fixed4 scene = tex2D(_MainTex, i.uv1);//return scene;//对blur后的纹理进行采样fixed4 blur = tex2D(_BlurTex, i.uv);//对blur之前的rt进行采样fixed4 ori = tex2D(_OriTex, i.uv);//轮廓是_BlurTex - _OriTex,周围0-0=0,黑色;边框部分为描边颜色-0=描边颜色;中间部分为描边颜色-描边颜色=0。最终输出只有边框fixed4 outline = blur - ori;//输出:blur部分为0的地方返回原始图像,否则为0,然后叠加描边fixed4 final = scene * (1 - all(outline.rgb)) + _OutlineColor * any(outline.rgb);//0.01,1,1return final;}ENDCGSubShader{//pass 0: 高斯模糊Pass{ZTest OffCull OffZWrite OffFog{ Mode Off }CGPROGRAM#pragma vertex vert_blur#pragma fragment frag_blurENDCG}//pass 1: 剔除中心部分以及最后和场景图叠加Pass{ZTest OffCull OffZWrite OffFog{ Mode Off }CGPROGRAM#pragma vertex vert_add#pragma fragment frag_addENDCG}}
}
结果如下:
总结
本篇文章主要研究了一下描边效果的几种类型(边缘发光型,硬描边,柔和边缘的描边)以及实现方式(边缘光,深度偏移,法线外拓,后处理):几种描边效果各有各的优点和缺点,最省的是边缘光效果,深度偏移+法线外拓的方式基本可以满足真正的描边需求,而后处理的效果比较好,但是如果能只增加一个pass就能得到的效果,就没有必要用后处理了,尤其是移动平台上。最后推荐一个后处理的插件:Highting System,里面有各种类型的描边效果,不过这个插件也是通过后处理来实现的(使用了RenderCommand+后处理),也是比较费。插件中模糊的描边效果:
硬边的描边效果:
补充:关于CommandBuffer实现的后处理描边,可以参考 Command Buffer的使用这篇文章