Unity Shader-兰伯特光照模型与Diffuse Shader

简介


学了一段时间shader,然而一直在玩后处理,如今终于下定决心钻研一下真正的带光照的shader。从Diffuse到Specular。一个游戏的画面好坏,很大程度上取决于光照和贴图。现实世界中,咱们之因此能看见东西,是由于他们要么反射了光源发出的光,要么是自身可以发光。而在游戏世界中,若是没有了光,咱们虽然能够直接根据贴图显示物体的材质,可是少了不少细节光影效果,游戏显得不真实。可是,真实的光照计算是一个很是复杂的过程,对于游戏这种至少30FPS的程序来讲是彻底不可能的,因此咱们必需要使用一种近似的光照算法,来模拟光照效果。本篇文章就来学习一下基本的光照模型以及其在unity下的shader实现。

漫反射和镜面反射


咱们观察世界是由于有光进入咱们的眼睛,光在世界中主要有反射和折射两种属性,当光照在某种介质表面时,一部分光发生反射,另外一部分光进入介质,发生折射,也有转化为其余能量的光。本篇文章只讨论反射,折射等其余现象之后再学习。光的反射分为两种,漫反射和镜面反射。

漫反射,是投射在粗糙表面上的光向各个方向反射的现象。当一束平行的入射光线射到粗糙的表面时,表面会把光线向着四面八方反射,因此入射线虽然互相平行,因为各点的法线方向不一致,形成反射光线向不一样的方向无规则地反射,这种反射称之为“漫反射”或“漫射”。这种反射的光称为漫射光。不少物体,如植物、墙壁、衣服等,其表面粗看起来彷佛是平滑,但用放大镜仔细观察,就会看到其表面是凹凸不平的,因此原本是平行的太阳光被这些表面反射后,弥漫地射向不一样方向。

镜面反射,是指若反射面比较光滑,当平行入射的光线射到这个反射面时,仍会平行地向一个方向反射出来,这种反射就属于镜面反射,其反射波的方向与反射平面的法线夹角(反射角),与入射波方向与该反射平面法线的夹角(入射角)相等,且入射波、反射波,及平面法线同处于一个平面内。
算法


兰伯特光照模型


先来学习一个最简单的光照模型,兰伯特光照模型。兰伯特光照模型是目前最简单通用的模拟漫反射的光照模型,定义以下:模型表面的明亮度直接取决于光线向量(light vector)和表面法线(normal)两个向量将夹角的余弦值。光线向量是指这个点到光从哪一个方向射入,表面法线则定义了这个表面的朝向。


若是漫反射光强设置为Diffuse,入射光光强为I,光方向和法线夹角为θ,那么兰伯特光照模型能够用下面的公式表示:Diffuse = I * cosθ
进一步地,咱们能够经过点乘来求得两个方向向量之间的夹角,入射光方向设置为L,法线方向设置为N,若是光方向向量和法线方向向量都为单位向量(这就是为何咱们在写shader的时候须要normalize操做的缘由),那么它们之间的夹角余弦值就能够表示为:cosθ = dot(L,N),最终漫反射光强公式,也就是兰伯特光照模型能够表示为:Diffuse = I  * dot(L,N)


逐顶点计算着色shader


咱们在shader中须要计算输出的颜色,逐顶点着色也就是说咱们的计算主要放在了vertex shader中,根据顶点来计算,每一个顶点中计算出了该点的颜色,直接做为vertex shader的输出,pixel(fragment) shader的输入,当到达pixel阶段时,直接输出顶点shader的结果。好比一个三角形面片,在vertex阶段,分别计算了每一个顶点的颜色值,在pixel阶段时,这个面片通过投影,最终显示在屏幕上的像素,会根据该像素周围的顶点来插值计算像素的最终颜色,这种着色方式也叫作 高洛德着色

下面看一下unity shader实现的逐顶点着色:
Shader "ApcShader/DiffusePerVetex"
{
	//属性
	Properties{
		_Diffuse("Diffuse", Color) = (1,1,1,1)
	}

	//子着色器	
	SubShader
	{
		Pass
		{
			//定义Tags
			Tags{ "RenderType" = "Opaque" }

			CGPROGRAM
			//引入头文件
	        #include "Lighting.cginc"
			//定义Properties中的变量
			fixed4 _Diffuse;
			//定义结构体:应用阶段到vertex shader阶段的数据,若是定义了
			struct a2v
			{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			//定义结构体:vertex shader阶段输出的内容
			struct v2f
			{
				float4 pos : SV_POSITION;
				fixed4 color : COLOR;
			};

			//定义顶点shader
			v2f vert(a2v v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				//把法线转化到世界空间
				float3 worldNormal = mul(v.normal, (float3x3)_World2Object);
				//归一化法线
				worldNormal = normalize(worldNormal);
				//把光照方向归一化
				fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				//根据兰伯特模型计算顶点的光照信息,dot可能有负值,小于0的部分能够理解为看不见,直接取0
				fixed3 lambert = max(0.0, dot(worldNormal, worldLightDir));
				//最终输出颜色为lambert光强*材质diffuse颜色*光颜色
				o.color = fixed4(lambert * _Diffuse.xyz * _LightColor0.xyz, 1.0);
				return o;
			}

			//定义片元shader
			fixed4 frag(v2f i) : SV_Target
			{
				return i.color;
			}

			//使用vert函数和frag函数
			#pragma vertex vert
			#pragma fragment frag	

			ENDCG
		}

	}
	//前面的Shader失效的话,使用默认的Diffuse
	FallBack "Diffuse"
}
咱们放置两个基本几何体,看一下shader的效果:


逐像素计算着色shader


逐像素计算时,咱们的主要计算放到了pixel shader里,在vertex shader阶段只是进行了基本的顶点变换操做,以及顶点的法线转化到世界空间的操做,而后将转化后的法线做为参数传递给pixel shader。其余的计算都放到了pixel shader阶段,这样,针对每一个像素,咱们均可以来计算这个像素的光照状况,而不是像逐顶点计算时,先计算好顶点的颜色,而后差值获得中间的像素颜色。这种逐像素着色的方式也叫做 冯氏着色(注意不是冯氏光照模型,不要搞混呦)。

下面看一下unity shader实现的逐像素着色:
Shader "ApcShader/DiffusePerPixel"
{
	//属性
	Properties{
		_Diffuse("Diffuse", Color) = (1,1,1,1)
	}

	//子着色器	
	SubShader
	{
		Pass
		{
			//定义Tags
			Tags{ "RenderType" = "Opaque" }

			CGPROGRAM
			//引入头文件
			#include "Lighting.cginc"
			//定义Properties中的变量
			fixed4 _Diffuse;
			//定义结构体:应用阶段到vertex shader阶段的数据
			struct a2v
			{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			//定义结构体:vertex shader阶段输出的内容
			struct v2f
			{
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
			};

			//定义顶点shader
			v2f vert(a2v v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				//把法线转化到世界空间
				o.worldNormal = mul(v.normal, (float3x3)_World2Object);
				return o;
			}

			//定义片元shader
			fixed4 frag(v2f i) : SV_Target
			{
				//归一化法线,即便在vert归一化也不行,从vert到frag阶段有差值处理,传入的法线方向并非vertex shader直接传出的
				fixed3 worldNormal = normalize(i.worldNormal);
				//把光照方向归一化
				fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				//根据兰伯特模型计算像素的光照信息,小于0的部分理解为看不见,置为0
				fixed3 lambert = max(0.0, dot(worldNormal, worldLightDir));
				//最终输出颜色为lambert光强*材质diffuse颜色*光颜色
				fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz;
				return fixed4(diffuse, 1.0);
			}

			//使用vert函数和frag函数
			#pragma vertex vert
			#pragma fragment frag	

			ENDCG
		}

	}
		//前面的Shader失效的话,使用默认的Diffuse
		FallBack "Diffuse"
}
仍是一个立方体和一个圆柱体,采用了逐像素着色后的结果:



从vertex阶段到fragment阶段发生了什么


咱们能够看一下逐顶点着色和逐像素着色的结果对比:

对于正方体,只有单个面,没有特别明显的差异,可是对于圆柱体,就能够看出一些差异了,逐顶点着色的圆柱体能够看出线条状的轮廓,其实每一个线条都是由两个三角形面片组成的长方形面片。

为何逐像素计算会获得更好的效果,由于咱们逐像素取的光照的方向是一致的,法线方向也是经过上一步的vertex shader传递过来的,若是像素和顶点对应了的话,那不是每一个像素的计算结果都会同样呢?然而,其实像素和顶点是不对应的,这个就是传说中的渲染流水线了,在顶点阶段计算的结果,并非直接传递给像素着色器的,而是通过了一系列的插值计算,咱们从vertex shader传递过来的法线方向,只表明了这一个顶点的顶点法线方向,而到了pixel阶段,这个像素所对应的法线等参数至关于其周围几个顶点进行插值后的结果。咱们用这一个像素点对应的法线方向与光照方向进行计算,就能够得到该像素点在光照条件下的颜色值,而不是先计算好颜色再插值获得结果。







半兰伯特光照模型


实现了逐顶点和逐像素的兰伯特光照模型,咱们再来看一下兰伯特光照模型的变种--半兰伯特光照。通过上面的对比,逐像素光照计算会得到更好的效果,因此咱们下面就采用逐像素的方式来实现半兰伯特光照模型。

上面的shader计算光照的时候,咱们计算法线方向和光方向的点乘值时,获得的结果有多是负数,而兰伯特光照模型对于该状况的处理是,dot值为负数,说明该点不会受到光的照射,因此对于该光源,该点无光,直接使用max(0,diffuse)来将不该该受光的位置全都置为黑色。虽然听起来颇有道理的样子,然而这种并好看。



然而,实际上,咱们在现实世界中常常会发现,即便咱们让一个物体不被光直接照射,咱们也可能会看到物体,虽然亮度不是很高,这实际上是因为物体之间光的反射形成的,也就是间接光照,间接光照是更高级的渲染,好比光线追踪算法等。可是在实时图形学,咱们大部分状况是经过一个环境光(Ambient Light)统一表明了间接光,这样,即便在没有光的时候,咱们也能够看见物体。

兰伯特光照出来的时候,貌似尚未这么高科技的技术,因此呢,有人就想到了一个取巧的技术(听说是《半条命》),既保证了兰伯特模型计算出来的光照结果大于0,又总体提高了亮度,使非直接受光面不是单纯的置为黑色。这是一个在图形学领域常常有的变换,区间转化,从(-1,1)转化到(0,1),若是不考虑无心义的负值,也能够说成从(0,1)转化到了(0.5,1)。方法很简单,乘以0.5再加上0.5。这样,本来亮度为1的地方,乘以0.5变成了0.5,加上0.5就又成了1,而本来光照强度为0的地方,就变成了0.5,本来为负数的地方,也能保证为大于0了。半兰伯特光照这种区间转化的原理图以下所示:


下面看一下逐像素计算的半兰伯特光照shader,比兰伯特光照的只是将法线向量与光方向向量的点乘结果用一种更好的方式区间转化到了(0,1)区间:
Shader "ApcShader/HalfLambert"
{
	//属性
	Properties{
		_Diffuse("Diffuse", Color) = (1,1,1,1)
	}

	//子着色器	
	SubShader
	{
		Pass
		{
			//定义Tags
			Tags{ "RenderType" = "Opaque" }

			CGPROGRAM
			//引入头文件
			#include "Lighting.cginc"
			//定义Properties中的变量
			fixed4 _Diffuse;
			//定义结构体:应用阶段到vertex shader阶段的数据
			struct a2v
			{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			//定义结构体:vertex shader阶段输出的内容
			struct v2f
			{
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
			};

			//定义顶点shader
			v2f vert(a2v v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				//把法线转化到世界空间
				o.worldNormal = mul(v.normal, (float3x3)_World2Object);
				return o;
			}

			//定义片元shader
			fixed4 frag(v2f i) : SV_Target
			{
				//归一化法线,即便在vert归一化也不行,从vert到frag阶段有差值处理,传入的法线方向并非vertex shader直接传出的
				fixed3 worldNormal = normalize(i.worldNormal);
				//把光照方向归一化
				fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				//半兰伯特光照,将原来(-1,1)区间的光照条件转化到了(0,1)区间,既保证告终果的正确,又总体提高了亮度,保证非受光面也能有光,而不是全黑
				fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
				//最终输出颜色为lambert光强*材质diffuse颜色*光颜色
				fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz;
				return fixed4(diffuse, 1.0);
			}

			//使用vert函数和frag函数
			#pragma vertex vert
			#pragma fragment frag	

			ENDCG
		}

	}
	//前面的Shader失效的话,使用默认的Diffuse
	FallBack "Diffuse"
}
看一下兰伯特光照模型和半兰伯特光照模型的对比:


因此,正如某图形学大牛说的,图形学这个,没有什么道理,只要看起来好看,那就好了!


带有纹理的半兰伯特光照shader


光照模型再牛,没有纹理也是难看,因此,咱们修改一下shader,加上纹理,而后找个帅帅哒模型穿上瞧一瞧。
光照计算主要放在Fragment shader中:
Shader "ApcShader/DiffuseWithTex"
{
	//属性
	Properties{
		_Diffuse("Diffuse", Color) = (1,1,1,1)
		_MainTex("Base 2D", 2D) = "white"{}
	}

	//子着色器	
	SubShader
	{
		Pass
		{
			//定义Tags
			Tags{ "RenderType" = "Opaque" }

			CGPROGRAM
			//引入头文件
			#include "Lighting.cginc"
			//定义Properties中的变量
			fixed4 _Diffuse;
			sampler2D _MainTex;
			//使用了TRANSFROM_TEX宏就须要定义XXX_ST
			float4 _MainTex_ST;

			//定义结构体:应用阶段到vertex shader阶段的数据
			struct a2v
			{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				float4 texcoord : TEXCOORD0;
			};
			//定义结构体:vertex shader阶段输出的内容
			struct v2f
			{
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
				//转化纹理坐标
				float2 uv : TEXCOORD1;
			};

			//定义顶点shader
			v2f vert(a2v v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				//把法线转化到世界空间
				o.worldNormal = mul(v.normal, (float3x3)_World2Object);
				//经过TRANSFORM_TEX宏转化纹理坐标,主要处理了Offset和Tiling的改变,默认时等同于o.uv = v.texcoord.xy;
				o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
				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);
				return fixed4(diffuse * color.rgb, 1.0);
			}

			//使用vert函数和frag函数
			#pragma vertex vert
			#pragma fragment frag	

			ENDCG
		}

	}
		//前面的Shader失效的话,使用默认的Diffuse
		FallBack "Diffuse"
}

光照计算主要放在vertex shader中:
Shader "ApcShader/DiffuseWithTexX"
{
	//属性
	Properties{
		_Diffuse("Diffuse", Color) = (1,1,1,1)
		_MainTex("Base 2D", 2D) = "white"{}
	}

	//子着色器	
	SubShader
	{
		Pass
		{
			//定义Tags
			Tags{ "RenderType" = "Opaque" }

			CGPROGRAM
			//引入头文件
	        #include "Lighting.cginc"
			//定义Properties中的变量
			fixed4 _Diffuse;
			sampler2D _MainTex;
			//使用了TRANSFROM_TEX宏就须要定义XXX_ST
			float4 _MainTex_ST;
			//定义结构体:应用阶段到vertex shader阶段的数据,若是定义了
			struct a2v
			{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				float4 texcoord : TEXCOORD0;
			};
			//定义结构体:vertex shader阶段输出的内容
			struct v2f
			{
				float4 pos : SV_POSITION;
				fixed4 color : COLOR;
				//转化纹理坐标
				float2 uv : TEXCOORD1;
			};

			//定义顶点shader
			v2f vert(a2v v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				//unity自身的diffuse也是带了环境光,这里咱们也增长一下环境光
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;
				//把法线转化到世界空间
				float3 worldNormal = mul(v.normal, (float3x3)_World2Object);
				//归一化法线
				worldNormal = normalize(worldNormal);
				//把光照方向归一化
				fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				//根据兰伯特模型计算顶点的光照信息,dot可能有负值,小于0的部分能够理解为看不见,直接取0
				fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
				//最终输出颜色为lambert光强*材质diffuse颜色*光颜色
				o.color = fixed4(lambert * _Diffuse.xyz * _LightColor0.xyz + ambient, 1.0);
				//经过TRANSFORM_TEX宏转化纹理坐标,主要处理了Offset和Tiling的改变,默认时等同于o.uv = v.texcoord.xy;
				o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
				return o;
			}

			//定义片元shader
			fixed4 frag(v2f i) : SV_Target
			{
				return i.color * tex2D(_MainTex, i.uv);
			}

			//使用vert函数和frag函数
			#pragma vertex vert
			#pragma fragment frag	

			ENDCG
		}

	}
	//前面的Shader失效的话,使用默认的Diffuse
	FallBack "Diffuse"
}

咱们用一我的物模型,分别使用两种shader,进行一下对比,左侧的shader主要计算在vertex,右侧的shader主要计算放在pixel:

能够看出,若是模型比较细致,其实在diffuse状况下,是没有特别明显的区别的,而大部分计算放在vertex shader中,对于效率更有益处,vertex shader通常不是GPU的瓶颈,逐顶点计算能够比逐像素计算省不少,因此将尽量多的计算放在vertex阶段而不是fragment阶段是一个很好的优化shader的策略。可是,注意!是在diffuse的状况,若是咱们的shader中有高光specular,那么,用逐顶点计算高光就会出现特别难看的光斑,这个下篇文章再进行介绍。

因为unity shader中diffuse是带有环境光的,因此咱们也在shader中计算了环境光。因为没有全局光照,因此间接光照就经过UNITY_LIGHTMODEL_AMBIENT这个宏进行访问

TRANSFORM_TEX宏


在添加了纹理以后,主要使用了一个宏和一个采样函数。采样函数顾名思义,tex2D,就是经过传入的纹理坐标,来得到纹理采样点所对应的颜色值。下面重点看一下Unity为咱们提供的TRANSFORM_TEX宏,咱们从UnityCG.cginc中能够找到这个宏的定义以下:
// Transforms 2D UV by scale/bias property
#define TRANSFORM_TEX(tex,name) (tex.xy * name##_ST.xy + name##_ST.zw)
若是咱们使用了这个宏,就须要在shader中定义咱们要采样的纹理的一个系数,命名方式为 纹理名_ST,float4类型。那么这个值是什么呢?
就是这个啦!咱们在使用纹理时,unity会为咱们提供两个参数,一个是Tiling,一个是Offset。简单来讲,Tiling表示纹理的缩放比例,Offset表示了纹理使用时采样的偏移值。关于Tiling和Offset的介绍,能够参考 这篇文章。知道了这个,也就好理解TRANSFORM_TEX宏所作的事情了,在采样时,将材质面板上设置的Tiling值经过XXX_ST.xy传递进来,用于和采样的坐标相乘,进行采样的缩放,将Offset值经过XXX_ST.zw传递进来,做为纹理采样的偏移。