UnityShader 浮雕凹凸贴图BumpMap与法线贴图NormalMap的原理及其区别

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/e295166319/article/details/80372843

效果图:

     

                         浮雕凹凸贴图效果 ====》 高度图

    

                    法线贴图凹凸效果    ====>  法线贴图

简介

法线贴图是目前游戏开发中最常见的贴图之一。我们知道,一般情况下,模型面数越高,可以表现的细节越多,效果也越好。但是,由于面数多了,顶点数多了,计算量也就上去了,效果永远是和性能成反比的。怎么样用尽可能简单模型来做出更好的效果就成了大家研究的方向之一。纹理映射是最早的一种,通过纹理直接贴在模型表面,提供了一些细节,但是普通的纹理贴图只是影响最终像素阶段输出的颜色值,不能让模型有一些凹凸之类的细节表现。而法线贴图就是为了解决上面的问题,给我们提供了通过低面数模型来模拟高面数模型的效果,增加细节层次感,效果与高模相差不多,但是大大降低了模型的面数。

凹凸映射和纹理映射非常相似。然而,纹理映射是把颜色加到多边形上,而凹凸映射是把粗糙信息加到多边形上。这在多边形的视觉上会产生很吸引人的效果。我们只需要添加一点信息到本来需要使用大量多边形的物体上。需要注意的是这个物体是平的,但是它看起来却是粗糙不平的。“凹凸映射和纹理映射有什么不同?”它们的不同之处在于——凹凸映射是一种负责光方向的纹理映射。

法线贴图原理

要模拟一个圆球,要想越平滑,就需要更多的面数,否则会很容易地发现面和面之间的明显边界。最早时的GPU是没有fragement编程能力的,也就是说在这种情况下,在计算时需要逐顶点计算光照,然后每个像素的颜色在各个顶点的颜色之间插值,也就是高洛德着色,这种情况下,面数决定一切效果,没有什么好办法。而当像素着色器出现之后,我们可以逐像素来计算光照效果,这时候,在计算每个像素的光照时,会计算这个像素所在的面的法向量,而这个面的法向量也是由这个面周围的顶点法线(也就是我们之前vertex shader中出现的normal)插值得来的,当然,如果面数很低,那么效果也好不到哪里去。但是,逐像素计算光照时,我们每一个像素都会根据该点的法向量来计算最终该点的光照结果,那么,我们如果能够改变这个法线的方向,不是就可以改变这个点的光照结果了呢!那么,把纹理采样的思想用在这里,我们直接用一张图来存储法线(或者法线偏移值,见下文),逐像素计算时,在采样diffuse贴图的时候,再采样一张法线的贴图,就可以修改法线了,进而修改最终的效果。

为什么法线贴图会让我们感觉有凹凸感呢?看下面一张图,在现实世界中,你要相信你的眼睛,眼见为实还有点道理,在计算机世界中,一切以忽悠你为目的。在平面的情况下,我们感觉物体是凹陷还是凸起,很大一部分取决于这个面的亮度,像下面这张图,有了这种亮度的对比,我们就很容易感觉这个按钮有周围的一圈凸起。

如果还是没理解,再看一套图片,同样一张图片,旋转180度后的结果完全相反。
从远处看,你判断这个物体是粗糙的的唯一证据是在它表面上下的亮度有改变。你的大脑能够获得这些亮暗不一的图案信息,然后判断出它们是表面中有凹凸的部位。左边的一幅图就说明了这一点。你可以发现它是一个浮雕式的表面。一些矩型和字母被印入表面,但是它们摸上去就像是一个隐藏的监控器的玻璃。如果这个图像是在适当的位置上,那么它除了改变亮度,不需要再做任何其他的工作。 
那么你也许会问:我是怎么知道哪些点要亮,哪些点要暗呢?这不难。绝大多数人生活在这样一种环境下——这个环境的大多数光源来自上方(译者注:比如白天主要的光来自太阳,夜晚主要的光来自天花板上的日光灯)。所以向上倾的地方就会更亮,而向下倾的地方就会更暗。所以这种现象使你的眼睛看到一个物体上亮暗区域时,可以判断出它的凹凸情况。相对亮的块被判断是面向上的,相对暗的块被判断是面向下的。所以我只需要给物体上的线条简单得上色。 
如果你想要更多的证据,这里还有一幅几乎相同的图,不同于前的是它旋转了180度。所以它是前一幅图倒转的图像。那些先前看起来是凹进去的区域,现在看起来是凸出来的了。

这个时候你的大脑并没有被完全欺骗,你脑中存留的视觉印象使你仍然有能力判断出这是前一幅图,只是它的光源变了,是从下往上照的,你的大脑可能强迫性地判断出它是第一幅图。事实上,你只要始终盯着它,并且努力地想像着光是从右下方向照射的,你就会理解它是凹的(译者注:因为日常生活的习惯,你会很容易把这些图形判断成凸出的图形,但是因为有了上一幅对照图的印象,你可能才会特别注意到这些图块其实还是凹入的,只是判断方法不符合我们日常生活习惯,因为这时大多数光不是从上方照射,而是从下往上照射)。

既然一个面的光照条件(亮度)的改变,就可以让我们感觉这个面有凹凸感,那么上面说的,通过改变法线来改变面上某点的光照条件,进而忽悠观察者,让他们感觉这个面有凹凸感的方法就行得通了。


假如下面是我们的低面数模型,上面是我们的高面数模型,上面的模型在计算光照时,由于面数多,每个面的法线方向不同,所以各个面的光照计算结果都不同,就有凹凸的感觉了,而下面的低模,只有一个面,整个面的光照条件都是一致的,就没有凹凸的感觉了。我们如果把上面的高模的法线信息保存下来,类似纹理贴图那样,存在一张图里,再给低模使用,低模就可以有跟高模一样的法线,进而在计算光照时达到和高模类似的效果,这也就是常说的烘法线的原理。

凹凸映射的目的是使用一张纹理来修改模型表面的法线,以便为模型提供更多的细节。这种万法不会真的改变模型的顶点位置,
只是让模型看起来好像是“凹凸不平”的,但可以从模型的轮廓处看出“破绽”。
有两种主要的方法可以用来进行凹凸映射: 一种方法是使用一张高度纹理(height map)来模拟表面位移( displacement ), 然后得到一个修改后的法线值,这种方法也被称为高度映射(height mapping);另一种方法则是使用一张法线纹理(normal map )来直接存储表面法线,这种方法又被称为法线映射( normal mapping )。

凹凸贴图(Bump Map)

既然说了要研究法线贴图,所以肯定要从老一辈的开始,首先来看一下凹凸贴图(Bump Map)。Bump Map是最早的法线贴图实现方式,这也是制作上最容易的一种模式,可以直接通过一张灰度图,默认为黑色,越凸起的地方颜色越亮,这种就是可以直接在PhotoShop中画的法线,但是这种法线贴图的原理理解起来比较难,我只说一下我的理解,然后附上unity中的shader实现。这种技术现在貌似已经过时了,但是思想还是流传下来了,而且这种画灰度图,或者通过灰度图生成法线贴图的方式现在仍然在使用,Unity就支持这种直接通过灰度图生成法线贴图。
BumpMap一种是Emboss Bump Map(浮雕凹凸贴图), 它使用的是 Height map ,原理是在原始图像的基础上,对 高度场图像进行复制、轻微平移、差操作 。但它存在很多严重的局限性,它只能用于漫反射表面,对于镜面高光来说是不可能的。当光源直接照射在物体表面时,如果没有偏移,那么物体表面就不会出现任何凹凸现象。

工作原理:

凹凸映射是补色渲染技术(Phong Shading Technique)的一项扩展,只是在补色渲染里,多边形表面上的法线将被改变这个向量用来计算该点的亮度。当你加入了凹凸映射,法线向量会略微地改变,怎么改变则基于凹凸图。改变法线向量就会改变多边形的点的颜色值。就这么简单。 
现在,有几种方法来达到这个目的(译者注:这个目的指改变法线向量)。我并没有实际编写补色渲染和凹凸映射的程序,但是我在这里将介绍一种我喜欢的方法来实现! 
现在我们需要将凹凸图中的高度信息转换成补色渲染用到的法线的调节信息。这个做起来不难,但是解释起来比较费劲。


好的,我们现在将凹凸位图的信息转换成一些小向量——一个向量对应于一个点。请看上面一副放大的凹凸图。相对亮的点比相对暗的点更为凸出。看清楚了吗?现在计算每个点的向量,这些向量表征了每个点的倾斜情况,请看下图的描绘。图中红色小圆点表示向量是向下的:

有很多计算向量的方法,不同的方法精确度不同,但是选择什么方法要取决于你所要求的精确度是个什么层次。最通常的方法是分别计算每个点上X和Y的倾斜度:

x_gradient = pixel(x-1, y) – pixel(x+1, y) 
y_gradient = pixel(x, y-1) – pixel(x, y+1)

在得出了这两个倾斜度后,你就可以计算多边形点的法线了。


这里有一个多边形,图上绘出了它的一条法线向量——n。除此,还有两条向量,它们将用来调节该点法线向量。这两条向量必须与当前被渲染的多边形的凹凸图对齐,换句话说,它们要与凹凸图使用同一种坐标轴。下边的图分别是凹凸图和多边形,两副图都显示了U、V两条向量(译者注:也就是平面2D坐标的两条轴):

现在你可以看到被调节后的新法线向量了。这个调节公式很简单: 
New_Normal = Normal + (U * x_gradient) + (V * y_gradient) 
有了新法线向量后,你就可以通过补色渲染技术计算出多边形每个点的亮度了。

注:上面的调节公式你可以理解成:原向量+偏移向量=新向量(向量的加法)

上面的原理简单理解就是:首先,通过灰度图来表现凹凸,那么,我们怎样判断一个点处在凹凸的边缘呢?答案是通过斜率,比如我要对(x,y)进行采样,怎样求这一点的斜率呢,学过数学的都知道,我们可以通过两点确定一条直线,进而求出这条直线的斜率。那么我们就可以对(x-1,y)和(x+1,y)两点进行采样,竖向也是一样,通过(x,y-1)和(x,y+1)进行采样,那么,我们就可以获得这一点上灰度值的变化,如果灰度值不变,说明该点不在边缘,如果灰度值有改变,那么说明该点在边缘,那么我们就可以根据这个斜率值来修改法线,进而修改光照结果。

我们在PhotoShop中画了一张简单的的Bump Map,目前RGB通道都有信息:


这种方法的好处是非常直观,我们可以从高度图中明确地知道一个模型表面的凹凸情况,但缺点是计算更加复杂,在实时计算时不能直接得到
表面法线,而是需要由像素的灰度值计算而得,因此需要消耗更多的性能。

Bump Map类型的shader如下:

Shader "Demo/BumpMap"
{
	//属性
	Properties{
		_Diffuse("Diffuse", Color) = (1,1,1,1)           // 漫反射颜色
		_MainTex("Base 2D", 2D) = "white"{}              // 纹理颜色
		_BumpMap("Bump Map", 2D) = "black"{}             // 高度图
		_BumpScale ("Bump Scale", Range(0, 30.0)) = 10.0 // 凹凸程度
	}

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

			CGPROGRAM
			//使用vert函数和frag函数
			#pragma vertex vert
			#pragma fragment frag	
			//引入头文件
			#include "Lighting.cginc"
			//定义Properties中的变量
			fixed4 _Diffuse;
			sampler2D _MainTex;
			//使用了TRANSFROM_TEX宏就需要定义XXX_ST
			float4 _MainTex_ST;
			sampler2D _BumpMap;
			float4 _BumpMap_TexelSize;
			float _BumpScale;

			//定义结构体:应用阶段到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 worldNormal1 = normalize(i.worldNormal);
				//采样bump贴图,需要知道该点的斜率,xy方向分别求,所以对于一个点需要采样四次
				fixed bumpValueU = tex2D(_BumpMap, i.uv + fixed2(-1.0 * _BumpMap_TexelSize.x, 0)).r - tex2D(_BumpMap, i.uv + fixed2(1.0 * _BumpMap_TexelSize.x, 0)).r;
				fixed bumpValueV = tex2D(_BumpMap, i.uv + fixed2(0, -1.0 * _BumpMap_TexelSize.y)).r - tex2D(_BumpMap, i.uv + fixed2(0, 1.0 * _BumpMap_TexelSize.y)).r;
				//用上面的斜率来修改法线的偏移值
				fixed3 worldNormal = fixed3(worldNormal1.x + bumpValueU * _BumpScale, worldNormal1.y + bumpValueV * _BumpScale, worldNormal1.z);
				//把光照方向归一化
				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);
			}

			ENDCG
		}

	}
		//前面的Shader失效的话,使用默认的Diffuse
		FallBack "Diffuse"
}
效果图如下:


法线贴图(Normal Map)


随着GPU的发展,Geforce3的出现,带来了真正的Normal Mapping技术,也叫作Dot3 bump mapping。这种Normal Map就是我们现在在使用的法线贴图技术。与之前通过灰度表现界面的凹凸程度,进而修改法线的方式完全不同,这种Normal Map直接将法线存储到了法线贴图中,也就是说,我们从法线贴图读取的法线直接就可以使用了,而不是需要像上面那样,再通过灰度渐变值来修改法线。这种法线对于制作来说,没有灰度图那样直白,但是却是真正的法线贴图技术,所谓烘焙法线,烘焙的就是这个。

 DOT3 Bump Map(点乘凹凸贴图),它使用的是Normal Map,这是目前图形硬件中使用的主要方法,它不需要存储高度,只需要将表面的实际法线作为(x,y,z)向量存储在法线图中,然后可以将含有法线的凹凸纹理和经过插值的光源向量在每个象素点结合起来,可以使用点乘。它的一个优点就是可以直接用来计算凹凸块上的镜面高光。 
   注意在max中,BumpMap用于逐像素光照,在Gamebryo中BumpMap用于模拟顶点高度凹凸,而NormalMap才是逐像素光照。  


虽然灰度图不会直接被用于实时计算法线了,但是在离线工具中却提供了直接通过灰度图生成法线的功能。Unity中就有这种功能:
我们把之前画的那张灰度图直接通过这种方式改成法线贴图,从法线贴图中我们就直接可以看到凹凸的效果了。在Unity里实现法线贴图的shader之前,首先看几个问题:

法线贴图是怎样存储的

法线纹理中存储的就是表面的法线方向。由于法线方向的分量范围在[-1, 1 ],而像素的分量范围为[0, 1],因此我们需要做一个映射,通常使用的映射就是:

这就要求,我们在Shader 中对法线纹理进行纹理来样后,还需要对结果进行一次反映射的过程,以得到原先的法线方向。反映射的过程实际就是使用上面映射函数的逆函数:

                                                                normal = pixel x 2 -1

这个步骤,Unity已经为我们完成了,我们在计算法线的时候,只需要调用UnpackNormal这个函数就可以实现区间的重新映射。

UnityCG.cginc中可以看到UnpackNormal这个函数的实现:

inline fixed3 UnpackNormalDXT5nm (fixed4 packednormal)
{
	fixed3 normal;
	normal.xy = packednormal.wy * 2 - 1;
	normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
	return normal;
}

inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if defined(UNITY_NO_DXT5nm)
	return packednormal.xyz * 2 - 1;
#else
	return UnpackNormalDXT5nm(packednormal);
#endif
}
当我们需要使用那些包含了法线映射的内置的Unity Shader 时,必须把使用的法线纹理按上面的方式标识成Normal map 才能得到正确结果(即便你忘了这么做, Unity 也会在材质面板中提醒你修正这个问题〉,这是因为这些Unity Shader 都使用了内置的UnpackNormal 函数来采样法线方向。
这里,我们看到了两个UnpackNormal的函数,下面的就是我们所说的直接转化区间。而上面的那个函数,看定义来说,是为了专门解出DXT5nm格式的normal map,这种类型的normal map,只用存储法向量中的两个通道,然后解开的时候,需要计算一下,重新算出另一个向量方向。这样可以实现的原理在于,存储的向量是单位向量,长度一定的情况下,就可以通过sqrt(1 - x^2 - y^2)来求得,如下图:

不过这是一种时间换空间的做法,以牺牲时间的代价,换来更好的压缩比以及压缩后的效果。关于DXT5nm,附上一篇参考文章: Normal Map的dds压缩

为什么法线贴图存储在切线空间

既然知道了法线可以存储在贴图中,我们就再来看一下,为什么法线贴图中一般都存储的是切线空间,为什么不存储在世界空间或者模型空间。首先看一下世界空间,如果我们的法线贴图存储的世界空间的法线信息,我们可以直接解出法线的值,在世界空间进行计算,是最直接并且计算效率最高的做法,但是世界空间的法线贴图就跟当前环境之间耦合过大了,比如同样的两个模型,仅仅是旋转方向不同,也需要两张法线贴图,这很明显是多余的,于是就有人想出了基于模型空间的法线,基于模型空间,在计算时,把模型空间的法线转换到世界空间,虽然多了一步操作,但是同一个模型可以共用法线,不用考虑旋转等问题。但是,人们感觉模型空间的法线贴图跟模型的耦合度还是高,那就继续解耦吧,于是基于切线空间的法线贴图就诞生了。下图为模型空间与切线空间法线。

然而,由于方向是相对于坐标空间来说的,那么法线纹理中存储的法线方向在哪个坐标空间中呢?对于模型顶点自带的法线,它们是定义在模型空间中的,因此一种直接的想法就是将修改后的模型空间中的表面法线存储在一张纹理中,这种纹理被称为是模型空间的法线纹理( object-space normal map )。然而,在实际制作中,我们往往会采用另一种坐标空间,即模型顶点的切线空间( tangent space )来存储法线。对于模型的每个顶点,它都有一个属于自己的切线空间,这个切线空间的原点就是该顶点本身,而z 轴是顶点的法线方向(n), x 轴是顶点的切线方向(t),而y 轴可由法线和切线叉积而得,也被称为是副切线( bitangent, b )或副法线,如下图所示。


这种纹理被称为是切线空间的法线纹理(tangent-space normal map ) 。

所谓的切线空间,跟那些比较常见的坐标系,比如世界坐标,模型坐标一样,也是一个坐标系,用三个基向量就可以表示。我们用模型上的一个点来看,这个点的有一个法线的方向,也就是这个点所在的面的法线的方向N,这个方向是确定的,我们可以用它作为Z轴。而剩下的两个轴,刚好就在这个面上,互相垂直,但是这两个轴的可选种类就多了,因为在这个面上任意两个向量都可以表示这个面。目前最常用的方式是以该点的uv二维坐标系表达该点的切线(tangent)和该点的次法线(binormal)所构成的切平面。它的法线既处处都垂直于它的表面。我们用展uv的方式,将纹理展开摊平,那么所有的法线就都垂直于这个纹理平面,法线就是z轴,而uv set,准确地说是该点uv朝着下一个顶点uv的方向向量分别作为tangent和binormal轴,也就是x,y轴。但是这样做有一个弊端,就是x轴和y轴之间不互相垂直,计算Tangent空间的公式如下:

T = normalize(dx/du, dy/du, dz/du)
N = T × normalize(dx/dv, dy/dv, dz/dv)
B = N × T

很遗憾我们在在Unity里面看不到全部源代码,不过从shader的定义中可以看到B的求解以及TBN矩阵的构建过程:
// Declares 3x3 matrix 'rotation', filled with tangent space basis
#define TANGENT_SPACE_ROTATION \
	float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w; \
	float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal )
float3x3是行向量构建,可以参照 这里,然后我们就可以通过mul(rotation,v)把需要的向量从模型空间转化到tangent空间。不过大部分内容Unity已经帮我们做好了,主要是TBN空间的创建,如果需要自己写渲染器的话,这个是一个比较麻烦的过程,也有类似 3DMax中导出顶点tangent值中的做法,直接在导出的时候将tangent空间信息导出,存储在顶点中。


总体来说,模型空间下的法线纹理更符合人类的直观认识,而且法线纹理本身也很直观, 容易调整,因为不同的法线方向就代表了不同的颜色。但美术人员往往更喜欢使用切线空间下的法线纹理。那么,为什么他们更偏好使用这个看起来“很鳖脚”的切线空间呢?
实际上,法线本身存储在哪个坐标系中都是可以的, 我们甚至可以选择存储在世界空间下。
但问题是,我们并不是单纯地想要得到法线,后续的光照计算才是我们的目的。而选择哪个坐标系意味着我们需要把不同信息转换到相应的坐标系中。例如,如果选择了切线空间, 我们需要把从法线纹理中得到的法线方向从切线空间转换到世界空间(或其他空间)中。

总体来说,使用模型空间来存储法线的优点如下。

  • 实现简单, 更加直观。我们甚至都不需要模型原始的法线和切线等信息, 也就是说,计算更少。生成它也非常简单, 而如果要生成切线空间下的法线纹理,由于模型的切线一般是和UV 方向相同,因此想要得到效果比较好的法线映射就要求纹理映射也是连续的。
  • 在纹理坐标的缝合处和尖锐的边角部分,可见的突变(缝隙)较少,即可以提供平滑的边界。这是因为模型空间下的法线纹理存储的是同一坐标系下的法线信息, 因此在边界处通过插值得到的法线可以平滑变换。而切线空间下的法线纹理中的法线信息是依靠纹理坐标的方向得到的结果,可能会在边缘处或尖锐的部分造成更多可见的缝合迹象。
但使用切线空间有更多优点。
  • 自由度很高。 模型空间下的法线纹理记录的是绝对法线信息,仅可用于创建它时的那个模型,而应用到其他模型上效果就完全错误了。而切线空间下的法线纹理记录的是相对法线信息,这意味着,即便把该纹理应用到一个完全不同的网格上,也可以得到一个合理的结果。
  • 可进行UV 动画。比如,我们可以移动一个纹理的UV 坐标来实现一个凹凸移动的效果,但使用模型空间下的法线纹理会得到完全错误的结果。原因同上。这种UV 动画在水或者火山熔岩这种类型的物体上会经常用到。
  • 可以重用法线纹理。比如, 一个砖块,我们仅使用一张法线纹理就可以用到所有的6 个面上。原因同上。
  • 可压缩。由于切线空间下的法线纹理中法线的Z 方向总是正方向,因此我们可以仅存储XY 方向,而推导得到Z 方向。而模型空间下的法线纹理由于每个方向都是可能的, 因此必须存储3 个方向的值,不可压缩。

切线空间下的法线纹理的前两个优点足以让很多人放弃模型空间下的法线纹理而选择它。从上面的优点可以看出,切线空间在很多情况下都优于模型空间,而且可以节省美术人员的工作。

最后总结一下: tangent space下,其实跟我们上一节计算的斜率很像,我们计算斜率基本也是tangent值。而这里T(x轴)使用normalize(dx/du, dy/du, dz/du),相当于计算了模型空间下x,y,z值随着纹理u坐标方向的斜率,换句话说,切线空间反映了模型空间坐标xyz随着纹理坐标uv的变化率(坡度),这也正是normal map中要存储的信息,所以normal map中的内容正好可以使用切线空间进行存储。

为什么法线贴图都是蓝色的

下图分别给出了模型空间和切线空间下的法线纹理:


从图7.13 中可以看出,模型空间下的法线纹理看起来是“五颜六色”的。这是因为所有法线所在的坐标空间是同一个坐标空间,即模型空间,而每个点存储的法线方向是各异的,有的是(0,1, 0),经过映射后存储到纹理中就对应了RGB(0 .5, 1, 0.5)浅绿色,有的是(0,-1 , 0),经过映射后存储到纹理中就对应了(0 .5, 0, 0. 5)紫色。既然法线贴图中存储的是法线的方向,也就是说是一个Vector3类型的变量,刚好和图片的RGB格式不谋而合。


而切线空间下的法线纹理看起来几乎全部是浅蓝色的。这是因为,每个法线方向所在的坐标空间是不一样的,即是表面每点各自的切线空间。这种法线纹理其实就是存储了每个点在各自的切线空间中的法线扰动方向。也就是说,如果一个点的法线方向不变,那么在它的切线空间中, 新的法线方向就是z 轴方向,即值为(0, 0, 1 ) , 经过映射后存储在纹理中就对应了RGB(0.5, 0.5, 1 )浅蓝色。而这个颜色就是法线纹理中大片的蓝色。这些蓝色实际上说明顶点的大部分法线是和模型本身法线一样的,不需要改变。

既然我们知道了法线贴图中存储的是切线空间的法线。而法线贴图所对应的表面,绝大部分的位置肯定是平滑的,只有需要凹凸变化的地方才会有变化,那么大部分地方的法线方向不变,也就是在切线空间的(0,0,1),这个值按照上面介绍的映射关系,从(-1,1)区间变换到(0,1)区间:((-1+1)/2, (-1+1)/2, (1+1)/2)= (0.5,0.5,1),再转化为颜色的(0,255)区间,最终就变成了(127,127,255)。好了,打开photoshop,看一下这个颜色值是什么:


法线一般就是这个颜色!那么,其他的地方,如果有凹凸感,就需要调整法线的方向,那么颜色就不一样了。

Unity下法线贴图Shader实现

我们就可以看一下Unity Shader中实现法线贴图的方式。光照模型仍然采用之前的半兰伯特光照,vertex fragemnt shader实现:

Shader "Deme/NormalMapInTangentSpace"
{
	//属性
	Properties{
		_Diffuse("Diffuse", Color) = (1,1,1,1)             // 漫反射颜色
		_MainTex("Base 2D", 2D) = "white"{}				   // 纹理颜色
		_BumpMap("Bump Map", 2D) = "bump"{}                // 法线贴图
		_BumpScale ("Bump Scale", Range(0.0, 30.0)) = 10.0 // 凹凸程度
	}

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

			CGPROGRAM
			//使用vert函数和frag函数
			#pragma vertex vert
			#pragma fragment frag	
			//引入头文件
			#include "Lighting.cginc"
			//定义Properties中的变量
			fixed4 _Diffuse;
			sampler2D _MainTex;
			//使用了TRANSFROM_TEX宏就需要定义XXX_ST
			float4 _MainTex_ST;
			sampler2D _BumpMap;
			float _BumpScale;

			//定义结构体:vertex shader阶段输出的内容
			struct v2f
			{
				float4 pos : SV_POSITION;
				//转化纹理坐标
				float2 uv : TEXCOORD0;
				//tangent空间的光线方向
				float3 lightDir : TEXCOORD1;
			};

			//定义顶点shader
			v2f vert(appdata_tan v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				// Compute the binormal  
		//      float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w;  
		//      // Construct a matrix which transform vectors from object space to tangent space  
		//      float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);  
				  // Or just use the built-in macro  
				//这个宏为我们定义好了模型空间到切线空间的转换矩阵rotation,注意后面有个;
				TANGENT_SPACE_ROTATION;
				//ObjectSpaceLightDir可以把光线方向转化到模型空间,然后通过rotation再转化到切线空间
				o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex));
				//通过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;

				// Get the texel in the normal map  
				fixed4 packedNormal = tex2D(_BumpMap, i.uv);  
				fixed3 tangentNormal;  
				// If the texture is not marked as "Normal map"  
		//      tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale;  
		//      tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));  
                  
				 //解出切线空间法线
				 // Or mark the texture as "Normal map", and use the built-in funciton  
				tangentNormal = UnpackNormal(packedNormal);  
				tangentNormal.xy *= _BumpScale;  
				tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));  

				//normalize一下切线空间的光照方向
				float3 tangentLight = normalize(i.lightDir);
				//根据半兰伯特模型计算像素的光照信息
				fixed3 lambert = 0.5 * dot(tangentNormal, tangentLight) + 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);
			}

			ENDCG
		}

	}
		//前面的Shader失效的话,使用默认的Diffuse
		FallBack "Diffuse"
}
代码详解:
在顶点着色器中,我们把模型空间下切线方向、 副切线方向和法线方向按行排列来得到从模型空间到切线空间的变换矩阵rotation。需要注意的是,在计算副切线时我们使用v.tangent.w 和叉积结果进行相乘,这是因为和切线与法线方向都垂直的方向有两个,而w 决定了我们选择其中哪一个方向。Unity 也提供了一个内置宏 TANGENT_SPACE_ROTATION (在UnityCG.cginc 中被定义〉来帮助我们直接计算得到rotation 变换矩阵,它的实现和上述代码完全一样。然后,我们使用Unity 的内置函数ObjSpaceLightDir和 ObjSpaceViewDir 来得到模型空间下的光照和视角方向,再利用变换矩阵rotation 把它们从模型空间变换到切线空间中。

在片元着色器中,我们首先利用tex2D 对法线纹理 _BumpMap 进行采样。正如本节一开头所讲的,法线纹理中存储的是把法线经过映射后得到的像素值, 因此我们需要把它们反映射回来。
如果我们没有在Unity 里把该法线纹理的类型设置成Normal map,就需要在代码中手动进行这个过程。我们首先把packedNormal 的xy 分量按之前提到的公式映射回法线方向,然后乘以 _BumpScale (控制凹凸程度〉来得到tangentNormal 的xy 分量。由于法线都是单位矢量,因此tangentNormal.z 分量可以由tangentNonnal.xy 计算而得。由于我们使用的是切线空间下的法线纹理, 因此可以保证法线方向的z 分量为正。在Unity 中,为了方便Unity 对法线纹理的存储进行优化,我们通常会把法线纹理的纹理类型标识成Normal map, Unity 会根据平台来选择不同的压缩方法。这时,如果我们再使用上面的方法来计算就会得到错误的结果,因为此时 _BumpMap的rgb 分量并不再是切线空间下法线方向的xyz 值了。在这种情况下,我们可以使用Unity 的内置函数UnpackNormal 来得到正确的法线方向。

效果如下:

世界空间下法线切图的计算:

我们需要在片元着色器中把法线方向从切线空间变换到世界空间下。这种方法的基本思想是:在顶点着色器中计算从切线空间到世界空间的变换矩阵,并把它传递给片元着色器。变换矩阵的计算可以由顶点的切线、副切线和法线在世界空间下的表示来得到。最后,我们只需要在片元着色器中把法线纹理中的法线方向从切线空间变换到世界空间下即可。尽管这种方法需要更多的计算,但在需要使用Cubemap 进行环境映射等情况下,我们就需要使用这种方法。

Shader实现如下:

Shader "Deme/NormalMapInWorldSpace"
{
	//属性
	Properties{
		_Diffuse("Diffuse", Color) = (1,1,1,1)             // 漫反射颜色
		_MainTex("Base 2D", 2D) = "white"{}				   // 纹理颜色
		_BumpMap("Bump Map", 2D) = "bump"{}                // 法线贴图
		_BumpScale ("Bump Scale", Range(0.0, 30.0)) = 10.0 // 凹凸程度
	}

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

			CGPROGRAM
			//使用vert函数和frag函数
			#pragma vertex vert
			#pragma fragment frag	
			//引入头文件
			#include "Lighting.cginc"
			//定义Properties中的变量
			fixed4 _Diffuse;
			sampler2D _MainTex;
			//使用了TRANSFROM_TEX宏就需要定义XXX_ST
			float4 _MainTex_ST;
			sampler2D _BumpMap;
			float _BumpScale;

			//定义结构体:vertex shader阶段输出的内容
			struct v2f
			{
				float4 pos : SV_POSITION;
				//转化纹理坐标
				float2 uv : TEXCOORD0;
				//TtoW0 、TtoW1 和TtoW2 就依次存储了从切线空间到世界空间的变换矩阵的每一行
				//实际上,对方向矢量的变换只需要使用3 × 3大小的矩阵
				//也就是说,每一行只需要使用float3 类型的变量即可
				float4 TtoW0 : TEXCOORD1;    
				float4 TtoW1 : TEXCOORD2;    
				float4 TtoW2 : TEXCOORD3; 
			};

			//定义顶点shader
			v2f vert(appdata_tan v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				
			    float3 worldPos = mul(_Object2World, v.vertex).xyz;    
				fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);    
				fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);    
				fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;   
                  
				// Compute the matrix that transform directions from tangent space to world space  
				// Put the world position in w component for optimization  
				o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);  
				o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);  
				o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);  

				//通过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;

				 // Get the position in world space        
				float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);  
				// Compute the light dir in world space  
				fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));  
              
				 // Get the normal in tangent space  
				fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv));  
				bump.xy *= _BumpScale;  
				bump.z = sqrt(1.0 - saturate(dot(bump.xy, bump.xy)));  
				// Transform the narmal from tangent space to world space  
				//mul(TtoW, bump) 下面的一句等同于这句,只是矩阵用向量分行表示
				bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));  

				//根据半兰伯特模型计算像素的光照信息
				fixed3 lambert = 0.5 * dot(bump, lightDir) + 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);
			}

			ENDCG
		}

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

代码详解:
在顶点着色器中, 我们计算了世界空间下的顶点切线、副切线和法线的矢量表示, 并把它们 按列 摆放得到从切线空间到世界空间的变换矩阵。我们把该矩阵的每一行分别存储在TtoW0 、TtoWl1和TtoW2 中, 并把世界空间下的顶点位置的xyz 分量分别存储在了这些变量的w 分量中,以便充分利用插值寄存器的存储空间。
在片元着色器中,我们首先从TtoW0 、TtoW1和TtoW2 的w 分量中构建世界空间下的坐标。然后,使用内置的UnityWorldSpaceLightDir 函数得到世界空间下的光照和视角方向。
接着,我们使用内置的UnpackNormal 函数对法线纹理进行采样和解码(需要把法线纹理的格式标识成Normal map ), 并使用_BumpScale 对其进行缩放。最后,我们使用TtoW0 、TtoW1 和TtoW2存储的变换矩阵把法线变换到世界空间下。这是通过使用点乘操作来实现矩阵的每一行和法线相乘来得到的。
从视觉表现上,在切线空间下和在世界空间下计算光照几乎没有任何差别。

总结

本篇文章简单探究了一下bump map以及normal map的原理以及在Unity中的实现。法线贴图可以很好地在低模上模拟高模的效果,虽然多采样了一次贴图,但是能模拟出数倍于模型本身面数的效果,极大地提升了实时渲染的效果。虽然法线贴图也有一些弊端,因为法线贴图只是给人造成一种凹凸的假象,所以在视角与物体平行时,看到的物体表面仍然是平的。并且还会有一些穿帮的现象,不过毕竟瑕不掩瑜,法线贴图仍然是目前渲染中最常使用的技术之一。为了解决上面的问题,一些更加高级的贴图技术,如视差贴图和位移贴图就诞生了。之后再研究这两种更加高级一点的贴图技术,本篇到此为止。上面给出了一些关于tangent空间求解的参考链接。最后再附上一些关于法线贴图原理的参考。




猜你喜欢

转载自blog.csdn.net/e295166319/article/details/80372843