# 前言

我很想这样说:
“今天花了相当长的时间,就为了研究视差贴图 (Parallax),可怎么试效果怎么不对!本来视差贴图也不过是在法线贴图的基础上发展出来的算法,法线贴图完全没问题,加上对视差贴图相关计算,这么久不行了呢?最终查了又查,发现居然是自己代码中关于视线的一个计算问题造成的!白白又浪费多的时间.... 这使我决定就算暂时不继续看书了,挤出点时间、再熬点夜,也得把这玩意好好进行分析下,写出来。”

实际上,那是发生在星期三的事儿了,最终还是拖到了周末。

# 法线贴图

谈到视差贴图,那么就不得不说一下法线贴图了。视差贴图实际上属于法线贴图得一个优化版本,它在法线贴图的基础上,计算了一个视觉上的偏移,因此,法线贴图是其基础。

对于法线贴图,之前我似乎也没讨论过,所以就放这儿一并简单说一下吧。

# 切空间

法线贴图的计算,一般都需要用到 “切空间”。切空间即每个点以自身为准的一个坐标系统,之所以有这个概念,是因为只有以点自身为准的坐标系,才能做到 “复用”。包括以物体坐标系为准之类的,都只能说适用于特定物体,详细情况可以参照《CG - 可编程实时图形权威指南》第八章关于凹凸映射的解释。我就稍微画个图吧:

切空间

简单来说,切空间就是以垂直点自身得法线、垂直法线的切线、垂直法线切线的 Binormal 组成的一个坐标系。法线贴图保存的东西,就是这儿的。

切空间至模型空间的矩阵由此构成:

$$ M_{切空间->模型空间}= \begin{bmatrix} T_x&B_x&N_x\\ T_y&B_y&N_y\\ T_z&B_z&N_z\\ \end{bmatrix} $$

所以,这个矩阵的逆矩阵,就是模型空间到切空间的转化矩阵。同时由于 N、T、Bt 这三个量构造的矩阵属于正交矩阵,正交矩阵的逆矩阵就是它本身的转置矩阵。所以求出模型空间到切空间的矩阵就简单了:

M模型空间>切空间=M切空间>模型空间1=M切空间>模型空间TM_{模型空间->切空间}=M_{切空间->模型空间}^{-1}=M_{切空间->模型空间}^T

所以:

$$ M_{模型空间->切空间}= \begin{bmatrix} T_x&T_x&T_x\\ B_y&B_y&B_y\\ N_z&N_z&N_z\\ \end{bmatrix} $$

然后,我们使用这个矩阵就可以转化相应的向量了。无论最终是在切空间计算,或者是模型空间、世界空间,都取决于自己 —— 反正只要都在一个空间,其它都是任意的。

# 法线贴图格式

法线贴图的格式是经过压缩的,因为大家都知道,向量是 “具有大小和方向的量”,那么对于规范化向量来说,虽然大小不用说了,不过那方向必定也还是有的,即 [-1,1] 范围。然后,对于图片来说,范围又只在 [0,1] 之间,所就得现将其映射至 [-1,1] 范围。这就只需要简单地乘以 2 再减一就可以了。

另外,还有一点就是:在 Unity 中,法线贴图被压缩为 DTXnm 格式,其中有效数据是 alpha 及 green 通道,代表 XY 分量,Z 分量则由 XY 进行计算获得。所以,最终使用的时候,我们还需要再次进行解码,计算方式如下:

normal=normalTex.ag;
normal.z=sqrt(1-normal.xnormal.x-normal.ynormal.y);

# 实现

现在终于轮到实现了。

作为一个 Unity 程序,现在当然是打开 Unity,新建一个 Unlit Shader 了,删掉无用的代码。然后在 appdata 中添加两个变量 normal 及 tangent 的语义,以获取模型的法线及切线:

float3 normal : NORMAL;
float4 tangent : TANGENT;

接着,需要在传入片段程序的结构体 v2f 中,继续加入我们需要传入片段程序的变量,分别是视线、以及光源方向,这几个方向都属于切空间的,我们会在顶点程序中进行计算:

float3 V : TEXCOORD3;
float3 L : TEXCOORD4;

然后是顶点程序中的计算,首先缓存法线、切线,并计算出 Binormal (注意最后 Binormal 乘以的 w 系数,这似乎是 Unity 对切线提供的某种缩放):

float3 N = v.normal;
float3 T = v.tangent;
float3 Bt =normalize( cross(N,T)*v.tangent.w);

接着,构建相应的切空间矩阵,并将视线、光源方向皆转化为切空间坐标,如下:

//构建前往切空间的矩阵
float3x3 o2Surf = float3x3(T, Bt, N);
//链接构建世界空间至切空间矩阵
float3x3 w2Surf = mul(o2Surf, (float3x3)_World2Object);

float3 wPos = mul(_Object2World,v.vertex);
float3 V= _WorldSpaceCameraPos - wPos;
o.V = mul(w2Surf,V);
o.L = mul(w2Surf,_WorldSpaceLightPos0.xyz);

最后,就是片段程序中的计算了,除了多了一个额外法线的计算,其它与前文所述并无不认同。
首先将传入片段程序的各个插值后的向量进行归一化:

float3 V = normalize(i.V);
float3 L = normalize(i.L);

重点就是这儿,按照上边说的,将法线从贴图中解压出来,并替换掉:

//解压法线
float4 normalTex = tex2D(_BumpTex,i.uv);
//映射至[-1,1]
normalTex = normalTex * 2 - 1;
float3	localNormal;
localNormal = float3(normalTex.ag,0);
localNormal.z = sqrt(1 - dot(localNormal, localNormal));

上边的 localNormal 的 Z 分量计算跟上边谈到的是一个性质的,稍微思考一下就明白了哦。

现在,新的法线已经计算出来,用这个法线进行光照的计算:

fixed4 col = tex2D(_MainTex, i.uv);
float4 diffuse = max(0, dot(localNormal, L))*col;
float4 specialer = col*pow(max(0, dot(V, reflect(-L, localNormal))), 25);
col = col*UNITY_LIGHTMODEL_AMBIENT +diffuse+ specialer;

完。

效果如下:

法线贴图

# 视差贴图

# 实现

视差贴图 (ParallaxMaping) 是法线贴图的一个升级。

效果如下:

视差贴图

完。。。

怎么可能... 当然确实是在法线贴图上改动不是大就是了。
首先是在属性中添加了两个变量 “_ParallaxTex” 即视差计算中需要的高度图,以及 “_ParallaxHeight” 用于调整这个高度图取值的一个 float 变量,一般来说是在 0~0.08 之间,太多就会糊掉了:

_ParallaxTex("Parallax Texture", 2D) = "white"{}
_ParallaxHeight("Parallax Height",RANGE(0,0.08))=0.01

然后,顶点程序基本上没什么变化,只是在片段程序中加了一段代码,用于计算 UV 偏移:

//解压视差贴图
float height = _ParallaxHeight* tex2D(_ParallaxTex, i.uv).a;
float2 uvOffset =height*(V.xy / V.z);

float2 uv = i.uv + uvOffset;

然后使用新的 UV 对法线及贴图进行采样:

//解压法线
float4 normalTex = tex2D(_BumpTex,uv);
//映射至[-1,1]
normalTex = normalTex * 2 - 1;
float3	localNormal;
localNormal = float3(normalTex.ag,0);
localNormal.z = sqrt(1 - dot(localNormal, localNormal));

fixed4 col = tex2D(_MainTex, uv);
float4 diffuse = max(0, dot(localNormal, L))*col;
float4 specialer = col*pow(max(0, dot(V, reflect(-L, localNormal))), 25);
col = col*UNITY_LIGHTMODEL_AMBIENT +diffuse+ specialer;

# 理论

法线贴图虽然可以做到表现一种凹凸不平的质感,不过最好的效果只是针对于垂直的方向,一旦角度变化,这个 “凹凸” 质感就会随视线角度的加大,而变的越发虚假。

视差贴图的理论就是,在法线贴图的基础上,通过计算视线角度对贴图的 UV 也进行一个偏移,增强法线贴图效果同时使其更不宜穿帮... 当然,如果视线与点的夹角太大,依然是毫无作用的。

可以画个图稍微看看:

图示

在此,我们需要计算的,就是最终偏移点的 UV,并使用这个新的 UV 进行采样。
根据三角形相似法则,计算这个偏移量的公式为:

Offsetxh=VxVz{Offset_x \over h}={V_x \over V_z}

Offsetyh=VyVz{Offset_y \over h}={V_y \over V_z}

所以就可由此计算偏移点:

Offsetx=h×VxVzOffset_x={h \times V_x \over V_z}

Offsety=h×VyVzOffset_y={h \times V_y \over V_z}

更详细得情况可以参考维基百科,这儿我就不当搬运工了。

法线贴图与视差贴图的比较

# Unity 提供函数

在 Unity 中,其实也提供了有关法线及视差计算的函数.
法线:

inline fixed3 UnpackNormal(fixed4 packednormal)

视差偏移:

inline float2 ParallaxOffset( half h, half height, half3 viewDir )

有兴趣的话,也可以扒出来看看,虽然有些不一样,不过实际计算是差不多的。特别是视差偏移计算,Unity 的函数多了点步骤:也就是在计算视线的分量之上添加了 0.42,使得比值变小,也就是说最终计算出来的偏移也会变小,相当于缩放,不过要注意的上边计算 h 中,Unity 也是不一样的。所以如果将我上边的视差计算换成 Unity 的 ParallaxOffset 函数,效果会有点轻微差别。
有时候如果觉得自己计算麻烦,这两个函数也都是可以直接拿来用的。

# 源码

//Author:CWHISME
//Date:2016.6.4
//Decription:
Shader "CWH/ParallaxMaping"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
		_BumpTex("Bump Texture", 2D) = "white"{}
		_ParallaxTex("Parallax Texture", 2D) = "white"{}
		_ParallaxHeight("Parallax Height",RANGE(0,0.08))=0.01
	}
	SubShader
	{
		Tags { "RenderType"="Opaque" }
		LOD 100

		Pass
		{
			Tags{"LightMode"="ForwardBase"}
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag

			sampler2D _MainTex;
			sampler2D _BumpTex;
			sampler2D _ParallaxTex;
			float _ParallaxHeight;

			struct appdata
			{
				float4 vertex : POSITION;
				float2 uv : TEXCOORD0;
				float3 normal : NORMAL;
				float4 tangent : TANGENT;
			};

			struct v2f
			{
				float2 uv : TEXCOORD0;
				float4 vertex : SV_POSITION;
				//float3 N : TEXCOORD1;
				//float3 T : TEXCOORD2;
				//float3 Bt : TEXCOORD3;
				float3 V : TEXCOORD1;
				float3 L : TEXCOORD2;
			};

			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
				o.uv = v.uv;
				float3 N = v.normal;
				float3 T = v.tangent;
				float3 Bt =normalize( cross(N,T)*v.tangent.w);

				//构建前往切空间的矩阵
				float3x3 o2Surf = float3x3(T, Bt, N);
				//链接构建世界空间至切空间矩阵
				float3x3 w2Surf = mul(o2Surf, (float3x3)_World2Object);

				float3 wPos = mul(_Object2World,v.vertex);
				float3 V= _WorldSpaceCameraPos - wPos;
				o.V = mul(w2Surf,V);
				o.L = mul(w2Surf,_WorldSpaceLightPos0.xyz);
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				//float3 N = normalize(i.N);
				//float3 T = normalize(i.T);
				//float3 Bt = normalize(i.Bt);
				float3 V = normalize(i.V);
				float3 L = normalize(i.L);
				////构建前往切空间的矩阵
				//float3x3 o2Surf = float3x3(T,Bt,N);
				////链接构建世界空间至切空间矩阵
				//float3x3 w2Surf = mul(o2Surf,(float3x3)_World2Object);

				//float3 L = mul(w2Surf,_WorldSpaceLightPos0.xyz);

				//解压视差贴图
				float height = _ParallaxHeight* tex2D(_ParallaxTex, i.uv).a;
				float2 uvOffset =height*(V.xy / V.z);

				float2 uv = i.uv + uvOffset;

				//解压法线
				float4 normalTex = tex2D(_BumpTex,uv);
				//映射至[-1,1]
				normalTex = normalTex * 2 - 1;
				float3	localNormal;
				localNormal = float3(normalTex.ag,0);
				localNormal.z = sqrt(1 - dot(localNormal, localNormal));

				fixed4 col = tex2D(_MainTex, uv);
				float4 diffuse = max(0, dot(localNormal, L))*col;
				float4 specialer = col*pow(max(0, dot(V, reflect(-L, localNormal))), 25);
				col = col*UNITY_LIGHTMODEL_AMBIENT +diffuse+ specialer;
				return col;
			}
			ENDCG
		}
	}
}

# 结尾

本来打算一鼓作气就把这篇 Blog 写完的,结果理想是丰富的,现实,是骨感的。写法线贴图居然就花了一上午,而且回头看,居然内容也不是很多的样子...
感觉像是回到了小学 (或者初中?),当年考试的时候,试卷都从来没有做完的时候。
特别是语文,基本上阅读都还没做... 完?突然就发现考试都结束了!
然后被问到为什么成绩这么差?就只好说:这不是我自己不行,是因为时间太少了啊,试卷一大半都还没做,结果能不差嘛?

呃.....