Unity Shader-描边效果

简介


描边效果是游戏里面非常常用的一种效果,一般是为了凸显游戏中的某个对象,会给对象增加一个描边效果。最近又跑回去玩了玩《剑灵》,虽然出了三年了,感觉在现在的网游里面画面仍然算很好的了,剑灵里面走近或者选中NPC的一瞬间,NPC就会出现描边效果,不过这个描边效果是渐变的,会很快减弱最后消失(抓了好久才抓住一张图....)

还有就是最常见的LOL中的塔,我们把鼠标移动到塔上,就会有很明显的描边效果:


简单描边效果的原理


描边效果有几种实现方式。其实边缘光效果与描边效果有些类似,适当调整边缘光效果,其实也可以达到凸显要表达的对象的意思。边缘光的实现最为简单,只是在计算的时候增加了一次计算法线方向与视线方向的夹角计算,用1减去结果作为系数乘以一个边缘光颜色就达到了边缘光的效果,是性能最好的一种方法,关于边缘光效果,可以参考一下之前的一篇文章: 边缘光效果。边缘光的效果如下图所示:

原始模型渲染:

使用了边缘光的效果:


边缘光效果虽然简单,但是有很大的局限性,边缘光效果只是在当前模型本身的光照计算时调整了边缘位置的颜色值,并没有达到真正的“描边”(当然,有时候我们就是想要这种边缘光的效果),而我们希望的描边效果,一般都是在正常模型的渲染状态下,在模型外面扩展出一个描边的效果。既然要让模型的形状有所改变(向外拓一点),那么肯定就和vertex shader有关系了。而我们的描边效果,肯定就是要让模型更“胖”一点,能够把我们原来的大小包裹住;微观一点来看,一个面,如果我们让它向外拓展,而我们指的外,也就是这个面的法线所指向的方向,那么就让这个面朝着法线的方向平移一点;再微观一点来看,对于顶点来说,也就是我们的vertex shader真正要写的内容了,我们正常计算顶点的时候,传入的vertex会经过MVP变换,最终传递给fragment shader,那么我们就可以在这一步让顶点沿着法线的方向稍微平移一些。我们在描边后,描边这一次渲染的边缘其实是没有办法和我们正常的模型进行区分的,为了解决这个问题,就需要用两个Pass来渲染,第一个Pass渲染描边的效果,进行外拓,而第二个Pass进行原本效果的渲染,这样,后面显示的就是稍微“胖”一点的模型,然后正常的模型贴在上面,把中间的部分挡住,边缘挡不住就露出了描边的部分了。

开启深度写入,剔除正面的描边效果


知道了原理,我们来考虑一下外拓的实现,我们可以在vertex阶段获得顶点的坐标,并且有法线的坐标,最直接的方式就是直接用顶点坐标+法线方向*描边粗细参数,然后用这个偏移的坐标值再进行MVP变换;但是这样做有一个弊端,其实就是我们透视的近大远小的问题,模型上离相机近的地方描边效果较粗,而远的地方描边效果较细。一种解决的方案是先进行MPV变换,变换完之后再去按照法线方向调整外拓。代码如下:
//描边Shader
//by:puppet_master
//2017.1.5

Shader "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 Front
			
			CGPROGRAM
			#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 frag
			ENDCG
		}
		
		//正常着色的Pass
		Pass
		{
			CGPROGRAM	
		
			//引入头文件
			#include "Lighting.cginc"
			//定义Properties中的变量
			fixed4 _Diffuse;
			sampler2D _MainTex;
			//使用了TRANSFROM_TEX宏就需要定义XXX_ST
			float4 _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;
			}

			//定义片元shader
			fixed4 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失效的话,使用默认的Diffuse
	FallBack "Diffuse"
}
开启了描边效果:

原始模型渲染采用了 半兰伯特Diffuse进行渲染,主要是前面多了一个描边的Pass。这个Pass里,我们没有关闭深度写入,主要是开启了模型的正面剔除,这样,在这个Pass渲染的时候,就只会渲染模型的背面,让背面向外拓展一下,既不会影响什么,并且背面一般都在正面的后面,一般情况下不会遮挡住正面,正好符合我们后面的部分外拓的需求。这个的主要优点是没有关闭深度写入,因为关闭深度写入,引入的其他问题实在是太多了。
附上一张进行了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,1
			CGPROGRAM
			#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坐标转
  • 79
    点赞
  • 258
    收藏
    觉得还不错? 一键收藏
  • 28
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 28
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值