US3S2L6——切线空间下计算法线纹理贴图

导入法线贴图资源

对于法线贴图,它的纹理类型需要设置为 Normal Map

image

关键知识点补充

  1. 模型空间下的切线数据

    模型数据中的切线数据为 float4​ 类型的(四维向量),其中的 w​ 表示副切线的方向
    用法线和切线叉乘得到的副切线方向可能有两个(如下图的两个方向的灰色线),用切线数据中的 w​ 与之相乘确定副切线方向

    image

  2. Unity 当中的法线纹理类型

    当我们把纹理类型设置为 Normal map(法线贴图)时,
    我们可以使用Unity提供的内置函数 UnpackNormal​ 来得到正确的法线方向。
    该函数内部不仅可以进行 法线分量=像素分量×21法线分量 = 像素分量 \times 2 - 1的逆运算,
    还会进行解压运算(Unity 会根据不同平台对法线纹理进行压缩)

    image

  3. 法线纹理属性命名一般为 _BumpMap​(凸块贴图),
    并声明一个我们还会声明一个名为 _BumpScale​(凸块缩放))的 float​ 属性,它主要用于控制凹凸程度

    当它为 0 时,表示没有法线效果,法线的影响会被消除
    当它为 1 时,表示使用法线贴图中的原始法线信息,没有缩放
    我们可以根据实际需求调整它的值,来达到视觉上令人满意的效果

  4. 如果使用的凹凸纹理不是法线纹理,而是高度纹理

    我们需要进行如下设置:

    图片类型设置为 Normal map(法线贴图)并勾选 Create from Grayscale(从灰度创建)
    这样我们就可以把高度纹理当成切线空间下的法线纹理处理了

    多出的参数分别为:

    image

    • Bumpiness(颠簸值):控制凹凸程度

    • Filtering(过滤模式):决定凹凸程度的算法

      image

      • Sharp:滤波生成法线
      • Smooth:平滑的生成法线

在切线空间下计算实现法线纹理Shader

主要思路:

  • 在顶点着色器中计算模型空间到切换空间的变换矩阵,将光线方向和观察方向通过变换矩阵转换为切线空间下的
  • 在片元着色器中对法线纹理采样,得到模型法线数据,结合之前得到的法线,光线方向和观察方向,通过光照模型计算光照

Shader 属性相关

  • 漫反射颜色
  • 单张纹理
  • 法线纹理
  • 凹凸程度(一般使用 0~1 即可)
  • 高光反射颜色
  • 光泽度
1
2
3
4
5
6
7
8
9
Properties
{
_MainColor("MainColor", Color) = (1, 1, 1, 1) //漫反射颜色
_MainTex("MainTex", 2D) = ""{} //主要纹理贴图
_BumpMap("BumpMap", 2D) = ""{} //凹凸纹理贴图
_BumpScale("BumpScale", Range(0, 1)) = 1 //凹凸程度
_SpecularColor("SpecularColor", Color) = (1, 1, 1, 1) //高光反射颜色
_SpecularNum("SpecularNum", Range(0, 20)) = 18 //光泽度
}

结构体相关

  • 传入顶点着色器的结构体

    可以直接使用 UnityCG.cginc​ 中的 appdata_full​
    其中包含了我们需要的顶点、法线、切线、纹理坐标相关数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // ​UnityCG.cginc​内定义,无需自己再定义
    struct appdata_full {
    float4 vertex : POSITION; //模型空间中的顶点位置
    float4 tangent : TANGENT; //顶点切线
    float3 normal : NORMAL; //顶点法线
    float4 texcoord : TEXCOORD0; //第一组纹理坐标
    float4 texcoord1 : TEXCOORD1; //第二组纹理坐标
    float4 texcoord2 : TEXCOORD2; //第三组纹理坐标
    float4 texcoord3 : TEXCOORD3; //第四组纹理坐标
    fixed4 color : COLOR; //顶点颜色
    UNITY_VERTEX_INPUT_INSTANCE_ID
    };
  • 传入片元着色器的结构体

    需要自定义一个结构体,其中包含 裁剪空间下坐标、uv坐标、相对于切线空间下的光的方向、视角的方向

    1
    2
    3
    4
    5
    6
    7
    8
    9
    struct v2f
    {
    float4 pos: SV_POSITION;
    //float2 uvTex: TEXCOORD0;
    //float2 uvBump: TEXCOORD1; //可以使用两个float2来分别存储主要纹理的uv和法线纹理的uv
    float4 uv: TEXCOORD0; //可以使用一个float4来同时存储主要纹理的uv(xy存储)和法线纹理的uv(zw存储)
    float3 lightDir: TEXCOORD1; //相对于切线空间下的光的方向
    float3 viewDir: TEXCOORD2; //相对于切线空间下的视角方向
    };

顶点着色器回调函数内容

执行步骤:

  1. 顶点坐标模型转裁剪

  2. 单张纹理 和 法线纹理 的UV坐标缩放偏移计算

  3. 副切线计算

    用模型空间中的法线和切线进行叉乘,再乘以切线中的 w​(确定副切线方向)

    1
    2
    // 假设v.tangent是切线(float4类型),v.normal是法线,(float3类型)
    float3 binormal = cross(normalize(v.tangent), normalize(v.normal)) * v.tangent.w;
  4. 构建模型空间到切线空间的变换矩阵

    模型空间到切线空间的变换矩阵为:(切线副切线法线)\begin{pmatrix} — & 切线 & — \\ — & 副切线 & — \\ — & 法线 & — \\ \end{pmatrix}

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 假设v.tangent是切线(float4类型),v.normal是法线(float3类型)
    // 计算副切线
    float3 binormal = cross(normalize(v.tangent), normalize(v.normal)) * v.tangent.w;
    // 得到模型空间到切线空间的转换矩阵
    float3x3 rotation = float3x3(
    v.tangent.xyz, //切线
    binormal, //副切线
    v.normal, //法线
    )
  5. 将光照方向和视角方向转换到模型空间(利用 ObjSpaceLightDir​ 和 ObjSpaceViewDir​ 内置函数)

  6. 将光照方向和视角方向转换到切线空间(利用变换矩阵进行乘法运算)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 注:appdata_full不需要自定义,#include "UnityCG.cginc"即可使用,这里仅用于展示其成员
struct appdata_full {
float4 vertex : POSITION; //模型空间中的顶点位置
float4 tangent : TANGENT; //顶点切线
float3 normal : NORMAL; //顶点法线
float4 texcoord : TEXCOORD0; //第一组纹理坐标
//...
};

struct v2f
{
float4 pos: SV_POSITION;
float4 uv: TEXCOORD0; //xy存储主要纹理的uv,zw存储法线纹理的uv
float3 lightDir: TEXCOORD1; //相对于切线空间下的光的方向
float3 viewDir: TEXCOORD2; //相对于切线空间下的视角方向
};

float4 _MainTex_ST; //颜色纹理的缩放和平移
float4 _BumpMap_ST; //法线纹理的缩放和平移

v2f vert (appdata_full v)
{
v2f data;

data.pos = UnityObjectToClipPos(v.vertex); //计算裁剪空间下顶点坐标
// 分别计算主纹理和法线纹理的缩放平移
data.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
data.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

// 计算副切线
float3 binormal = cross(normalize(v.tangent), normalize(v.normal)) * v.tangent.w;
// 得到模型空间到切线空间的转换矩阵
float3x3 rotation = float3x3(
v.tangent.xyz, //切线
binormal, //副切线
v.normal //法线
);

data.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)); // 切线空间下的光的方向
data.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)); // 切线空间下的视角方向

return data;
}

片元着色器回调函数内容

执行步骤:

  1. 取出法线贴图中的法线信息(利用纹理采样函数 tex2D​ )
  2. 利用内置的 UnpackNormal​ 函数对法线信息进行逆运算以及可能的解压
  3. 用得到的切线空间的法线数据 乘以 BumpScale​ 来控制凹凸程度
  4. 得到单张纹理颜色和漫反射颜色的叠加颜色
  5. 用切线空间下的光方向、视角方向、法线方向,进行 Blinn Phong光照模型 计算
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
struct v2f
{
float4 pos: SV_POSITION;
//float2 uvTex: TEXCOORD0;
//float2 uvBump: TEXCOORD1; //可以使用两个float2来分别存储主要纹理的uv和法线纹理的uv
float4 uv: TEXCOORD0; //可以使用一个float4来同时存储主要纹理的uv(xy存储)和法线纹理的uv(zw存储)
float3 lightDir: TEXCOORD1; //相对于切线空间下的光的方向
float3 viewDir: TEXCOORD2; //相对于切线空间下的视角方向
};

float4 _MainColor; //漫反射颜色
sampler2D _MainTex; //颜色纹理
sampler2D _BumpMap; //法线纹理
float _BumpScale; //凹凸程度
float4 _SpecularColor; //高光颜色
fixed _SpecularNum; //光泽度

fixed4 frag (v2f i) : SV_Target
{
float4 packedNormal = tex2D(_BumpMap, i.uv.zw); // 获取法线纹理的颜色数据
float3 tangentNormal = UnpackNormal(packedNormal); // 将颜色数据逆运算并解压缩,得到切线空间下法线数据
tangentNormal *= _BumpScale; // 将法线数据乘以凹凸系数

// 计算带纹理颜色的BlinnPhong光照计算,这里使用已经计算好的切线数据
fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _MainColor.rgb; // 反射率

// 漫反射光照计算:这里需要使用已经计算完毕的切线数据和光照方向,注意!这里的 tangentNormal 无需归一化
fixed3 lambertColor = _LightColor0.rgb * albedo.rgb * max(0, dot(tangentNormal, normalize(i.lightDir)));
// 高光反射光照计算:这里需要使用已经计算完毕的切线数据和光照方向
float3 halfA = normalize(normalize(i.viewDir) + normalize(i.lightDir)); // 半角向量
fixed3 specularColor = _LightColor0.rgb * _SpecularColor.rgb * pow(max(0, dot(tangentNormal, halfA)), _SpecularNum);
// 最终颜色计算
fixed3 color = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo + lambertColor + specularColor;

return fixed4(color.rgb, 1);
}

代码示例

完整 Shader 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
Shader "TeachShader/Lesson51"
{
Properties
{
_MainColor("MainColor", Color) = (1, 1, 1, 1)
_MainTex("MainTex", 2D) = ""{}
_BumpMap("BumpMap", 2D) = ""{}
_BumpScale("BumpScale", Range(0, 1)) = 1
_SpecularColor("SpecularColor", Color) = (1, 1, 1, 1)
_SpecularNum("SpecularNum", Range(0, 20)) = 18
}
SubShader
{
Pass
{
Tags { "LightMode" = "ForwardBase" }

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "Lighting.cginc"

struct v2f
{
float4 pos: SV_POSITION;
//float2 uvTex: TEXCOORD0;
//float2 uvBump: TEXCOORD1; //可以使用两个float2来分别存储主要纹理的uv和法线纹理的uv
float4 uv: TEXCOORD0; //可以使用一个float4来同时存储主要纹理的uv(xy存储)和法线纹理的uv(zw存储)
float3 lightDir: TEXCOORD1; //相对于切线空间下的光的方向
float3 viewDir: TEXCOORD2; //相对于切线空间下的视角方向
};

float4 _MainColor; //漫反射颜色
sampler2D _MainTex; //颜色纹理
float4 _MainTex_ST; //颜色纹理的缩放和平移
sampler2D _BumpMap; //法线纹理
float4 _BumpMap_ST; //法线纹理的缩放和平移
float _BumpScale; //凹凸程度
float4 _SpecularColor; //高光颜色
fixed _SpecularNum; //光泽度

v2f vert (appdata_full v)
{
v2f data;

data.pos = UnityObjectToClipPos(v.vertex); //计算裁剪空间下顶点坐标
// 分别计算主纹理和法线纹理的缩放平移
data.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
data.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

// 计算副切线
float3 binormal = cross(normalize(v.tangent), normalize(v.normal)) * v.tangent.w;
// 得到模型空间到切线空间的转换矩阵
float3x3 rotation = float3x3(
v.tangent.xyz, //切线
binormal, //副切线
v.normal //法线
);

data.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)); // 切线空间下的光的方向
data.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)); // 切线空间下的视角方向

return data;
}

fixed4 frag (v2f i) : SV_Target
{
float4 packedNormal = tex2D(_BumpMap, i.uv.zw); // 获取法线纹理的颜色数据
float3 tangentNormal = UnpackNormal(packedNormal); // 将颜色数据逆运算并解压缩,得到切线空间下法线数据
tangentNormal *= _BumpScale; // 将法线数据乘以凹凸系数

// 计算带纹理颜色的BlinnPhong光照计算,这里使用已经计算好的切线数据
fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _MainColor.rgb; // 反射率

// 漫反射光照计算:这里需要使用已经计算完毕的切线数据和光照方向,注意!这里的 tangentNormal 无需归一化
fixed3 lambertColor = _LightColor0.rgb * albedo.rgb * max(0, dot(tangentNormal, normalize(i.lightDir)));
// 高光反射光照计算:这里需要使用已经计算完毕的切线数据和光照方向
float3 halfA = normalize(normalize(i.viewDir) + normalize(i.lightDir)); // 半角向量
fixed3 specularColor = _LightColor0.rgb * _SpecularColor.rgb * pow(max(0, dot(tangentNormal, halfA)), _SpecularNum);
// 最终颜色计算
fixed3 color = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo + lambertColor + specularColor;

return fixed4(color.rgb, 1);
}
ENDCG
}
}
}

显示效果(左图为不使用法线贴图的方块,右图为使用法线贴图的方块):

image

可见,使用法线纹理贴图的模型,虽然实际上没有做凹凸面,但是通过法线影响光照,通过光线模拟出了凹凸感

修改凹凸系数的计算方式,让法线系数不影响光照

目前的 Shader 写法中,凹凸系数 BumpScale​ 会影响光照,凹凸系数越小,光照越暗

image

我们目前这种直接让 法线×凹凸系数法线 \times 凹凸系数 的计算方式,并不是一个标准算法
因为当凹凸系数趋近于 0 时,会影响光照模型的计算(法线也趋于0了,导致计算出来的颜色是偏黑的)

为了让凹凸系数不影响光的效果,有一种专门的算法

  1. 只让法线中的 xy 乘以凹凸系数

    1
    tangentNormal.xy *= _BumpScale;
  2. 同时保证法线为单位向量(让法线不会为0,而是趋近于顶点法线)

    若要让法线在 xy 乘以凹凸系数后还要保持为单位向量,就需要让 z 分量进行改变
    根据单位向量的性质可得:

    (x,y,z)为单位向量x2+y2+z2=1z2=1(x2+y2)z=1(x2+y2)\because (x,y,z)为单位向量 \\ \therefore x^2 + y^2 + z^2 = 1 \\ \therefore z^2 = 1 - (x^2 + y^2) \\ \therefore z = \sqrt{1 - (x^2 + y^2)}

    又因为 x2+y2=(x,y)(x,y)x^2 + y^2 = (x,y) \cdot (x,y),因此代码内可实现为:

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

    其中,saturate​ 函数是保证计算结果在 0~1 之间的

通过这样的计算,当凹凸系数在 0~1 之间变化时,会保证法线为单位向量,这样就不会影响光照表现了

注意:这种算法并不是来自真实的物理规律,只是为了 “看起来正常”

因此,根据以上规则,片元着色器的 Shader 代码修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fixed4 frag (v2f i) : SV_Target
{
float4 packedNormal = tex2D(_BumpMap, i.uv.zw); // 获取法线纹理的颜色数据
float3 tangentNormal = UnpackNormal(packedNormal); // 将颜色数据逆运算并解压缩,得到切线空间下法线数据
// 将法线数据的xy乘以凹凸系数,根据xy修正z,避免凹凸系数影响光照亮度
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

// 计算带纹理颜色的BlinnPhong光照计算,这里使用已经计算好的切线数据
fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _MainColor.rgb; // 反射率

// 漫反射光照计算:这里需要使用已经计算完毕的切线数据和光照方向,注意!这里的 tangentNormal 无需归一化
fixed3 lambertColor = _LightColor0.rgb * albedo.rgb * max(0, dot(tangentNormal, normalize(i.lightDir)));
// 高光反射光照计算:这里需要使用已经计算完毕的切线数据和光照方向
float3 halfA = normalize(normalize(i.viewDir) + normalize(i.lightDir)); // 半角向量
fixed3 specularColor = _LightColor0.rgb * _SpecularColor.rgb * pow(max(0, dot(tangentNormal, halfA)), _SpecularNum);
// 最终颜色计算
fixed3 color = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo + lambertColor + specularColor;

return fixed4(color.rgb, 1);
}

效果如下(左侧方块的凹凸系数为1,右侧方块的凹凸系数为0.3):

image

可见,现在的凹凸系数可以在不影响光照的情况下,改变模型凹凸感了

修改后的完整 Shader 代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
Shader "TeachShader/Lesson51"
{
Properties
{
_MainColor("MainColor", Color) = (1, 1, 1, 1)
_MainTex("MainTex", 2D) = ""{}
_BumpMap("BumpMap", 2D) = ""{}
_BumpScale("BumpScale", Range(0, 1)) = 1
_SpecularColor("SpecularColor", Color) = (1, 1, 1, 1)
_SpecularNum("SpecularNum", Range(0, 20)) = 18
}
SubShader
{
Pass
{
Tags { "LightMode" = "ForwardBase" }

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "Lighting.cginc"

struct v2f
{
float4 pos: SV_POSITION;
//float2 uvTex: TEXCOORD0;
//float2 uvBump: TEXCOORD1; //可以使用两个float2来分别存储主要纹理的uv和法线纹理的uv
float4 uv: TEXCOORD0; //可以使用一个float4来同时存储主要纹理的uv(xy存储)和法线纹理的uv(zw存储)
float3 lightDir: TEXCOORD1; //相对于切线空间下的光的方向
float3 viewDir: TEXCOORD2; //相对于切线空间下的视角方向
};

float4 _MainColor; //漫反射颜色
sampler2D _MainTex; //颜色纹理
float4 _MainTex_ST; //颜色纹理的缩放和平移
sampler2D _BumpMap; //法线纹理
float4 _BumpMap_ST; //法线纹理的缩放和平移
float _BumpScale; //凹凸程度
float4 _SpecularColor; //高光颜色
fixed _SpecularNum; //光泽度

v2f vert (appdata_full v)
{
v2f data;

data.pos = UnityObjectToClipPos(v.vertex); //计算裁剪空间下顶点坐标
// 分别计算主纹理和法线纹理的缩放平移
data.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
data.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

// 计算副切线
float3 binormal = cross(normalize(v.tangent), normalize(v.normal)) * v.tangent.w;
// 得到模型空间到切线空间的转换矩阵
float3x3 rotation = float3x3(
v.tangent.xyz, //切线
binormal, //副切线
v.normal //法线
);

data.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)); // 切线空间下的光的方向
data.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)); // 切线空间下的视角方向

return data;
}

fixed4 frag (v2f i) : SV_Target
{
float4 packedNormal = tex2D(_BumpMap, i.uv.zw); // 获取法线纹理的颜色数据
float3 tangentNormal = UnpackNormal(packedNormal); // 将颜色数据逆运算并解压缩,得到切线空间下法线数据
// 将法线数据的xy乘以凹凸系数,根据xy修正z,避免凹凸系数影响光照亮度
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

// 计算带纹理颜色的BlinnPhong光照计算,这里使用已经计算好的切线数据
fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _MainColor.rgb; // 反射率

// 漫反射光照计算:这里需要使用已经计算完毕的切线数据和光照方向,注意!这里的 tangentNormal 无需归一化
fixed3 lambertColor = _LightColor0.rgb * albedo.rgb * max(0, dot(tangentNormal, normalize(i.lightDir)));
// 高光反射光照计算:这里需要使用已经计算完毕的切线数据和光照方向
float3 halfA = normalize(normalize(i.viewDir) + normalize(i.lightDir)); // 半角向量
fixed3 specularColor = _LightColor0.rgb * _SpecularColor.rgb * pow(max(0, dot(tangentNormal, halfA)), _SpecularNum);
// 最终颜色计算
fixed3 color = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo + lambertColor + specularColor;

return fixed4(color.rgb, 1);
}
ENDCG
}
}
}