0%

【Unity Shader】(二)基础纹理

这一节我们将学习纹理映射的实现方式,包括单张图片纹理,以及在游戏中广泛应用的凹凸纹理、渐变纹理和遮罩纹理。

1 单张纹理

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
Shader "Unity Shaders Book/Chapter 7/Single Texture" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
// 使用纹理代替之前的漫反射颜色,纹理的类型是2D,使用"white" {}将纹理默认值设为白色,可以在材质面板中选择其他纹理
_MainTex ("Main Tex", 2D) = "white" {}
_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"

fixed4 _Color;
// 纹理的类型是sampler2D
sampler2D _MainTex;
// 在Unity中,我们需要使用纹理名_ST的方式来声明某个纹理的属性,其中,ST是缩放(scale)和平移(translation)的缩写
// _MainTex_ST.xy 存储的是缩放值,_MainTex_ST.zw 存储的是偏移值,用于后续的纹理坐标变换
float4 _MainTex_ST;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0; //用第一组纹理坐标填充texcoord变量
};

struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2; //存储纹理坐标的变量
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

o.worldNormal = UnityObjectToWorldNormal(v.normal);

o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

// 对纹理坐标进行变换,因为顶点纹理坐标在[0,1],因此要用缩放和平移对纹理坐标进行变换
o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
// 也可以直接调用内置函数TRANSFORM_TEX,第一个参数是顶点纹理坐标,第二个参数是纹理名字
// o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);

return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

// 使用tex2D函数对纹理进行采样,第一个参数是被采样的纹理,第二个参数是纹理坐标
// 将纹理颜色和颜色属性_Color的乘积作为反射率
fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
// 用反射率乘以环境光作为环境光项
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
// 用反射率作为漫反射颜色计算漫反射项
fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));
// 高光项
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);

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

ENDCG
}
}
FallBack "Specular"
}

渲染效果如下:

image-20220402212721166

2 凹凸纹理

之前学习过凹凸纹理的实现有两种方式,一种是使用高度贴图,纹理中记录顶点法线的位移(高度),但是这样做不够逼真,物体边缘处还是平滑的,很容易看出破绽;另一种是法线贴图,纹理中直接记录每个顶点的法线方向,由于法线方向的分量范围在[-1, 1],而像素的分量范围为[0, 1],因此我们需要做一个映射,通常使用的映射就是:
$$
piexl = \frac{normal + 1}{2}
$$
这就要求我们对法线纹理进行纹理采样后,还要进行一个反映射,即:
$$
normal = pixel \times 2 - 1
$$
既然记录的是法线方向,那么一定有一个参考坐标系,最简单的当然是直接记录模型空间下的法线方向,这样我们可以正常转换到世界空间然后直接计算光照,这种纹理称为模型空间下的法线纹理;更好的方式是记录每个顶点的切线空间下的法线方向,所谓切线空间是指以顶点切线为 x 轴,副切线为 y 轴,顶点法线为 z 轴的空间,这种纹理称为切线空间下的法线纹理。为什么存储切线空间中的纹理更好呢?我们可以对比一下两种法线纹理的优缺点:

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

显然切线空间下的法线纹理好处更多。

接下来实现凹凸纹理的效果。我们需要在计算光照模型中统一各个方向矢量所在的坐标空间。 由于法线纹理中存储的法线是切线空间下的方向,因此我们通常有两种选择:一种选择是在切线空间下进行光照计算,此时我们需要把光照方向、视角方向变换到切线空间下;另一种选择是在世界空间下进行光照计算,此时我们需要把采样得到的法线方向变换到世界空间下,再和世界空间下的光照方向和视角方向
进行计算。从效率上来说,第一种方法往往要优于第二种方法,因为我们可以在顶点着色器中就完成对光照方向和视角方向的变换,而第二种方法由于要先对法线纹理进行采样,所以变换过程必须在片元着色器中实现,这意味着我们需要在片元着色器中进行一次矩阵操作。但从通用性角度来说,第二种方法要优于第一种方法,因为有时我们需要在世界空间下进行一些计算,例如在使用 Cubemap 进行环境映射时,我们需要使用世界空间下的反射方向对 Cubemap 进行采样。

2.1 在切线空间下计算

我们首先来实现第一种方法,即在切线空间下计算光照模型。基本思路是:在片元着色器中通过纹理采样得到切线空间下的法线,然后再与切线空间下的视角方向、光照方向等进行计算,得到最终的光照结果。为此,我们首先需要在顶点着色器中把视角方向和光照方向从模型空间变换到切线空间中,即我们需要知道从模型空间到切线空间的变换矩阵。这个变换矩阵的逆矩阵,即从切线空间到模型空间的变换矩阵是非常容易求得的,就只有坐标轴的旋转和原点的平移,而对于矢量变换,不需要平移,只有坐标轴的旋转,因此我们在顶点着色器中按切线 (x 轴)、副切线 (y 轴)、法线 (z 轴)的顺序按列排列即可得到从切线空间到模型空间的变换矩阵。如果一个变换中仅存在平移和旋转变换,那么这个变换的逆矩阵就等于它的转置矩阵,而从切线空间到模型空间的变换正是符合这样要求的变换。因此,从模型空间到切线空间的变换矩阵就是从切线空间到模型空间的变换矩阵的转置矩阵,我们把切线 (x 轴)、副切线 (y 轴)、法线 (z 轴)的顺序按行排列即可。CG 中矩阵填充默认刚好是按行填充。

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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
Shader "Unity Shaders Book/Chapter 7/Normal Map In Tangent Space" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
// 使用 "bump" 作为法线纹理的默认值,当没有提供任何法线纹理时,"bump"就对应了模型自带的法线信息
_BumpMap ("Normal Map", 2D) = "bump" {}
// 用于控制凹凸程度的系数,当它为 0 时,意味着该法线纹理不会对光照产生任何影响
_BumpScale ("Bump Scale", Float) = 1.0
_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"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST; //法线纹理坐标的缩放和偏移属性
float _BumpScale;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
//切线的类型是float4,这是因为我们需要第4个分量w来确定副切线方向,即切线空间的y轴方向
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};

struct v2f {
float4 pos : SV_POSITION;
// 因为要存储两个纹理坐标,因此定义为float4类型,xy存储纹理坐标,zw存储法线纹理坐标
float4 uv : TEXCOORD0;
//存储切线空间下的光照方向和视线方向
float3 lightDir: TEXCOORD1;
float3 viewDir : TEXCOORD2;
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
//纹理坐标变换
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

// 构造模型空间到切线空间的矩阵
// 副切线方向,由于法线和切线的叉乘有两个方向,因此切线的第4个分类w来决定使用哪个方向作为副切线方向
fixed3 Binormal = cross(v.normal, v.tangent.xyz) * v.tangent.w;
// 模型空间下的三个方向按行排列构成模型空间到切线空间的矩阵
float3x3 ObjToTangent = float3x3(v.tangent.xyz, Binormal, v.normal);

// 将光线方向和视线方向从模型空间转换到切线空间
o.lightDir = mul(ObjToTangent, ObjSpaceLightDir(v.vertex));
o.viewDir = mul(ObjToTangent, ObjSpaceViewDir(v.vertex));

return o;
}

fixed4 frag(v2f i) : SV_Target {
// 别忘了归一化
fixed3 tangentLightDir = normalize(i.lightDir);
fixed3 tangentViewDir = normalize(i.viewDir);

// 对法线纹理进行采样
fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
fixed3 tangentNormal;
// 如果没有把法线纹理的类型在Unity中设置成 "Normal map",我们就需要进行手动反映射
// 我们首先把 packedNormal 的 xy 分量按之前提到的公式映射回法线方向
// 然后乘以_BumpScale (控制凹凸程度) 来得到 tangentNormal 的 xy 分量
// tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale;
// 由于法线都是单位矢量,因此 tangentNormal.z 分量可以由 tangentNonnal.xy 计算而得到
// tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

// 我们通常会把法线纹理的纹理类型标识成 Normal map,此时_BumpMap的 rgb 分量并不再是切线空间下法线方向的 xyz 值了
// 因此如果我们再使用上面的方法来计算就会得到错误的结果
// 在这种情况下,我们可以使用 Unity 的内置函数 UnpackNormal 来得到正确的法线方向
tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

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

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

//使用法线纹理得到的法线计算漫反射和高光
fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(tangentNormal, tangentLightDir));
fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss);

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

ENDCG
}
}
FallBack "Specular"
}

我们可以调整材质面板中的 Bump Scale 属性控制凹凸程度,Bump Scale = -1 时渲染效果如下:

image-20220403150337945

Bump Scale = 1 时渲染效果如下:

image-20220403150354630

可以看出凹凸程度的正负决定了物体表面是“凸出来”还是“凹进去”。

2.2 在世界空间下计算

现在,我们在世界空间下计算光照。这时我们需要在片元着色器中把法线方向从切线空间变换到世界空间下。因此要先在顶点着色器中计算从切线空间到世界空间的变换矩阵,并把它传递给片元着色器。变换矩阵的计算可以由顶点的切线、副切线和法线在世界空间下的表示按列排列来得到。尽管这种方法需要更多的计算,但在需要使用 Cubemap 进行环境映射等情况下,我们就需要使用这种方法。

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
93
94
95
96
97
98
99
100
101
102
103
104
Shader "Unity Shaders Book/Chapter 7/Normal Map In World Space" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
// 使用 "bump" 作为法线纹理的默认值,当没有提供任何法线纹理时,"bump"就对应了模型自带的法线信息
_BumpMap ("Normal Map", 2D) = "bump" {}
// 用于控制凹凸程度的系数,当它为 0 时,意味着该法线纹理不会对光照产生任何影响
_BumpScale ("Bump Scale", Float) = 1.0
_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"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST; //法线纹理坐标的缩放和偏移属性
float _BumpScale;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
//切线的类型是float4,这是因为我们需要第4个分量w来确定副切线方向,即切线空间的y轴方向
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};

struct v2f {
float4 pos : SV_POSITION;
// 因为要存储两个纹理坐标,因此定义为float4类型,xy存储纹理坐标,zw存储法线纹理坐标
float4 uv : TEXCOORD0;
// 一个插值寄存器最多只能存储float4大小的变量,因此无法直接存储矩阵,需要按行存储
// 切线空间到世界空间的矩阵只需要3x3,还可以将世界空间下的顶点坐标存在w分量中以充分利用寄存器
float4 TangentToWorld0 : TEXCOORD01;
float4 TangentToWorld1 : TEXCOORD02;
float4 TangentToWorld2 : TEXCOORD03;
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
//纹理坐标变换
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

// 构造切线空间到世界空间的矩阵
// 将世界空间下的切线、副切线、法线按列排列即可,并将世界空间下的顶点坐标存入w分量
fixed3 WorldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 WorldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 WorldBiTangent = cross(WorldNormal, WorldTangent) * v.tangent.w;
float3 WorldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.TangentToWorld0 = float4(WorldTangent.x, WorldBiTangent.x, WorldNormal.x, WorldPos.x);
o.TangentToWorld1 = float4(WorldTangent.y, WorldBiTangent.y, WorldNormal.y, WorldPos.y);
o.TangentToWorld2 = float4(WorldTangent.z, WorldBiTangent.z, WorldNormal.z, WorldPos.z);

return o;
}

fixed4 frag(v2f i) : SV_Target {
// 得到世界空间下的顶点坐标
float3 WorldPos = float3(i.TangentToWorld0.w, i.TangentToWorld1.w, i.TangentToWorld2.w);
// 得到世界空间下的光照方向
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(WorldPos));
// 得到世界空间下的视线方向和半程向量
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(WorldPos));
fixed3 halfDir = normalize(worldLightDir + viewDir);

// 对法线纹理进行采样得到切线空间下的法线
fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
fixed3 tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
// 将法线转到世界空间下
float3x3 TangentToWorld = float3x3(i.TangentToWorld0.xyz, i.TangentToWorld1.xyz, i.TangentToWorld2.xyz);
fixed3 WorldNormal = normalize(mul(TangentToWorld, 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(WorldNormal, worldLightDir));
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(WorldNormal, halfDir)), _Gloss);

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

ENDCG
}
}
FallBack "Specular"
}

渲染效果和在切线空间下计算是完全一样的。这里需要特别说明结构体 v2f 中分别用三个向量存储矩阵,这是因为插值寄存器最多只能存储 float4 类型的变量,因此矩阵要分行从顶点着色器传递给片元着色器。从顶点着色器传递给片元着色器的过程中,插值寄存器中的值(顶点的属性)会被自动插值为片元的属性。

另外我们也可以导入高度纹理,然后在纹理面板勾选 Create from Grayscale 来自动生成切线空间的法线纹理。

3 渐变纹理

我们在图形学中学过,纹理在现代 GPU 中可以认为是一块可以支持快速查询的内存,因此不仅可以用来存储颜色,还可以用来存储任何属性,上面的法线贴图就是一个例子。另一个常见的用法就是使用渐变纹理来控制漫反射光照的结果。在之前计算漫反射光照时,我们都是使用表面法线和光照方向的点积结果与材质的反射率相乘来得到表面的漫反射光照。但有时,我们需要更加灵活地控制光照结果。

使用渐变纹理控制光照结果可以保证物体的轮廓线相比于之前使用的传统漫反射光照更加明显,而且能够提供多种色调变化,现在很多卡通风格的渲染中都使用了这种技术。

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
Shader "Unity Shaders Book/Chapter 7/Ramp Texture" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
// 定义一个纹理属性存储渐变纹理代替漫反射颜色
_RampTex ("Ramp Tex", 2D) = "white" {}
_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"

fixed4 _Color;
sampler2D _RampTex;
float4 _RampTex_ST;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};

struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
};

v2f vert(a2v v) {
v2f o;

o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
// 使用内置函数进行纹理坐标转换
o.uv = TRANSFORM_TEX(v.texcoord, _RampTex);

return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

// 使用半兰伯特模型
fixed halfLambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
// 使用半兰伯特对渐变纹理进行采样得到漫反射颜色
// 因为半兰伯特将夹角余弦从 [-1,1] 映射到了 [0,1],所以可以作为纹理坐标
// 渐变纹理本质上是一维纹理,纵坐标像颜色不变,所以纵坐标是多少实际上无所谓,这里同样使用halfLambert
fixed3 diffuseColor = tex2D(_RampTex, fixed2(halfLambert, halfLambert)).rgb * _Color.rgb;

fixed3 diffuse = _LightColor0.rgb * diffuseColor;

fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);

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

ENDCG
}
}
FallBack "Specular"
}

使用不同渐变纹理的渲染效果如下:

image-20220403162805665

需要注意的是我们要把渐变纹理的 Wrap Mode 设为 Clamp 模式,以防止对纹理进行采样时由于浮点数精度而造成的问题。下图是使用 Repeat 模式的渐变纹理效果:

image-20220403162942135

下图是使用 Clamp 模式的渐变纹理效果:

image-20220403163013842

可以看出在 Repeat 模式下高光区域存在一些黑点,这是由浮点精度造成的。当我们使用 fixed2(haIfLambert, halfLambert) 对渐变纹理进行采样时,虽然理论上 haIfLambert 的值在 [0, 1] 之间,但是可能会有 1.00001 这样的值出现。如果使用 Repeat 模式,此时就会舍
弃整数部分,只保留小数部分,得到的值就是 0.00001, 对应了渐变图中最左边的值,即黑色。因此,就会出现图中这样在高光区域有黑点的情况。所以我们只需要把渐变纹理的 Wrap Mode 设为 Clamp 模式就可以解决这种问题。超过 1 则截取到 1,就可以取到正常值。

4 遮罩纹理

遮罩纹理 (mask texture) 是极其有用的一种纹理,在很多商业游戏中都可以见到它的身影。那么什么是遮罩呢? 简单来讲,遮罩允许我们可以保护某些区域,使它们免于某些修改。例如,在之前的实现中,我们都是把高光反射应用到模型表面的所有地方,即所有的像素都使用同样大小的高光强度和高光指数。但有时,我们希望模型表面某些区域的反光强烈一些,而某些区域弱一些。为了得到更加细腻的效果,我们就可以使用一张遮罩纹理来控制光照。另一种常见的应用是在制作地形材质时需要混合多张图片,例如表现草地的纹理、表现石子的纹理、表现裸露土地的纹理等,使用遮罩纹理可以控制如何混合这些纹理。

使用遮罩纹理的流程一般是:通过采样得到遮罩纹理的纹素值,然后使用其中某个(或某几个)通道的值(例如 texel.r)来与某种表面属性进行相乘,这样,当该通道的值为 0 时,可以保护表面不受该属性的影响。总而言之, 使用遮罩纹理可以让美术人员更加精准(像素级别)地控制模型表面的各种性质。

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
Shader "Unity Shaders Book/Chapter 7/Mask Texture" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_BumpMap ("Normal Map", 2D) = "bump" {}
_BumpScale("Bump Scale", Float) = 1.0
// 定义一个高光反射遮罩纹理属性
_SpecularMask ("Specular Mask", 2D) = "white" {}
// 定义一个控制遮罩影响度的系数
_SpecularScale ("Specular Scale", Float) = 1.0
_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"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float _BumpScale;
sampler2D _SpecularMask;
float _SpecularScale;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};

struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 lightDir: TEXCOORD1;
float3 viewDir : TEXCOORD2;
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;

// 计算切线空间的光照方向和视线方向,使用了TANGENT_SPACE_ROTATION语义
TANGENT_SPACE_ROTATION;
o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz;

return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 tangentLightDir = normalize(i.lightDir);
fixed3 tangentViewDir = normalize(i.viewDir);

fixed3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv));
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

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

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

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

fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
// 获得高光遮罩纹理采样值并乘以遮罩影响系数得到掩码值
// 由于本次使用的遮罩纹理的 rgb 值都是一样的,因此使用哪个分量计算都可以,这里使用 r 分量计算掩码值
fixed specularMask = tex2D(_SpecularMask, i.uv).r * _SpecularScale;
// 使用高光遮罩纹理计算高光项,将掩码值乘到原本的高光项上即可
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss) * specularMask;

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

ENDCG
}
}
FallBack "Specular"
}

未使用遮罩纹理的渲染效果如下:

image-20220403165016553

使用高光遮罩纹理的效果如下:

image-20220403165056580

可以看出,遮罩纹理可以让我们更加精细地控制光照细节,得到更细腻的效果。

在真实的游戏制作过程中,遮罩纹理已经不止限于保护某些区域使它们免于某些修改,而是可以存储任何我们希望逐像素控制的表面属性。通常,我们会充分利用一张纹理的 RGBA 四个通道,用于存储不同的属性。例如,我们可以把高光反射的强度存储在 R 通道,把边缘光照的强度存储在 G 通道,把高光反射的指数部分存储在 B 通道,最后把自发光强度存储在 A 通道。

在游戏《DOTA2》的开发中,开发人员为每个模型使用了 4 张纹理:一张用于定义模型颜色,一张用于定义表面法线,另外两张则都是遮罩纹理。这样,两张遮罩纹理提供了共 8 种额外的表面属性,这使得游戏中的人物材质自由度很强,可以支持很多高级的模型属性。

---- 本文结束 知识又增加了亿点点!----

文章版权声明 1、博客名称:LycTechStack
2、博客网址:https://lz328.github.io/LycTechStack.github.io/
3、本博客的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系博主进行删除处理。
4、本博客所有文章版权归博主所有,如需转载请标明出处。