基础纹理(一)

本文最后更新于:2025年4月17日 下午

基础纹理

纹理最初的目的就是使用一张图片来控制模型的外观。使用纹理映射(texture mapping)技术,我们可以把一张图“黏”在模型表面,逐**纹素(texel)**地控制模型的颜色。

在美术人员建模的时候,通常会在建模软件中利用纹理展开技术把**纹理映射坐标(texture-mapping corrdinates)**存储在每个顶点上。纹理映射坐标定义了该顶点在纹理中对应的2D坐标。通常,这些坐标使用一个二维变量(u, v)来表示,其中 u 是横向坐标,而 v 是纵向坐标。因此,纹理映射坐标也被称为 UV 坐标。

尽管纹理的大小可以是多种多样的,例如可以是 256 $\times$ 256,或者 1028 $\times$ 1028,但顶点 UV 坐标的范围通常都是被归一化到[0, 1]范围内。需要注意的是,纹理采样时使用的纹理坐标不一定时在[0, 1]范围内。实际上,这种不在[0, 1]范围内的纹理坐标有时会非常有用。与之关系紧密的是纹理的平铺模式,它将决定渲染引擎在遇到不在[0, 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
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

Shader "Custom/Chapter7/Chapter7-SingleTexture"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_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 _MainTex;
float4 _MainTex_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, _MainTex);
return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
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"
}

与其他属性类型不同的是,我们还需要为纹理类型的属性声明一个 float4 类型的变量 _MainTex_ST。其中,_MainTex_ST 的名字不是任意起的。在Unity中,我们需要使用纹理名_ST的方式来声明某个纹理的属性。其中,ST 是缩放(scale)和平移(translation)的缩写。_MainTex_ST 可以让我们得到该纹理的缩放和平移(偏移)值,_MainTex_ST.xy 存储的是缩放值,而 _MainTex_ST.zw 存储的是偏移值。这些值可以在材质面板的纹理属性中调节,如下图所示。

材质面板的纹理属性

在顶点着色器中,我们使用纹理的属性值 _MainTex_ST 来对顶点纹理坐标进行变换,得到最终的纹理坐标。计算过程是,首先使用缩放属性 _MainTex_ST.xy 对顶点纹理坐标进行缩放,然后再使用偏移属性 _MainTex_ST.zw 对结果进行偏移。Unity 提供了一个内置宏 TRANSFORM_TEX 来帮我们计算上述过程。TRANSFORM_TEX 是在 UnityCG.cginc 中定义的:

1
2
//Transforms 2D UV by scale/bias property
#define TRANSFORM_TEX(tex,name) (tex.xy * name##_ST.xy + name##_ST.zw)

它接受两个参数,第一个参数是顶点纹理坐标,第二个参数是纹理名,在它的实现中,将利用纹理名_ST的方式来计算变换后的纹理坐标。

上面的代码首先计算了世界空间下的法线方向和光照方向。然后,使用 CG 的 tex2D 函数对纹理进行采样。它的第一个参数是需要被采样的纹理,第二个参数是一个 float2 类型的纹理坐标,它将返回计算得到的纹素值。我们使用采样结果和颜色属性 _Color 的乘积来作为材质的反射率 albedo,并把它和环境光照相乘得到的环境光部分。随后,我们使用 albedo 来计算漫反射光照的结果,并和环境光照、高光反射光照相加后返回。结果如下图:

SingleTexture

纹理的属性

在我们向 Unity 中导入一张纹理资源后,可以在它的材质面板上调整其属性,如下图所示。

纹理的属性

纹理面板中的第一个属性是纹理类型,我们之所以要为导入的纹理选择合适的类型,是因为只有这样才能让 Unity 知道我们的意图,为 Unity Shader 传递正确的纹理,并在一些情况下可以让 Unity 对该纹理进行优化。

下面一个属性非常重要——Wrap Mode。它决定了当纹理坐标超过[0, 1]范围后将如何被平铺。在 Repeat 模式下,如果纹理坐标超过了1,那么它的整数部分将会被舍弃,而直接使用小数部分进行采样,这样的结果是纹理将会不断重复;在 Clamp 模式下,如果纹理坐标大于1,那么将会截取到1,如果小于0,那么将会截取到0。

Repeat模式

Clamp模式

我们还可以在材质面板中调整纹理的偏移量,下图展示了在纹理的偏移属性为(0.2, 0.6)时分别使用 Repeat 和 Clamp 模式的结果。

Repeat模式下偏移

Clamp模式下偏移

纹理导入面板中 Filter Mode 属性决定了当纹理由于变换而产生拉伸时将会采用哪种滤波模式。Filter Mode 支持3种模式:Point,Bilinear 以及 Trilinear。它们得到的图片滤波效果依次提升,但需要耗费的性能也依次增大。纹理滤波会影响放大或缩小纹理时得到的图片质量。例如,当我们把一张 64 $\times$ 64 大小的纹理贴在一个 512 $\times$ 512 大小的平面上时,就需要放大纹理。下图给出了三种滤波模式下的放大结果。

Point模式放大图片

Bilinear模式放大图片

Trilinear模式放大图片

纹理缩小的过程比放大更加复杂一些,此时原纹理中的多个像素将会对应一个目标像素。纹理缩放更加复杂的原因在于我们往往需要处理抗锯齿问题,一个最常用的方法就是使用多级渐远纹理(mipmapping)技术,多级渐远纹理技术将原纹理提前用滤波处理来得到很多更小的图像,形成了一个图像金字塔,每一层都是对上一层图像降采样的结果。这样在实时运行时,就可以快速得到结果像素,例如当物体远离摄像机时,可以直接使用较小的纹理。但缺点是需要使用一定的空间用于存储这些多级渐远纹理,通常会多占用 33% 的内存空间。这是一种典型的用空间换取时间的方法。在 Unity 中,我们可以在纹理导入面板中勾选 Generate Mip Maps 即可开启多级渐远纹理技术。

下图给出了从一个倾斜的角度观察一个网格结构的地板时,使用不同的 Filter Mode (同时也使用了多级渐远纹理技术)得到的效果。

不同Filter Mode加多级渐远纹理技术

在内部实现上,Point 模式使用了**最近邻(nearest neighbor)**滤波,在放大或缩小时,它的采样像素数目通常只有一个,因此图像会看起来有种像素风格的效果。而 Bilinear 滤波则使用了线性滤波,对于每个目标像素,他会找到4个临近像素,然后对它们进行线性插值混合后得到的最终像素,因此图像看起来像被模糊了。而 Trilinear 滤波几乎是和 Bilinear 一样的,只是 Trilinear 还会在多级渐远纹理之间进行混合。如果一张纹理没有使用多级渐远纹理技术,那么 Trilinear 得到的结果是和 Bilinear 就一样的。通常我们会选择 Bilinear 滤波模式。需要注意的是,有时我们不希望纹理看起来是模糊的,例如对于一些类似棋盘的纹理,我们希望它就是像素风的,这时我们可能会选择 Point 模式。

最后,我们来讲一下纹理的最大尺寸和纹理模式。当我们在为不同平台发布游戏时,需要考虑目标平台的纹理尺寸和质量问题。Unity 允许我们为不同目标平台选择不同的分辨率,如下图所示。

选择纹理的最大尺寸和纹理模式

如果导入的纹理大小超过了 Max Texture Size 中的设置值,那么 Unity 将会把该纹理缩放为这个最大分辨率。理想情况下,导入的纹理可以是非正方形的,但长宽的大小应该是2的幂,例如2、4、8、16、32、64等。如果使用了非2的幂大小(Non Power of Two,NPOT)的纹理,那么这些纹理往往会占用更多的内存空间,而且GPU读取该纹理的速度也会有所下降。有一些平台甚至不支持这种 NPOT 纹理,这时 Unity 在内部会把它缩放成最近的2的幂大小。出于性能和空间的考虑,我们应该尽量使用2的幂大小的纹理。

而 Format 决定了 Unity 内部使用哪种格式来存储该纹理。使用的纹理格式精度越高,占用的内存空间越大,但得到的效果也越好。我们可以从纹理导入面板的最下方看到存储该纹理需要占用的内存空间(如果开启了多级渐远纹理技术,也会增加纹理的内存占用)。对于一些不需要使用很高精度的纹理(例如用于漫反射颜色的纹理),我们应该尽量使用压缩格式。

凹凸映射

纹理的另一种常见的应用就是凹凸映射(bump mapping)。凹凸映射的目的是使用一张纹理来修改模型表面的法线,以便为模型提供更多的细节。这种方法不会真的改变模型的顶点位置,只是让模型看起来好像是“凹凸不平”的,但可以从模型的轮廓处看出“破绽”。

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

高度纹理

我们首先来看第一种技术,即使用一张高度图来实现凹凸映射。高度图中存储的是强度值(intensity),它用于表示模型表面局部的海拔高度。因此,颜色越浅表明该位置的表面越向外凸起,而颜色越深表明该位置越向里凹。这种方法的好处是非常直观,我们可以从高度图中明确地知道一个模型表面的凹凸情况,但缺点是计算更加复杂,在实时计算时不能直接得到表面法线,而是需要由像素的灰度值计算而得,因此需要消耗更多的性能。下图是一张高度图。

高度图

高度图通常会和法线映射一起使用,用于给出表面凹凸的额外信息。也就是说,我们通常会使用法线映射来修改光照。

法线纹理

而法线纹理中存储的就是表面的法线方向。由于法线方向的分量范围在[-1, 1],而像素的分量范围为[0, 1],因此我们需要做一个映射,通常使用的映射就是:
$$
pixel = \cfrac{normal + 1}{2}
$$
这就要求,我们在Shader中对法线纹理进行采样后,还需要对结果进行一次反映射的过程,以得到原先的法线方向。反映射的过程实际就是使用上面映射函数的逆函数:
$$
noraml = pixel \times 2 - 1
$$
然而,由于方向是相对于坐标空间来说的,那么法线纹理中存储的法线方向在哪个坐标空间中呢?对于模型顶点自带的法线,它们是定义在模型空间中的,因此一种直接的想法就是将修改后的模型空间中的表面法线存储在一张纹理中,这种纹理被称为是模型空间的法线纹理(object-space normal map)。然而,在实际制作中,我们往往会采用另一种坐标空间,即模型顶点的**切线空间(tangent space)**来存储法线。对于模型的每个顶点,它都有一个属于自己的切线空间,这个切线空间的原点就是该顶点本身,而 z 轴是顶点的法线方向(n),x 轴是顶点的切线方向(t),而 y 轴可由法线和切线叉积而得,也被称为是副切线(bitangent,b)或副法线,如下图所示。

切线空间

这种纹理被称为是切线空间的法线纹理(tangent-space normal map)。下图分别给出了模型空间和切线空间下的法线纹理。

法线纹理

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

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

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

  • 实现简单,更加直观。我们甚至都不需要模型原始的法线和切线等信息,也就是说,计算更少。生成它也非常简单,而如果要生成切线空间下的法线纹理,由于模型的切线一般是和 UV 方向相同,因此想要得到效果比较好的法线映射就要求纹理映射也是连续的。
  • 在纹理坐标的缝合处和尖锐的边角部分,可见的突变(缝隙)较少,即可以提供平滑的边界。这是因为模型空间下的法线纹理存储的是同一坐标系下的法线信息,因此在边界处通过插值得到的法线可以平滑变换。而切线空间下的法线纹理中的法线信息是依靠纹理坐标的方向得到的结果,可能会在边缘处或尖锐的部分造成更多可见的缝合迹象。

但使用切线空间有更多优点。

  • 自由度很高。模型空间下的法线纹理记录的是绝对法线信息,仅可用于创建它时的那个模型,而应用到其他模型上效果就完全错误了。而切线空间下的法线纹理记录的是相对法线信息,这意味着,即便把该纹理应用到一个完全不同的网格上,也可以得到一个合理的结果。
  • 可以进行 UV 动画。比如,我们可以移动一个纹理的 UV 坐标来实现一个凹凸移动的效果,但使用模型空间下的法线纹理会得到完全错误的结果。原因同上。这种 UV 动画在水或者火山熔岩这种类型的物体上会经常用到。
  • 可以重用法线纹理。比如,一个砖块,我们仅使用一张法线纹理就可以用到所有的6个面上。原因同上。
  • 可压缩。由于切线空间下的法线纹理中法线的 Z 方向总是正方向,因此我们可以仅存储 XY 方向,而推导得到 Z 方向。而模型空间下的法线纹理由于每个方向都是可能的,因此必须存储3个方向的值,不可压缩。

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

实践

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

在切线空间下计算

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

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
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

Shader "Custom/Chapter7/Chapter7-NormalMapTangentSpace"
{
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
_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 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};

struct v2f {
float4 pos : SV_POSITION;
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;

//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 build-in macro
TANGENT_SPACE_ROTATION;

//Transform the light direction from object space to tangent space
o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
//Transform the view direction from object space to tangent space
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);

//Get the texel in the normal map
fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
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 function
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"
}

对于法线纹理_BumpMap,我们使用"bump"作为它的默认值。"bump"是 Unity 内置的法线纹理,当没有提供任何法线纹理时,"bump"就对应了模型自带的法线信息。_BumpScale则是用于控制凹凸程度的,当它为0时,意味着该法线纹理不会对光照产生任何影响。我们使用 TANGENT 语义来描述 float4 类型的 tangent 变量,以告诉 Unity 把顶点的切线方向填充到 tangent 变量中。需要注意的是,和法线 normal 不同,tangent 的类型是 float4,而非 float3,这是因为我们需要使用 tangent.w 分量来决定切线空间中的第三个坐标轴——副切线的方向性。

由于我们使用了两张纹理,因此需要存储两个纹理坐标。为此,我们把 v2f 中的 uv 变量的类型定义为 float4 类型,其中 xy 分量存储了 _MainTex 的纹理坐标,而 zw 分量存储了 _BumpMap 的纹理坐标(实际上,_MainTex 和 _Bumpmap 通常会使用同一组纹理坐标,出于减少插值寄存器的使用数目的目的,我们往往只计算和存储一个纹理坐标即可)。然后,我们把模型空间下切线方向、副切线方向和法线方向按行排列来得到从模型空间到切线空间的变换矩阵 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 分量可以由 tangentNormal.xy 计算而得。由于我们使用的是切线空间下的法线纹理,因此可以保证法线方向的 z 分量为正。在 Unity 中,为了方便 Unity 对法线纹理的存储进行优化,我们通常会把法线纹理的纹理类型标识成 Normal Map,Unity 会根据平台来选择不同的压缩方法。这时,如果我们再使用上面的方法来计算就会得到错误的结果,因为此时 _BumpMap 的 rgb 分量不再是切线空间下法线方向的 xyz 值了。这种情况下,我们可以使用 Unity 的内置函数 UnpackNormal 来得到正确的法线方向。

结果如下:

在切线空间下计算光照

在世界空间下计算

现在,我们来实现在世界空间下计算光照模型。我们需要在片元着色器中把法线方向从切线空间变换到世界空间下。这种方法的基本思想是:在顶点着色器中计算从切线空间到世界空间的变换矩阵,并把它传递给片元着色器。变换矩阵的计算可以由顶点的切线、副切线和法线在世界空间下的表示来得到。最后,我们只需要在片元着色器中把法线纹理中的法线方向从切线空间变换到世界空间下即可。尽管这种方法需要更多的计算,但在需要使用 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
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

Shader "Custom/Chapter7/Chapter7-NormalMapWorldSpace"
{
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
_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 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};

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

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;

float3 worldPos = mul(unity_ObjectToWorld, 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);

return o;
}

fixed4 frag(v2f i) : SV_Target {
//Get the position in world space
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
//Compute the light and view dir in world space
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));

//Get the normal in tangent space
fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));
bump.xy *= _BumpScale;
bump.z = sqrt(1.0 - saturate(dot(bump.xy, bump.xy)));
//Transform the normal from tangent space to world space

bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));

fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(bump, lightDir));
fixed3 halfDir = normalize(lightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(bump, halfDir)), _Gloss);
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}

FallBack "Specular"
}

一个插值寄存器最多只能存储 float4 大小的变量,对于矩阵这样的变量,我们可以把它们按行拆成多个变量再进行存储。上面代码中的 TtoW0、TtoW1、和 TtoW2 就依次存储了从切线空间到世界空间的变换矩阵的每一行。实际上,对方向矢量的变换只需要使用 3 $\times$ 3 大小的矩阵,也就是说,每一行只需要使用 float3 类型的变量即可。但为了充分利用插值寄存器的存储空间,我们把世界空间下的顶点位置存储在这些变量的 w 分量中。

我们首先从 TtoW0、TtoW1、和 TtoW2 的 w 分量中构建世界空间下的坐标。然后,使用内置的 UnityWorldSpaceLightDir 和 UnityWorldSpaceViewDir 函数得到世界空间下的光照和视角方向。接着,我们使用内置的 UnpackNormal 函数对法线纹理进行采样和解码(需要把法线纹理的格式标识成 Normal Map),并使用 _BumpScale 对其进行缩放。最后,我们使用 TtoW0、TtoW1、和 TtoW2 存储的变换矩阵把法线变换到世界空间下。这是通过使用点乘操作来实现矩阵的每一行和法线相乘来得到的。从视觉表现上,在切线空间下和在世界空间下计算光照几乎没有任何差别。

Unity中的法线纹理类型

法线纹理标识成Normal map

上面我们提到了当把法线纹理的纹理类型标识成 Normal Map 时,可以使用 Unity 的内置函数 UnpackNormal 来得到正确的法线方向。当我们需要使用那些包含了法线映射的内置的 Untiy Shader 时,必须把使用的法线纹理标识成 Normal map 才能得到正确的结果,这是因为这些 Unity Shader 都使用了内置的 UnpackNormal 函数来采样法线方向。当我们把纹理类型设置成 Normal map 可以让 Unity 根据不同平台对纹理进行压缩,再通过 UnpackNormal 函数来针对不同的压缩格式对法线纹理进行正确的采样。

当我们把纹理类型设置成 Normal map 后,还有一个复选框是 Create from Grayscale,它是用于从高度图中生成法线纹理的。高度图本身记录的是相对高度,是一张灰度图,白色表示相对更高,黑色表示相对更低。当我们把一张高度图导入到 Unity 后,除了需要把它的纹理类型设置成 Normal map 外,还需勾选 Create from Grayscale,这样就可以得到类似下图的结果。然后,我们就可以把它和切线空间下的法线纹理同等对待了。

Unity根据高度图生成切线空间下的法线纹理

当勾选了 Create from Grayscale后,还多出了下两个选项——Bumpiness 和 Filtering。其中 Bumpiness 用于控制凹凸程度,而 Filtering 决定我们使用哪种方式来计算凹凸程度,它有两种选项:一种是 Smooth,这使得生成后的法线纹理会比较平滑;另一种是 Sharp,它会使用 Sobel 滤波(一种边缘检测时使用的滤波器)来生成法线。Sobel 滤波的实现非常简单,我们只需要在一个 3 $\times$ 3 的滤波器中计算 x 和 y 方向上的导数,然后从中得到法线即可。具体方法是:对于高度图中的每个像素,我们考虑它与水平方向和竖直方向上的像素差,把它们的差当成该点对应的法线在 x 和 y 方向上的位移,然后使用之前提到的映射函数存储成到法线纹理的 r 和 g 分量即可。

参考

《Unity Shader入门精要》


基础纹理(一)
http://example.com/posts/基础纹理(一)/
作者
祭零小白
发布于
2022年1月22日
许可协议