UnityShader 漫反射(兰伯特与半兰伯特光照模型-逐顶点和逐像素光照)

漫反射效果

UnityShader 漫反射(兰伯特与半兰伯特光照模型-逐顶点和逐像素光照)_第1张图片

漫反射

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

实现原理

漫反射光照是用于对那些被物体表面随机散射到各个方向的辐射度进行建模的。在漫反射中,视角的位置是不重要的,因为反射是完全随机的,因此可以认为在任何反射方向上的分布都是一样的。但是,入射光线的角度很重要。

漫反射光照符合兰伯特定律(Lambert's law): 反射光线的强度与表面法线和光源方向之间夹角的余弦值成正比。因此,漫反射部分的计算如下:

Cdiffuse=(Clight · mdiffuse)max(0, n · )

其中, n 是表面法线, I 是指向光源的单位矢量, mdiffuse 是材质的漫反射颜色, clight 是光源颜色。需要注意的是,我们需要防止法线和光源方向点乘的结果为负值,为此,我们使用取最大值的函数来将其截取到 0, 这可以防止物体被从后面来的光源照亮。

UnityShader 漫反射(兰伯特与半兰伯特光照模型-逐顶点和逐像素光照)_第2张图片

逐顶点计算着色shader

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

下面看一下unity shader实现的逐顶点着色:

Shader "Diffuse/Diffuse Vertex-Level" {
	Properties {
		_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)      // 控制材质的漫反射颜色 
	}
	SubShader {
		Pass { 
		     // LightMode 标签是Pass 标签中的一种,它用于定义该Pass 在Unity 的光照流水线中的角色
			//只有定义了正确的LightMode,我们才能得到一些Unity 的内置光照变量,例如下面的_LightColor0
			Tags { "LightMode"="ForwardBase" }
		
			CGPROGRAM
			
			#pragma vertex vert
			#pragma fragment frag

			//为了使用Unity 内置的一些变量,如后面要讲到的_LightColor0,还需要包含进Unity 的内置文件Lighting.cginc
			#include "Lighting.cginc"
			
			fixed4 _Diffuse;
			
			struct a2v {
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			
			struct v2f {
				float4 pos : SV_POSITION;
				fixed3 color : COLOR;
			};
			
			v2f vert(a2v v) {
				v2f o;

				//将顶点从模型空间转换到投影空间
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				
				// Get ambient term
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
				
				// 把法线从模型空间转化到世界空间  
				fixed3 worldNormal = normalize(mul(v.normal, (float3x3)_World2Object));
				// 得到世界空间下的光照方向
				fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
				// 根据兰伯特模型计算顶点的光照信息
				fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLight));

				o.color = ambient + diffuse;
				
				return o;
			}
			
			fixed4 frag(v2f i) : SV_Target {
				return fixed4(i.color, 1.0);
			}
			
			ENDCG
		}
	}
	FallBack "Diffuse"
}

以上代码详细解释:

1:我们已经重复过很多次, 顶点着色器最基本的任务就是把顶点位置从模型空间转换到裁剪空间中, 因此我们需要使用Unity 内置的 模型*世界*投影矩阵UNITY_MATRIX_MVP 来完成这样的坐标变换。

2:接下来,我们通过Unity 的内置变量 UNITY_LIGHTMODEL_AMBIENT 得到了环境光部分。

3:然后, 就是真正计算漫反射光照的部分。为了计算漫反射光照我们需要知道4 个参数。在前面的步骤中, 我们已经知道了材质的漫反射颜色Diffuse 以及顶点法线v.normal。我们还需要知道光源的颜色和强度信息以及光源方向。

4:Unity 提供给我们一个内置变量 _LightColor0 来访问该Pass 处理的光源的颜色和强度信息(注意,想要得到正确的值需要定义合适的LightMode标签〉,而光源方向可以由 _WorldSpaceLightPos0 来得到。需要注意的是,这里对光源方向的计算并不具有通用性。在本节中, 我们假设场景中只有一个光源且该光源的类型是平行光。但如果场景中有多个光源并且类型可能是点光源等其他类型, 直接使用 _WorldSpaceLightPos0 就不能得到正确的结果。

5:在计算法线和光源方向之间的点积时,我们需要选择它们所在的坐标系, 只有两者处于同一坐标空间下,它们的点积才有意义。在这里, 我们选择了世界坐标空间。而由a2v 得到的顶点法线是位于模型空间下的, 因此我们首先需要把法线转换到世界空间中。我们已经知道可以使用顶点变换矩阵的逆转置矩阵对法线进行相同的变换,因此我们首先得到模型空间到世界空间的变换矩阵的逆矩阵 _World20bject,然后通过调换它在mul 函数中的位置,得到和转置矩阵相同的矩阵乘法。由于法线是一个三维矢量,因此我们只需要截取 _World2Object 的前三行前三列即可。

6:在得到了世界空间中的法线和光源方向后,我们需要对它们进行归一化操作。在得到它们点积的结果后,我们需要防止这个结果为负值。为此,我们使用了saturate 函数。saturate 函数是CG提供的一种函数,它的作用是可以把参数截取到[0, 1]的范围内。最后,再与光源的颜色和强度以及材质的漫反射颜色相乘即可得到最终的漫反射光照部分。

7:最后,我们对环境光和漫反射光部分相加,得到最终的光照结果。

逐像素计算着色shader


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

下面看一下unity shader实现的逐像素着色:
Shader "Diffuse/Diffuse Pixel-Level" {
	Properties {
		_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)        // 控制材质的漫反射颜色 
	}
	SubShader {
		Pass { 
			 // LightMode 标签是Pass 标签中的一种,它用于定义该Pass 在Unity 的光照流水线中的角色
			//只有定义了正确的LightMode,我们才能得到一些Unity 的内置光照变量,例如下面的_LightColor0
			Tags { "LightMode"="ForwardBase" }
		
			CGPROGRAM
			
			#pragma vertex vert
			#pragma fragment frag

			//为了使用Unity 内置的一些变量,如后面要讲到的_LightColor0,还需要包含进Unity 的内置文件Lighting.cginc
			#include "Lighting.cginc"
			
			fixed4 _Diffuse;
			
			struct a2v {
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			
			struct v2f {
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
			};
			
			v2f vert(a2v v) {
				v2f o;
				// Transform the vertex from object space to projection space
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);

				// Transform the normal from object space to world space
				o.worldNormal = mul(v.normal, (float3x3)_World2Object);

				return o;
			}
			
			fixed4 frag(v2f i) : SV_Target {
				// Get ambient term
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
				
				// Get the normal in world space
				fixed3 worldNormal = normalize(i.worldNormal);
				// Get the light direction in world space
				fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				
				// Compute diffuse term
				fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));
				
				fixed3 color = ambient + diffuse;
				
				return fixed4(color, 1.0);
			}
			
			ENDCG
		}
	} 
	FallBack "Diffuse"
}

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

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

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

UnityShader 漫反射(兰伯特与半兰伯特光照模型-逐顶点和逐像素光照)_第3张图片

半兰伯特光照模型

逐像素光照可以得到更加平滑的光照效果。但是,即便使用了逐像素漫反射光照,有一个问题仍然存在。在光照无法到达的区域,模型的外观通常是全黑的,没有任何明暗变化,这会使模型的背光区域看起来就像一个平面一样,失去了模型细节表现。
然而,实际上,我们在现实世界中经常会发现,即使我们让一个物体不被光直接照射,我们也可能会看到物体,虽然亮度不是很高,这其实是由于物体之间光的反射造成的,也就是间接光照,间接光照是更高级的渲染,比如光线追踪算法等。但是在实时图形学,我们大部分情况是通过一个环境光(Ambient Light)统一代表了间接光,这样,即使在没有光的时候,我们也可以看见物体。实际上我们可以通过添加环境光来得到非全黑的效果,但即便这样仍然无法解决背光面明暗一样的缺点。为此, 有一种改善技术被提出来,这就是半兰伯特( Half Lambert)光照模型

Valve 公司在开发游戏《半条命》时提出了一种技术,由于该技术是在原兰伯特光照模型的基础上进行了一个简单的修改, 因此被称为半兰伯特光照模型
广义的半兰伯特光照模型的公式如下:
Cdiffuse=(Clight · mdiffuse)(α (n· I)+ β)
可以看出, 与原兰伯特模型相比,半兰伯特光照模型没有使用max操作来防止n I 的点积为负值,而是对其结果进行了一个α倍的缩放再加上一个β大小的偏移。绝大多数情况下, α和β
的值均为0.5 ,即公式为:
Cdiffuse=(Clight · mdiffuse)(0.5 (n· I)+ 0.5)
通过这样的方式,我们可以把n·I 的结果范围从[-1, 1 ]映射到[0, 1 ]范围内。也就是说,对于模型的背光面,在原兰伯特光照模型中点积结果将映射到同一个值,即0 值处;而在半兰伯特模型中,背光面也可以有明暗变化,不同的点积结果会映射到不同的值上。
方法很简单,乘以0.5再加上0.5。这样,原本亮度为1的地方,乘以0.5变成了0.5,加上0.5就又成了1,而原本光照强度为0的地方,就变成了0.5,原本为负数的地方,也能保证为大于0了。半兰伯特光照这种区间转化的原理图如下所示:
UnityShader 漫反射(兰伯特与半兰伯特光照模型-逐顶点和逐像素光照)_第4张图片
需要注意的是,半兰伯特是没有任何物理依据的,它仅仅是一个视觉加强技术。

下面看一下逐像素计算的半兰伯特光照shader,与兰伯特光照相比,只是将法线向量与光照方向向量的点乘结果用一种更好的方式区间转化到了(0,1)区间:
Shader "Diffuse/Half Lambert" {
	Properties {
		_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)    // 控制材质的漫反射颜色 
	}
	SubShader {
		Pass { 
		     // LightMode 标签是Pass 标签中的一种,它用于定义该Pass 在Unity 的光照流水线中的角色
			//只有定义了正确的LightMode,我们才能得到一些Unity 的内置光照变量,例如下面的_LightColor0
			Tags { "LightMode"="ForwardBase" }
		
			CGPROGRAM
			
			#pragma vertex vert
			#pragma fragment frag

			//为了使用Unity 内置的一些变量,如后面要讲到的_LightColor0,还需要包含进Unity 的内置文件Lighting.cginc
			#include "Lighting.cginc"
			
			fixed4 _Diffuse;
			
			struct a2v {
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			
			struct v2f {
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
			};
			
			v2f vert(a2v v) {
				v2f o;
				// Transform the vertex from object space to projection space
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				
				// Transform the normal from object space to world space
				o.worldNormal = mul(v.normal, (float3x3)_World2Object);
				
				return o;
			}
			
			fixed4 frag(v2f i) : SV_Target {
				// Get ambient term
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
				
				// Get the normal in world space
				fixed3 worldNormal = normalize(i.worldNormal);
				// Get the light direction in world space
				fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				
				// Compute diffuse term
				fixed halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
				fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * halfLambert;
				
				fixed3 color = ambient + diffuse;
				
				return fixed4(color, 1.0);
			}
			
			ENDCG
		}
	} 
	FallBack "Diffuse"
}

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

UnityShader 漫反射(兰伯特与半兰伯特光照模型-逐顶点和逐像素光照)_第5张图片

可以看出,如果模型比较细致,其实在diffuse情况下,是没有特别明显的区别的,而大部分计算放在vertex shader中,对于效率更有益处,vertex shader一般不是GPU的瓶颈,逐顶点计算可以比逐像素计算省很多,所以将尽可能多的计算放在vertex阶段而不是fragment阶段是一个很好的优化shader的策略。但是,注意!只是在diffuse的情况,如果我们的shader中有高光
specular,那么,用逐顶点计算高光就会出现特别难看的光斑。

由于unity shader中漫反射是带有环境光的,所以我们也在shader中计算了环境光。由于没有全局光照,所以间接光照就通过
UNITY_LIGHTMODEL_AMBIENT这个宏进行访问。

源工程下载:UnityShader 漫反射(兰伯特与半兰伯特光照模型-逐顶点和逐像素光照)






你可能感兴趣的:(Unity,Shader应用,Unity,Shader,实际应用)