Unity 渲染原理(十)Unity基础纹理(普通纹理与法线纹理)

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

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

纹理大小可以是多种多样的,例如可以是256*256的,但是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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
Shader "Unlit/SingleTexture"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Color("Color Tint", Color) = (1,1,1,1)
_Specular("Specular", Color) = (1,1,1,1)
_Gloss("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100

Pass
{
Tags { "LightMode" = "UniversalForward" }

CGPROGRAM
#include "Lighting.cginc"
#pragma vertex vert
#pragma fragment frag

fixed4 _Color;
sampler2D _MainTex;
// 每个贴图都有对应的一套隐含的属性,其中{贴图名}_ST表示的是改贴图的缩放和偏移
float4 _MainTex_ST;
fixed4 _Specular;
float _Gloss;

struct a2v
{
// 这个之所以是float4,是因为xyzw是齐次坐标,w为1表示的是点坐标,为0表示的是向量
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};

// 片元着色器的语义的作用可以看:https://docs.unity3d.com/cn/2019.4/Manual/SL-ShaderSemantics.html
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 = v.texcoord.xy * _MainTex_ST.xy * _MainTex_ST.zw;

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"
}

Unity纹理的属性

我们在将图片导入Unity后,有很多的属性可供选择。

我们就挑其中比较关键的讲一下:

  • 纹理类型。有Texture,Normal Map和CubeMap可选,我们目前用到的是Texture
  • Wrap Mode。决定了纹理坐标超过[0,1]范围时如何平铺,有两种选择,一种是Repeat,在这种模式下,如果纹理坐标超过了1,那么它的整数部分会被舍弃,而直接使用小数部分进行采样,这样的结果是纹理将会不断重复(也就是说,如果大于1,那么就减一乘十,如果还是大于1,那么继续减一乘十),另一种模式是Clamp,如果纹理坐标大于1,那么就选择1,如果小于0,就截取到0
  • Filter Mode。决定了由于变换产生拉伸时将会采取那种滤波方式,有三种选择,Point,Billinear以及Trilinear,滤波效果一次提升,但是消耗也依次增大(Trillinear需要借助Minmap的结果,如果没有开启minmap,那么Trillnear和Billinear的结果是相同的)
  • Minmap。如果遇到了纹理缩小的情况,原纹理中的多个像素将会对应一个目标像素,这个时候问题比较复杂,因为我们往往需要处理抗锯齿问题。其中一个方式就是使用多级渐远纹理(mipmapping),该技术会提前使用滤波技术来得到很多更小的图像,形成一个图像金字塔,每一层都是对上一层采样的结果,这样在实时运行时,就可以快速得到结果像素,这是典型的空间换时间的做法。
  • Read/Write Enable。如果你想在运行时通过代码读取该贴图,需要开启该选项,但是记住,一旦开启,该贴图占用的内存会翻倍

凹凸映射

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

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

高度纹理

使用一张高度图来实现凹凸映射,高度图中存储的是强度值,用于表示模型表面局部的海拔高度。颜色越浅表明该位置的表面越向外凸起,颜色越深表明该位置越往里凹。这种方法的好处是非常直观,但缺点是计算更加复杂,在实时计算时不能直接得到表面法线,而是需要由像素的灰度值计算而得,因此需要消耗更多的性能。

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

法线纹理

法线纹理中存储的就是表面的法线方向。由于发现方向的分量范围在[-1,1]之间,而像素的分量范围为[0,1],因此我们需要做一个映射,通常使用的映射是

pixel=normal+12pixel = \frac{normal + 1}{2}

这就要求我们在Shader中对法线纹理采样后,还需要对结果进行一次发映射的过程,以得到原本的法线方向。反映射的过程其实就是使用上面的映射函数的逆函数

normal=pixel21normal = pixel * 2 -1

由于方向是相对于坐标空间来讲的,那么法线纹理中存储的法线方向在哪个坐标空间中呢?对于模型自带的法线,它是定义在模型空间中的,因此一种直接的想法就是将修改后的模型空间中的表面法线存储在一张纹理中,这种纹理被称为是模型空间的法线纹理(object-space normal map)。然而,在实际制作过程中,我们往往会采用另一种坐标空间,即模型定点的切线空间来存储法线。对于模型的每个顶点,它都有一个属于自己的切线空间,这个切线空间的原点就是该顶点本身,而z轴是顶点的法线方向,x轴是顶点的切线方向,而y轴由x和z叉积而得,也被称为是副切线或者副法线,这种纹理被称为是切线空间的法线纹理(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),经过映射后存储在纹理中就对应了浅蓝色。

不同空间下法线纹理的对比

总的来说,模型空间下的法线纹理更符合人们的直观认知,而法线纹理本身也很直观,容易调整,不同的法线方向就代表了不同的颜色。但是美术人员更喜欢切线空间下的法线纹理,这是为什么呢?

实际上,法线本身存储在哪个坐标系中都是可以的,我们甚至可以存储在世界空间下,但问题是,我们并不是想要单纯的得到法线,后续的光照计算才是我们的目的。而选择那个坐标系意味着我们需要把不同的信息转换到相应的坐标系中,如果选择了切线空间,我们需要把从法线纹理中得到的法线方向从切线空间转换到世界空间中

总的来说,使用模型空间的法线优点如下;

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

但是切线空间有更多的优点:

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

实践

我们需要在计算光照模型中统一各个方向矢量所在的坐标空间。由于法线纹理中存储的法线是切线空间下的方向,因此我们通常有两种选择:一种是在切线空间下进行光照计算,此时我们需要把光照方向,视角方向变换到切线空间下;另一种选择是在世界空间下进行光照计算,此时我们需要把采样得到的法线方向变换到世界空间下,再和世界空间下的光照方向和视角方向进行计算。

从效率上来讲,第一种方法往往要优于第二种方法,因为我们可以在顶点着色器中就完成对光照方向和视角方向的变换,而第二种方法由于要先对法线纹理进行采样,所以变换过程必须在片元着色器中实现,这意味着我们要在片元着色器中进行一次矩阵操作。但是从通用型角度而言,第二种方法要优于第一种方法,因为有时候我们要在世界空间下进行一些计算,比如使用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
Shader "Unlit/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
{
Tags {"RenderType" = "Opaque"}
LOD 100

Pass
{
Tags{"LightMode" = "UniversalForward"}
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;

TANGENT_SPACE_ROTATION;
// TANGENT_SPACE_ROTATION 宏 相当于嵌入如下两行代码:
//
//    float3 binormal = cross( v.normal, v.tangent.xyz ) * v.tangent.w;
//    float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal );定义转换object space的向量到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);

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)));

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"
}

在世界空间下计算

我们需要在片元着色器中把法线方向从切线空间变换到世界空间下。这种方法的基本思想是,在顶点着色器中计算从切线空间到世界空间的变换矩阵,并把它传递给片元着色器。变换矩阵的计算可以由顶点的切线,副切线和法线在世界空间下的表示得到。最后,我们只需要在片元着色器中吧法线纹理中的法线方向从切线空间变换到世界空间下即可。

尽管这种方法需要更多的计算,但是在需要使用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
Shader "Unlit/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
{
Tags {"RenderType" = "Opaque"}
LOD 100

Pass
{
Tags{"LightMode" = "UniversalForward"}
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: TEXCOORD0;
float4 TtoW1: TEXCOORD1;
float4 TtoW2: 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;

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;

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
{
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));

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

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"
}