Unity Shader入门精要笔记(十三):法线纹理

时间:2021-08-22 04:13:17

本系列文章由Aimar_Johnny编写,欢迎转载,转载请标明出处,谢谢。

http://blog.csdn.net/lzhq1982/article/details/75212518


在说法线纹理之前,我们要先介绍一下凹凸映射。如前面光照篇介绍的内容得知,法线参与光线反射的过程,物体表面法线与光线的角度直接影响漫反射的颜色和强度,所以可以说法线方向可以间接影响我们看到物体的凹凸程度。 正常的法线是垂直于当前的面片的,我们为了展现物体表面的精细程度,可以无限增加面片数,也就是我们常说的高模,可是众所周知,我们的硬件情况尤其是移动设备的硬件情况是不允许太多的三角形片面的存在的,那么有没有办法可以用相对少量的三角形面片实现近似高模的感觉,这就要用凹凸纹理了,凹凸映射的目的是使用一张纹理来修改模型表面的法线,这样用纹理的法线代替模型的法线,然后参与反射的过程,使之看起来有凹凸不平的效果,达到不增加面片而提供更多表面细节的目的。

有两种主要的方法来进行凹凸映射:一种是用高度纹理(height map)来模拟表面位移,然后得到一个修改后的法线值,这种方法也叫高度映射(height mapping);另一种方法则是用一张法线纹理(normal map)来直接存储表面法线,这种方法被称为法线映射(normal mapping)

1、高度纹理

在说法线纹理之前我们先简要介绍一个高度纹理。我们需要一张高度图来实现凹凸映射。高度图中存储的是强度值,用于表示模型表面局部的海拔高度。颜色越浅表明该位置的表面越向外凸起,颜色越深表明该位置越向里凹。所以我们能从高度图明确的知道一个模型表面的凹凸情况。但缺点是计算复杂,不能直接得到表面法线,需要由像素灰度值计算得到。下面是一张高度图:

Unity Shader入门精要笔记(十三):法线纹理

2、法线纹理

这是今天介绍的重点,也是普遍应用的技术。法线纹理存的是表面法线的方向。但法线方向分量范围是在[-1, 1],而像素分量范围是[0, 1],所以我们要做一个映射:

pixel = (normal + 1) / 2

所以我们在Shader中对法线纹理采样后,还需要对结果进行一次反映射来得到原来的法线方向。过程其实就是用上面公式的逆推:

normal = pixel * 2 - 1

方向是相对于坐标空间来说的,那么法线纹理中存储的法线方向在哪个坐标空间呢?可以是模型空间的法线纹理,也可以是切线空间的法线纹理。

a、模型空间的法线纹理

对于模型顶点自带的法线,是定义在模型空间的,所以一种直接的想法是将修改的模型空间的法线存在一张纹理中,得到的即是模型空间的法线纹理。

b、切线空间的法线纹理

然而这才是主流的法线纹理,对于模型的每个顶点,都有一个属于自己的切线空间,其原点是该顶点,z轴是法线方向(n),x轴是切线方向(t),y轴可由法线和切线叉积得到,也称为副切线(bitangent, b)。如下图:

Unity Shader入门精要笔记(十三):法线纹理

上面介绍了两种纹理的来源,下面我们直接看一下两种纹理的图:

Unity Shader入门精要笔记(十三):法线纹理

相信大家常见的就是右侧那种蓝色的图。模型空间的法线纹理看起来是五颜六色的。这是因为所有法线所在的坐标空间是同一个空间,即模型空间。而每个点的法线方向是各异的,基本对应了[0, 1]全范围,所以什么颜色都有。而切线空间下的法线为什么是蓝的呢?我们要知道,每个法线都对应自己的切线空间,这种空间的法线纹理其实就是存储了每个点在各自的切线空间中的法线扰动方向。除非我们想表现凹凸的部分,大部分都是其空间z轴方向,即为(0, 0 ,1),映射后就是RGB(0.5, 0,5, 1)的浅蓝色。这些蓝色也说明顶点的大部分法线是和模型本身法线一样的。那么为什么切线空间的法线纹理是主流呢。我们先看看两种空间的优缺点:

1)模型空间的优势

a、实现简单,更加直观。我们不需要模型原始的法线和切线信息,计算更少。

b、在纹理坐标的缝合处和尖锐的边角部分,可见的突变(缝隙)较少,即可以提供平滑的边界。因为都处于同一坐标系下,边界处通过插值得到的法线可以平滑变换。而切线空间下的法线信息是依靠每个独立坐标系生成的,不连续性可能造成边缘处或尖锐部分有可见的缝合迹象。

2)切线空间的优势

a、*度很高。模型空间下的法线纹理记录的是绝对法线信息,仅可以用于它对应的那个模型。而切线空间下的法线纹理记录的是相对的法线信息,即使把该纹理对应到一个完全不同的网格上,也可以得到一个合理的结果。

b、可进行UV动画。比如我们可以移动一个纹理的UV坐标来实现一个凹凸移动的效果,但使用模型空间下的法线纹理就会得到完全错误的结果。这种UV动画在水或者火山熔岩这类物体上会经常用到。

c、可以重用法线纹理。比如一个砖块,我们仅使用一个纹理就可以用到所有的6个面上。

d、可压缩。切线空间下的法线纹理的z方向总是正方向,所以我们可以仅存储xy方向,推导出z方向。而模型空间下的法线由于每个方向都有可能,所以必须存储3个方向的值,不可压缩。

切线空间下的法线纹理的前两个优点足以让人放弃模型空间了。切线空间在很多情况下都优于模型空间,所以我们也用切线空间下的法线纹理。

3、法线纹理的实现

要实现法线纹理,首先我们要统一计算的坐标空间。由于我们要说的是切线空间下的法线纹理,那有两种选择:一种是在切线空间下进行光照计算,则我们要把光照方向、视角方向变换到切线空间下;另一种是在世界空间下进行光照计算,则我们需要把采样得到的法线方向变换到世界空间下,再和世界空间下的光照和视角方向进行计算。第一种方法效率高一些,因为顶点着色器就可以完成对光照方向和视角方向的转换,而第二种采样是在片元着色器,所以计算也在片元着色器。第二种方法通用性好一些,因为除了法线其他很多计算都方便在世界空间,如果在切线空间,那么都要转换,比较不方便。

即便上面说了两种方法,但作者已经在关于法线转换的问题这个网页里说明在切线空间计算的问题了,所以我这里只说转换到世界空间计算的方法,对切线空间下计算有兴趣的读者去看书吧。

在世界空间下计算光照模型,我们需要在片元着色器中把法线方向从切线空间变换到世界空间下。所以在顶点着色器中,我们计算从切线空间到世界空间的变换矩阵,并传给片元着色器。变换矩阵的计算可以由顶点的切线,副切线,法线在世界空间下的表示来得到。这样在片元着色器中我们就可以用变换矩阵将法线纹理采样的法线信息变换到世界空间下,从而参与光照计算。直接上代码:

Shader "CustomShader/Texture/BumpWorldSpaceShader"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_BumpTex ("Normal Map", 2D) = "bump" {}
_BumpScale ("Bump Scale", Float) = 1.0
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
Pass
{
Tags {"LightMode" = "ForwardBase"}

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpTex;
float4 _BumpTex_ST;
float _BumpScale;
fixed4 _Color;
fixed4 _Specular;
float _Gloss;

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

struct v2f
{
float4 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float4 TtoW0 : TEXCOORD1;
float4 TtoW1 : TEXCOORD2;
float4 TtoW2 : TEXCOORD3;
};

v2f vert (appdata v)
{
v2f o;
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);

o.uv.xy = TRANSFORM_TEX(v.uv.xy, _MainTex);
o.uv.zw = TRANSFORM_TEX(v.uv.xy, _BumpTex);

float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent);
fixed3 binormal = cross(worldNormal, worldTangent) * v.tangent.w;

o.TtoW0 = float4(worldTangent.x, binormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, binormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, binormal.z, worldNormal.z, worldPos.z);

return o;
}

fixed4 frag (v2f i) : SV_Target
{
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);

fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));

fixed4 bumpColor = tex2D(_BumpTex, i.uv.zw);
fixed3 tangentNormal;
//tangentNormal.xy = (bumpColor.xy * 2 - 1) * _BumpScale;
//tangentNormal.z = sqrt(1 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
tangentNormal = UnpackNormal(bumpColor);
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

float3x3 t2wMatrix = float3x3(i.TtoW0.xyz, i.TtoW1.xyz, i.TtoW2.xyz);
tangentNormal = normalize(half3(mul(t2wMatrix, tangentNormal)));

fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Color.rgb;

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(tangentNormal, lightDir));

fixed3 halfDir = normalize(viewDir + lightDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss);

return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}

FallBack "Specular"
}

代码很长,这应该是从前面学过来最长的代码了,不过没关系,我们慢慢看。

a、首先是属性部分

Properties
{
    _MainTex ("Texture"2D) = "white" {}
    _BumpTex ("Normal Map"2D) = "bump" {}
    _BumpScale ("Bump Scale"Float) = 1.0
    _Color ("Color Tint"Color) = (1111)
    _Specular ("Specular"Color) = (1111)
    _Gloss ("Gloss"Range(8.0256)) = 20
}

_MainTex是主纹理,_BumpTex才是法线纹理,_BumpScale是控制凹凸程度的变量,其他都是参与光照的,不解释。

b、在appdata的顶点输入结构体中,我们传入了normal顶点法线和tangent顶点切线

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

在v2f顶点输出结构体中,我们定义了三个float4向量TtoW0,TtoW1,TtoW2

struct v2f
{
    float4 uv : TEXCOORD0;
    float4 vertex : SV_POSITION;
    float4 TtoW0 : TEXCOORD1;
    float4 TtoW1 : TEXCOORD2;
    float4 TtoW2 : TEXCOORD3;
};

还记得我在原理中说过,我们要在顶点着色器中得到从切线空间变换到世界空间的转换矩阵吗,所以这三个向量就是变换矩阵的组成部分,我们要传给片元处理。

c、原理中还说过,变换矩阵的计算可以由顶点的切线,副切线,法线在世界空间下的表示来得到,模型的切线和法线我们有了,直接转换到世界空间就可,副切线我们需要叉乘一下。

fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent);
fixed3 binormal = cross(worldNormal, worldTangent) * v.tangent.w;

眼尖的读者会看到计算副切线时我们乘了个v.tangent.w,这是因为和切线,法线都垂直的方向有两个,而w决定了我们选择哪一个方向。最后我们把它们赋给v2f结构体传出去即可。

o.TtoW0 = float4(worldTangent.x, binormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, binormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, binormal.z, worldNormal.z, worldPos.z);

注意,我们用它们的w分量存了一个worldPos,因为worldPos参与片元的光照计算,所以要传出去,这样传可以省一个传递参数,优化计算是我们要时刻注意的。

d、我们把主纹理和法线纹理的纹理坐标分别存在了o.uv的xy和zw里。

o.uv.xy = TRANSFORM_TEX(v.uv.xy, _MainTex);
o.uv.zw = TRANSFORM_TEX(v.uv.xy, _BumpTex);

f、片元着色器是计算的重点

我们先把worldPos提取出来,并参与计算世界空间的光照方向和视角方向。

float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));

下面该说重点了,首先我们要对法线纹理进行采样。

fixed4 bumpColor = tex2D(_BumpTex, i.uv.zw);

采样的信息是法线对应的xy方向信息,开篇时说过转换原理,因为颜色分量是[0, 1],而法线是[-1, 1],所以我们要做个转换,先看我注释掉的地方:

fixed3 tangentNormal;

tangentNormal.xy = (bumpColor.xy * 2 - 1) * _BumpScale;

tangentNormal.z = sqrt(1 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

第一行就是转换,顺便乘了下凹凸程度,因为法线是单位向量,所以z可以直接由xy分量求得,也就是第二行。Unity中,为了方便Unity对法线纹理的存储进行优化,我们通常会把法线纹理的纹理类型标识成Normal map(详见纹理属性篇),Unity会根据不同平台选择不同的压缩方法。因为压缩方法的不同,_BumpMap的rgb分量就不再是切线空间下的xyz值了,那用上面的公式就错了。这时就用Unity内置函数UnpackNormal来得到正确的法线方向。

tangentNormal = UnpackNormal(bumpColor);

当然也不要忘了对其xy乘以_BumpScale,并且求z。

然后开始把切线空间的法线变换到世界空间了。顶点着色器已经传过来了转换矩阵的三行数据,我们再组装成矩阵,左乘变换即可。

float3x3 t2wMatrix = float3x3(i.TtoW0.xyz, i.TtoW1.xyz, i.TtoW2.xyz);
tangentNormal = normalize(half3(mul(t2wMatrix, tangentNormal)));

后面都是光照部分了,用上面计算出来的法线tangentNormal参与光照计算,光照部分就不解释了,不清楚的看前面的光照篇吧。

最后我们看一下效果图:

Unity Shader入门精要笔记(十三):法线纹理Unity Shader入门精要笔记(十三):法线纹理


最后注意一点,因为我们用了Unity内置函数UnpackNormal,那一定不要忘了设置法线纹理的类型为Normal map,否则会得不到想要的效果。

Unity Shader入门精要笔记(十三):法线纹理